Basic login UI flow

This commit is contained in:
Damontecres 2025-12-23 16:16:03 -05:00
parent a2884a7225
commit 6046799b91
No known key found for this signature in database
17 changed files with 577 additions and 69 deletions

View file

@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
@ -44,6 +45,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
@ -97,6 +99,12 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var refreshRateService: RefreshRateService
@Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
@Inject
lateinit var userSwitchListener: UserSwitchListener
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class)

View file

@ -3,17 +3,24 @@ package com.github.damontecres.wholphin.api.seerr
import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import okhttp3.Call
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import timber.log.Timber
class SeerrApiClient(
val baseUrl: String,
val apiKey: String?,
private val apiKey: String?,
okHttpClient: OkHttpClient,
) {
private val cookieJar = SeerrCookieJar()
private val client =
okHttpClient
.newBuilder()
.cookieJar(cookieJar)
.addInterceptor {
Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}")
it.proceed(
@ -26,6 +33,11 @@ class SeerrApiClient(
)
}.build()
val hasValidCredentials: Boolean
get() =
apiKey.isNotNullOrBlank() ||
cookieJar.hasValidCredentials(baseUrl)
private fun <T : ApiClient> create(initializer: (String, Call.Factory) -> T): Lazy<T> =
lazy {
initializer.invoke(baseUrl, client)
@ -50,3 +62,26 @@ class SeerrApiClient(
val usersApi by create(::UsersApi)
val watchlistApi by create(::WatchlistApi)
}
private class SeerrCookieJar : CookieJar {
private val cookies = mutableMapOf<String, List<Cookie>>()
override fun saveFromResponse(
url: HttpUrl,
cookies: List<Cookie>,
) {
cookies
.filter { it.name == "connect.sid" }
.groupBy { it.domain }
.forEach { (domain, cookies) ->
this.cookies[domain] = cookies
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> = this.cookies[url.host].orEmpty()
fun hasValidCredentials(baseUrl: String): Boolean =
baseUrl.toHttpUrlOrNull()?.host?.let { domain ->
cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() }
} == true
}

View file

@ -9,7 +9,6 @@ import androidx.room.Update
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrServerUsers
import com.github.damontecres.wholphin.data.model.SeerrUser
import java.util.UUID
@Dao
interface SeerrServerDao {
@ -32,22 +31,25 @@ interface SeerrServerDao {
suspend fun updateUser(user: SeerrUser) = addUser(user)
@Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinRowId = :userId")
@Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
suspend fun getUser(
serverId: Int,
userId: UUID,
jellyfinUserRowId: Int,
): SeerrUser?
@Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId")
suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List<SeerrUser>
@Query("DELETE FROM seerr_servers WHERE id = :serverId")
suspend fun deleteServer(serverId: Int)
@Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinRowId = :jellyfinRowId")
@Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
suspend fun deleteUser(
serverId: Int,
jellyfinRowId: Int,
jellyfinUserRowId: Int,
)
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinRowId)
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)
@Transaction
@Query("SELECT * FROM seerr_servers")

View file

@ -38,14 +38,14 @@ data class SeerrServer(
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("jellyfinRowId"),
childColumns = arrayOf("jellyfinUserRowId"),
onDelete = ForeignKey.CASCADE,
),
],
primaryKeys = ["jellyfinRowId", "serverId"],
primaryKeys = ["jellyfinUserRowId", "serverId"],
)
data class SeerrUser(
val jellyfinRowId: Int,
val jellyfinUserRowId: Int,
val serverId: Int,
val authMethod: SeerrAuthMethod,
val username: String?,
@ -53,7 +53,7 @@ data class SeerrUser(
val credential: String?,
) {
override fun toString(): String =
"SeerrUser(jellyfinRowId=$jellyfinRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
}
enum class SeerrAuthMethod {

View file

@ -842,6 +842,13 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val SeerrIntegration =
AppClickablePreference<AppPreferences>(
title = R.string.seerr_integration,
getter = { },
setter = { prefs, _ -> prefs },
)
}
}
@ -903,6 +910,7 @@ val basicPreferences =
title = R.string.more,
preferences =
listOf(
AppPreference.SeerrIntegration,
AppPreference.AdvancedSettings,
),
),

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.content.SharedPreferences
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext
@ -11,18 +10,20 @@ class SeerrApi(
@param:ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("seerr", Context.MODE_PRIVATE)
// private val prefs: SharedPreferences =
// context.getSharedPreferences("seerr", Context.MODE_PRIVATE)
var api: SeerrApiClient =
SeerrApiClient(
prefs.getString("baseUrl", null)!! + "/api/v1",
prefs.getString("apiKey", null)!!,
okHttpClient,
// prefs.getString("baseUrl", null)!! + "/api/v1",
// prefs.getString("apiKey", null)!!,
baseUrl = "",
apiKey = null,
okHttpClient = okHttpClient,
)
private set
val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && api.apiKey.isNotNullOrBlank()
val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
fun update(
baseUrl: String,

View file

@ -1,16 +1,29 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
import com.github.damontecres.wholphin.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -21,6 +34,7 @@ class SeerrServerRepository
private val seerrApi: SeerrApi,
private val seerrServerDao: SeerrServerDao,
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
private val _currentServer = MutableStateFlow<SeerrServer?>(null)
val currentServer: StateFlow<SeerrServer?> = _currentServer
@ -37,6 +51,24 @@ class SeerrServerRepository
}
}
fun clear() {
_currentServer.update { null }
_currentUser.update { null }
seerrApi.update("", null)
}
suspend fun set(
server: SeerrServer,
user: SeerrUser,
) {
_currentServer.update {
_currentUser.update {
user
}
server
}
}
suspend fun addAndChangeServer(
url: String,
apiKey: String,
@ -51,7 +83,7 @@ class SeerrServerRepository
// TODO test api key
val user =
SeerrUser(
jellyfinRowId = jellyfinUser.rowId,
jellyfinUserRowId = jellyfinUser.rowId,
serverId = server.id,
authMethod = SeerrAuthMethod.API_KEY,
username = null,
@ -59,8 +91,6 @@ class SeerrServerRepository
credential = apiKey,
)
seerrServerDao.addUser(user)
// TODO user info
seerrApi.api.usersApi.authMeGet()
seerrApi.update(server.url, apiKey)
_currentServer.update {
@ -86,47 +116,127 @@ class SeerrServerRepository
}
server?.server?.let { server ->
serverRepository.currentUser.value?.let { jellyfinUser ->
val response =
when (authMethod) {
SeerrAuthMethod.LOCAL -> {
seerrApi.api.authApi.authLocalPostWithHttpInfo(
AuthLocalPostRequest(
email = username,
password = password,
),
)
}
// TODO Need to update server early so that cookies are saved
seerrApi.update(server.url, null)
login(seerrApi.api, authMethod, username, password)
SeerrAuthMethod.JELLYFIN -> {
seerrApi.api.authApi.authJellyfinPostWithHttpInfo(
AuthJellyfinPostRequest(
username = username,
password = password,
),
)
}
SeerrAuthMethod.API_KEY -> {
throw IllegalArgumentException("Cannot authenticate with API key using this function")
}
}
response.headers["Cookies"]
// TODO get session cookie
val user =
SeerrUser(
jellyfinRowId = jellyfinUser.rowId,
jellyfinUserRowId = jellyfinUser.rowId,
serverId = server.id,
authMethod = SeerrAuthMethod.API_KEY,
authMethod = authMethod,
username = username,
password = password,
credential = null,
)
seerrServerDao.addUser(user)
_currentServer.update {
_currentUser.update {
user
}
server
}
}
}
}
suspend fun testConnection(
authMethod: SeerrAuthMethod,
url: String,
username: String?,
passwordOrApiKey: String,
): LoadingState {
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient)
try {
login(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success
} catch (ex: Exception) {
Timber.w(ex, "Error testing seerr connection")
return LoadingState.Error(ex)
}
}
}
data class CurrentSeerr(
val server: SeerrServer,
val user: SeerrUser,
)
private suspend fun login(
client: SeerrApiClient,
authMethod: SeerrAuthMethod,
username: String?,
password: String?,
): User =
when (authMethod) {
SeerrAuthMethod.LOCAL -> {
client.authApi.authLocalPost(
AuthLocalPostRequest(
email = username ?: "",
password = password ?: "",
),
)
}
SeerrAuthMethod.JELLYFIN -> {
client.authApi.authJellyfinPost(
AuthJellyfinPostRequest(
username = username ?: "",
password = password ?: "",
),
)
}
SeerrAuthMethod.API_KEY -> {
client.usersApi.authMeGet()
}
}
@ActivityScoped
class UserSwitchListener
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
) {
init {
Timber.v("Launching")
context as AppCompatActivity
context.lifecycleScope.launchIO {
serverRepository.currentUser.asFlow().collect { user ->
Timber.v("New user")
seerrServerRepository.clear()
if (user != null) {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.firstOrNull()
?.let { seerrUser ->
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
seerrApi.update(server.url, seerrUser.credential)
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
try {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
}
}
seerrServerRepository.set(server, seerrUser)
}
}
}
}
}
}
}

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@ -61,6 +62,10 @@ object DatabaseModule {
@Singleton
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
@Provides
@Singleton
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
@Provides
@Singleton
fun userPreferencesDataStore(

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -84,6 +86,8 @@ fun PreferencesContent(
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
var showSeerrServerDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
@ -381,6 +385,22 @@ fun PreferencesContent(
)
}
AppPreference.SeerrIntegration -> {
ClickPreference(
title = stringResource(pref.title),
onClick = { showSeerrServerDialog = true },
modifier = Modifier,
summary =
if (seerrIntegrationEnabled) {
stringResource(R.string.enabled)
} else {
null
},
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
@ -440,6 +460,11 @@ fun PreferencesContent(
)
}
}
if (showSeerrServerDialog) {
AddSeerServerDialog(
onDismissRequest = { showSeerrServerDialog = false },
)
}
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerPreferencesDao
@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.combine
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@ -43,6 +46,7 @@ class PreferencesViewModel
private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo,
) : ViewModel(),
@ -52,6 +56,11 @@ class PreferencesViewModel
val currentUser get() = serverRepository.currentUser
val seerrEnabled =
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
}
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->

View file

@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@ -54,7 +53,7 @@ class SeerrDiscoverViewModel
sort = MediaApi.SortMediaGet.MEDIA_ADDED,
).results
.orEmpty()
Timber.v(tv.firstOrNull()?.jellyfinMediaId)
// Timber.v(tv.firstOrNull()?.jellyfinMediaId)
}
viewModelScope.launchIO {
val movies = seerrService.discoverMovies()

View file

@ -31,14 +31,15 @@ import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun AddSeerrServerContent(
fun AddSeerrServerApiKey(
onSubmit: (url: String, apiKey: String) -> Unit,
errorMessage: String?,
status: LoadingState,
modifier: Modifier = Modifier,
) {
var error by remember(errorMessage) { mutableStateOf(errorMessage) }
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
@ -135,3 +136,141 @@ fun AddSeerrServerContent(
)
}
}
@Composable
fun AddSeerrServerUsername(
onSubmit: (url: String, username: String, password: String) -> Unit,
username: String,
status: LoadingState,
modifier: Modifier = Modifier,
) {
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.focusGroup()
.padding(16.dp)
.wrapContentSize(),
) {
var url by remember { mutableStateOf("") }
var username by remember { mutableStateOf(username) }
var password by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = "Enter URL, username, & password",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "URL",
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
value = url,
onValueChange = {
error = null
url = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
keyboardActions =
KeyboardActions(
onNext = {
usernameFocusRequester.tryRequestFocus()
},
),
isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "Username",
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
value = username,
onValueChange = {
error = null
username = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
keyboardActions =
KeyboardActions(
onNext = {
passwordFocusRequester.tryRequestFocus()
},
),
isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "Password",
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
value = password,
onValueChange = {
error = null
password = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions =
KeyboardActions(
onGo = { onSubmit.invoke(url, username, password) },
),
isInputValid = { true },
modifier = Modifier.focusRequester(passwordFocusRequester),
)
}
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleLarge,
)
}
TextButton(
stringRes = R.string.submit,
onClick = { onSubmit.invoke(url, username, password) },
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}

View file

@ -0,0 +1,106 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun AddSeerServerDialog(
onDismissRequest: () -> Unit,
viewModel: SwitchSeerrViewModel = hiltViewModel(),
) {
val currentUser by viewModel.currentUser.observeAsState()
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
val status by viewModel.serverConnectionStatus.collectAsState(LoadingState.Pending)
LaunchedEffect(status) {
if (status is LoadingState.Success) {
onDismissRequest.invoke()
}
}
when (val auth = authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
-> {
BasicDialog(
onDismissRequest = { authMethod = null },
) {
AddSeerrServerUsername(
onSubmit = { url, username, password ->
viewModel.submitServer(
url,
username,
password,
auth,
)
},
username = currentUser?.name ?: "",
status = status,
)
}
}
SeerrAuthMethod.API_KEY -> {
BasicDialog(
onDismissRequest = { authMethod = null },
) {
AddSeerrServerApiKey(
onSubmit = { url, apiKey -> viewModel.submitServer(url, apiKey) },
status = status,
)
}
}
null -> {
ChooseSeerrLoginType(
onDismissRequest = onDismissRequest,
onChoose = { authMethod = it },
)
}
}
}
@Composable
fun ChooseSeerrLoginType(
onDismissRequest: () -> Unit,
onChoose: (SeerrAuthMethod) -> Unit,
) {
val params =
remember {
DialogParams(
fromLongClick = false,
title = "Login to Seerr server",
items =
listOf(
DialogItem(
text = "API Key",
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
),
DialogItem(
text = "Jellyfin user",
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
),
DialogItem(
text = "Local user",
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
),
),
)
}
DialogPopup(
params = params,
onDismissRequest = onDismissRequest,
dismissOnClick = false,
)
}

View file

@ -1,10 +1,18 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@HiltViewModel
class SwitchSeerrViewModel
@ -12,10 +20,65 @@ class SwitchSeerrViewModel
constructor(
private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
) : ViewModel() {
val currentUser = serverRepository.currentUser
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
private fun cleanUrl(url: String) =
if (!url.endsWith("/api/v1")) {
url
.toHttpUrlOrNull()
?.newBuilder()
?.apply {
addPathSegment("api")
addPathSegment("v1")
}?.build()
.toString()
} else {
url
}
fun submitServer(
url: String,
apiKey: String,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
seerrServerRepository.testConnection(
authMethod = SeerrAuthMethod.API_KEY,
url = url,
username = null,
passwordOrApiKey = apiKey,
)
if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, apiKey)
}
serverConnectionStatus.update { result }
}
}
fun submitServer(
url: String,
username: String,
password: String,
authMethod: SeerrAuthMethod,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
seerrServerRepository.testConnection(
authMethod = authMethod,
url = url,
username = username,
passwordOrApiKey = password,
)
if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, authMethod, username, password)
}
serverConnectionStatus.update { result }
}
}
}

View file

@ -402,6 +402,7 @@
<string name="discover">Discover</string>
<string name="request">Request</string>
<string name="pending">Pending</string>
<string name="seerr_integration">Seerr integration</string>
<string-array name="theme_song_volume">
<item>Disabled</item>

View file

@ -135,9 +135,6 @@ components:
readOnly: true
required:
- id
- email
- createdAt
- updatedAt
UserSettings:
type: object
properties: