diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt new file mode 100644 index 00000000..1ff7212b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SeerrPluginLogin { + @SerialName("None") + @Serializable + data object None : SeerrPluginLogin + + @SerialName("ApiKey") + @Serializable + data class ApiKey( + val apiKey: String, + ) : SeerrPluginLogin + + @SerialName("Local") + @Serializable + data class Local( + val local: SeerrPluginLocalLogin, + ) : SeerrPluginLogin +} + +@Serializable +data class SeerrPluginLocalLogin( + val username: String, + val password: String, +) + +@Serializable +data class SeerrPluginSettings( + val version: Int = 1, + val serverUrl: String? = null, + val login: SeerrPluginLogin? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index ea170950..0d0eb0b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -9,20 +9,24 @@ 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.SeerrPermission +import com.github.damontecres.wholphin.data.model.SeerrPluginLogin +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -39,26 +43,25 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _connection = - MutableStateFlow(SeerrConnectionStatus.NotConfigured) - val connection: StateFlow = _connection + private val _connection = MutableStateFlow(SeerrState()) + val connection: StateFlow = _connection val current: Flow = - _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not */ val active: Flow = - connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } + connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _connection.update { SeerrConnectionStatus.NotConfigured } + _connection.update { SeerrState() } seerrApi.update("", null) } @@ -67,7 +70,7 @@ class SeerrServerRepository user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(server, user, exception) } + _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } seerrApi.update("", null) } @@ -78,8 +81,9 @@ class SeerrServerRepository ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() _connection.update { - SeerrConnectionStatus.Success( - CurrentSeerr(server, user, userConfig, publicSettings), + it.copy( + connection = + SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), ) } } @@ -169,7 +173,7 @@ class SeerrServerRepository suspend fun removeServerForCurrentUser(): Boolean { val user = - when (val conn = connection.first()) { + when (val conn = connection.value.connection) { SeerrConnectionStatus.NotConfigured -> return false is SeerrConnectionStatus.Error -> conn.user is SeerrConnectionStatus.Success -> conn.current.user @@ -178,6 +182,81 @@ class SeerrServerRepository clear() return rows > 0 } + + /** + * Given the [SeerrPluginSettings], try to connect to the Seerr server + * + * @return true if successfully updated the SeerrApi and logged in + */ + suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean { + val currentUser = serverRepository.currentUser ?: return false + if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) { + val server = + SeerrServer( + id = -1, + url = settings.serverUrl, + ) + val user = + SeerrUser( + jellyfinUserRowId = currentUser.rowId, + serverId = server.id, + authMethod = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY + is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL + }, + username = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> null + is SeerrPluginLogin.Local -> settings.login.local.username + }, + password = null, + credential = null, + ) + try { + val userConfig = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> { + Timber.v("maybeAddPluginServer, using API key") + seerrApi.update(settings.serverUrl, settings.login.apiKey) + seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null) + } + + is SeerrPluginLogin.Local -> { + Timber.v("maybeAddPluginServer, using local user") + seerrApi.update(settings.serverUrl, null) + seerrLogin( + seerrApi.api, + SeerrAuthMethod.LOCAL, + settings.login.local.username, + settings.login.local.password, + ) + } + + SeerrPluginLogin.None -> { + return false + } + } + // Success + _connection.update { it.copy(pluginProvided = true) } + set(server, user, userConfig) + return true + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + clear() + Timber.w(ex, "Error adding plugin provided seerr") + _connection.update { + it.copy( + pluginProvided = true, + connection = SeerrConnectionStatus.Error(server, user, ex), + ) + } + return false + } + } + return false + } } /** @@ -185,6 +264,11 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +data class SeerrState( + val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, + val pluginProvided: Boolean = false, +) + sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt index 896b483c..8b51ad63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -1,11 +1,13 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okio.IOException @@ -32,6 +34,7 @@ class ServerPluginApi companion object { private const val HOME_CONFIG_PATH = "homesettings" + private const val SEERR_CONFIG_PATH = "seerrsettings" } /** @@ -57,32 +60,37 @@ class ServerPluginApi } } - /** - * Get the default home page settings from the plugin - * - * @return the home page settings or null if no rows are configured or the server isn't installed - */ + private suspend inline fun executeRequest(path: String): T? { + val url = createUrl(path) ?: return null + val request = Request(url.toHttpUrl()) + return executeRequest(request) + } + @OptIn(ExperimentalSerializationApi::class) - suspend fun fetchHomePageSettings(): HomePageSettings? = + private suspend inline fun executeRequest(request: Request): T? = withContext(WholphinDispatchers.IO) { - val url = createUrl(HOME_CONFIG_PATH) ?: return@withContext null - val request = - Request - .Builder() - .url(url) - .get() - .build() - return@withContext okHttpClient.newCall(request).execute().use { res -> + okHttpClient.newCall(request).execute().use { res -> if (res.isSuccessful) { - json - .decodeFromStream(res.body.byteStream()) - .takeIf { it.rows.isNotEmpty() } + json.decodeFromStream(res.body.byteStream()) } else if (res.code == 404) { - Timber.w("fetchHomePageSettings returned 404") + Timber.w("Plugin 404: %s", request.url.encodedPath) null } else { throw ApiClientException(res.code.toString() + " " + res.body.string()) } } } + + /** + * Get the default home page settings from the plugin + * + * @return the home page settings or null if no rows are configured or the server isn't installed + */ + suspend fun fetchHomePageSettings(): HomePageSettings? = + executeRequest(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() } + + /** + * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured + */ + suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index b64b15ca..b208d663 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -67,7 +67,7 @@ class UserSwitchListener AppCompatDelegate.setApplicationLocales(localeList) } - // Check if plugin is installed, then for home settings + // Check if plugin is installed, then for home settings & seerr settings launchIO { val serverPluginInstalled = try { @@ -81,48 +81,64 @@ class UserSwitchListener Timber.i("Server plugin installed: %s", serverPluginInstalled) serverRepository.serverPluginInstalled.value = serverPluginInstalled - // Check for home settings - homeSettingsService.loadCurrentSettings(user) - } - if (BuildConfig.DISCOVER_ENABLED) { - // Check for seerr server launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - seerrLogin( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) + // Check for home settings + homeSettingsService.loadCurrentSettings(user) + } + + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server + launchIO { + if (serverPluginInstalled) { + serverPluginApi.fetchSeerrSettings()?.let { + Timber.d("Trying seerr auto login") + val result = seerrServerRepository.usePluginProvidedServer(it) + if (result) { + // Successfully used server plugin provided seerr + Timber.i("Seerr auto login succeeded") + return@launchIO } } } + Timber.v("Checking for existing configured seerr server") + // Either no server plugin seerr or there was an issue with it + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + seerrLogin( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } + } + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 2e60f675..76b7dc77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -450,12 +450,14 @@ fun ConfirmDialog( properties: DialogProperties = DialogProperties(), elevation: Dp = 1.dp, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes) }, ) @@ -470,6 +472,8 @@ fun ConfirmDialogContent( onConfirm: () -> Unit, modifier: Modifier = Modifier, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -500,11 +504,11 @@ fun ConfirmDialogContent( modifier = Modifier.fillMaxWidth(), ) { TextButton( - stringRes = R.string.cancel, + stringRes = cancelStringRes, onClick = onCancel, ) TextButton( - stringRes = R.string.confirm, + stringRes = confirmStringRes, onClick = onConfirm, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index ad7b7afc..81f4833b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -57,6 +57,7 @@ enum class PreferenceScreenOption { fun rememberSeerrDialogBody( user: SeerrUser, server: SeerrServer, + pluginProvided: Boolean, ex: Exception? = null, ): String { val username = @@ -81,8 +82,13 @@ fun rememberSeerrDialogBody( ${stringResource(R.string.url)}: ${server.url} ${stringResource(R.string.username)}: $username$method """.trimIndent() + + if (pluginProvided) { + "\n\n${stringResource(R.string.managed_by_server)}" + } else { + "" + } + if (ex != null) { - "\n\n${stringResource(R.string.voice_error_server)}: ${ex.localizedMessage}" + "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}" } else { "" } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index aeb6ba22..e30484a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -48,8 +48,6 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSwitchPreference @@ -66,7 +64,6 @@ import com.github.damontecres.wholphin.preferences.experimentalPreferences import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences -import com.github.damontecres.wholphin.services.CurrentSeerr import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker @@ -83,7 +80,8 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.UpdateViewModel -import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -501,7 +499,7 @@ fun PreferencesContent( title = stringResource(pref.title), onClick = { seerrDialogMode = - when (val conn = seerrConnection) { + when (val conn = seerrConnection.connection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( conn.server, @@ -523,15 +521,13 @@ fun PreferencesContent( }, modifier = focusModifier, summary = - when (seerrConnection) { + when (seerrConnection.connection) { is SeerrConnectionStatus.Error -> { stringResource(R.string.voice_error_server) } SeerrConnectionStatus.NotConfigured -> { - stringResource( - R.string.add_server, - ) + stringResource(R.string.add_server) } is SeerrConnectionStatus.Success -> { @@ -726,59 +722,15 @@ fun PreferencesContent( ) } } - when (val mode = seerrDialogMode) { - is SeerrDialogMode.Remove -> { - val body = rememberSeerrDialogBody(mode.current.user, mode.current.server) - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - elevation = 3.dp, - ) - } - - SeerrDialogMode.Add -> { - val currentUser by seerrVm.currentUser.collectAsState(null) - val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) - val serverAddedMessage = stringResource(R.string.seerr_server_added) - LaunchedEffect(status) { - if (status == LoadingState.Success) { - Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() - seerrDialogMode = SeerrDialogMode.None - } - } - AddSeerServerDialog( - currentUsername = currentUser?.name, - status = status, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) - } - - is SeerrDialogMode.Error -> { - val body = rememberSeerrDialogBody(mode.user, mode.server, mode.ex) - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - bodyColor = MaterialTheme.colorScheme.error, - elevation = 3.dp, - ) - } - - SeerrDialogMode.None -> {} - } } + SeerrDialog( + seerrDialogMode = seerrDialogMode, + seerrConnection = seerrConnection, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + seerrVm = seerrVm, + ) + if (showQuickConnectDialog) { val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) val successMessage = stringResource(R.string.quick_connect_success) @@ -880,19 +832,3 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) - -private sealed interface SeerrDialogMode { - data object None : SeerrDialogMode - - data object Add : SeerrDialogMode - - data class Remove( - val current: CurrentSeerr, - ) : SeerrDialogMode - - data class Error( - val server: SeerrServer, - val user: SeerrUser, - val ex: Exception, - ) : SeerrDialogMode -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 5fa8c667..4a65b3d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerrServerApiKey( + initialUrl: String, onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, modifier: Modifier = Modifier, @@ -54,12 +55,18 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + if (initialUrl.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } Text( text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, @@ -152,6 +159,7 @@ fun AddSeerrServerApiKey( @Composable fun AddSeerrServerUsername( + initialUrl: String, onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, @@ -166,14 +174,22 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl) } 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() } + LaunchedEffect(Unit) { + val focusRequesterToUse = + when { + url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester + url.isNotNullOrBlank() -> usernameFocusRequester + else -> focusRequester + } + focusRequesterToUse.tryRequestFocus() + } Text( text = stringResource(R.string.username_or_password), style = MaterialTheme.typography.titleMedium, @@ -317,6 +333,7 @@ fun AddSeerrServerUsername( private fun AddSeerrServerUsernamePreview() { WholphinTheme { AddSeerrServerUsername( + initialUrl = "", onSubmit = { string: String, string1: String, string2: String -> }, username = "test", status = LoadingState.Pending, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index d84b98db..cc68fe04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -1,27 +1,161 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import android.widget.Toast +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R 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.CurrentSeerr +import com.github.damontecres.wholphin.services.SeerrState import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ConfirmDialog 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.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody import com.github.damontecres.wholphin.util.LoadingState +@Composable +fun SeerrDialog( + seerrDialogMode: SeerrDialogMode, + seerrConnection: SeerrState, + onDismissRequest: () -> Unit, + seerrVm: SwitchSeerrViewModel = hiltViewModel(), +) { + val context = LocalContext.current + when (val mode = seerrDialogMode) { + is SeerrDialogMode.Remove -> { + val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided) + if (seerrConnection.pluginProvided) { + ScrollableDialog( + onDismissRequest = onDismissRequest, + ) { + item { + Text(body) + } + } + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.Add -> { + val currentUser by seerrVm.currentUser.collectAsState(null) + val state by seerrVm.state.collectAsState() + val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(state.serverConnectionStatus) { + if (state.serverConnectionStatus == LoadingState.Success) { + Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() + onDismissRequest.invoke() + } + } + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + LoadingPage(Modifier.padding(16.dp)) + } + } + + LoadingState.Success -> { + AddSeerServerDialog( + initialUrl = state.initialUrl, + currentUsername = currentUser?.name, + status = state.serverConnectionStatus, + onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, + onDismissRequest = onDismissRequest, + ) + } + } + } + + is SeerrDialogMode.Error -> { + val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex) + if (seerrConnection.pluginProvided) { + ConfirmDialog( + title = stringResource(R.string.error), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.retryServerFromPlugin() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + confirmStringRes = R.string.retry, + ) + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.None -> {} + } +} + +sealed interface SeerrDialogMode { + data object None : SeerrDialogMode + + data object Add : SeerrDialogMode + + data class Remove( + val current: CurrentSeerr, + ) : SeerrDialogMode + + data class Error( + val server: SeerrServer, + val user: SeerrUser, + val ex: Exception, + ) : SeerrDialogMode +} + @Composable fun AddSeerServerDialog( currentUsername: String?, + initialUrl: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onResetStatus: () -> Unit, @@ -42,6 +176,7 @@ fun AddSeerServerDialog( properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( + initialUrl = initialUrl ?: "", onSubmit = { url, username, password -> onSubmit.invoke(url, username, password, auth) }, @@ -57,6 +192,7 @@ fun AddSeerServerDialog( onDismissRequest = { authMethod = null }, ) { AddSeerrServerApiKey( + initialUrl = initialUrl ?: "", onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 773f6c74..2fde3eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -3,11 +3,13 @@ package com.github.damontecres.wholphin.ui.setup.seerr import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException 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.services.ServerPluginApi import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState @@ -15,6 +17,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -28,11 +31,29 @@ class SwitchSeerrViewModel private val seerrServerRepository: SeerrServerRepository, private val seerrService: SeerrService, private val serverRepository: ServerRepository, + private val serverPluginApi: ServerPluginApi, ) : ViewModel() { val currentUser = serverRepository.currentUserFlow val currentSeerrServer = seerrServerRepository.currentServer - val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + private val _state = MutableStateFlow(SwitchSeerrState()) + val state: StateFlow = _state + + init { + if (serverRepository.serverPluginInstalled.value) { + viewModelScope.launchIO { + try { + serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl -> + _state.update { it.copy(initialUrl = serverUrl) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching seerr settings") + } finally { + _state.update { it.copy(loading = LoadingState.Success) } + } + } + } + } fun submitServer( url: String, @@ -41,13 +62,21 @@ class SwitchSeerrViewModel authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - serverConnectionStatus.update { LoadingState.Loading } + _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) } val urls = try { createUrls(url) } catch (ex: IllegalArgumentException) { showToast(context, "Invalid URL") - serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } + _state.update { + it.copy( + serverConnectionStatus = + LoadingState.Error( + "Invalid URL", + ex, + ), + ) + } return@launchIO } Timber.v("Urls to try: %s", urls) @@ -98,7 +127,7 @@ class SwitchSeerrViewModel ) } } - serverConnectionStatus.update { LoadingState.Success } + _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } } else { val message = results @@ -107,7 +136,7 @@ class SwitchSeerrViewModel "$url - ${s?.localizedMessage}" }.joinToString("\n") showToast(context, "Could not connect") - serverConnectionStatus.update { LoadingState.Error(message) } + _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } } } } @@ -121,11 +150,31 @@ class SwitchSeerrViewModel } } + fun retryServerFromPlugin() { + viewModelScope.launchIO { + val settings = serverPluginApi.fetchSeerrSettings() + if (settings != null) { + val result = seerrServerRepository.usePluginProvidedServer(settings) + if (!result) { + showToast(context, context.getString(R.string.voice_error_server)) + } + } else { + seerrServerRepository.clear() + } + } + } + fun resetStatus() { - serverConnectionStatus.update { LoadingState.Pending } + _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) } } } +data class SwitchSeerrState( + val loading: LoadingState = LoadingState.Pending, + val serverConnectionStatus: LoadingState = LoadingState.Pending, + val initialUrl: String? = null, +) + fun createUrls(url: String): List { val urls = mutableListOf() if (url.startsWith("http://") || url.startsWith("https://")) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ffe79fc..e263261c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -977,4 +977,6 @@ Server user profile Server default User web client + Managed by server + Error