From 8ea84b3efe42d2bb225974739a107c6dd8aab706 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:37:27 -0500 Subject: [PATCH] Add setting to protect a user profile with a PIN (#356) Allows for setting a per-user profile PIN that must be entered to switch to the user. The PIN is a sequence of directional buttons, ie D-Pad left/right/up/down. This PIN is per device per user per server. The PIN is stored locally and not sent nor shared with the server. If you forget your PIN, can still access the app using server credentials which also removes the PIN. Additionally, there is another setting to disable automatically sign in. When enabled (default) the last user is automatically restored, even if there is a PIN. If disabled, a server & user must always be selected when opening the app. Closes #268 Closes #321 --- .../10.json | 325 ++++++++++++++++++ .../damontecres/wholphin/MainActivity.kt | 59 +++- .../damontecres/wholphin/data/AppDatabase.kt | 3 +- .../wholphin/data/JellyfinServerDao.kt | 2 +- .../wholphin/data/ServerRepository.kt | 19 + .../wholphin/data/model/JellyfinServer.kt | 3 +- .../wholphin/preferences/AppPreference.kt | 19 + .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/AppUpgradeHandler.kt | 11 + .../damontecres/wholphin/ui/Extensions.kt | 12 +- .../ui/preferences/PreferencesContent.kt | 32 ++ .../ui/preferences/PreferencesViewModel.kt | 12 + .../wholphin/ui/preferences/SetPinFlow.kt | 98 ++++++ .../wholphin/ui/setup/InstallUpdatePage.kt | 13 +- .../damontecres/wholphin/ui/setup/PinEntry.kt | 227 ++++++++++++ .../wholphin/ui/setup/SwitchServerContent.kt | 3 +- .../wholphin/ui/setup/SwitchUserContent.kt | 23 +- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/fa_strings.xml | 2 + app/src/main/res/values/strings.xml | 10 + 20 files changed, 845 insertions(+), 30 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/10.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/10.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/10.json new file mode 100644 index 00000000..0a7bf63d --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/10.json @@ -0,0 +1,325 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "addc8fcbddd84a88e20fbda34849cf26", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'addc8fcbddd84a88e20fbda34849cf26')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index c24bbeed..341a120a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin import android.os.Bundle +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -42,6 +44,7 @@ import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination @@ -49,6 +52,8 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.DebugLogTree import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -129,13 +134,15 @@ class MainActivity : AppCompatActivity() { ) { var isRestoringSession by remember { mutableStateOf(true) } LaunchedEffect(Unit) { - try { - serverRepository.restoreSession( - appPreferences.currentServerId?.toUUIDOrNull(), - appPreferences.currentUserId?.toUUIDOrNull(), - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception restoring session") + if (appPreferences.signInAutomatically) { + try { + serverRepository.restoreSession( + appPreferences.currentServerId?.toUUIDOrNull(), + appPreferences.currentUserId?.toUUIDOrNull(), + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception restoring session") + } } isRestoringSession = false } @@ -158,12 +165,30 @@ class MainActivity : AppCompatActivity() { ) } } else { - key(current) { - val initialDestination = - if (current != null) { - Destination.Home() + DisposableEffect(Unit) { + onDispose { + if (!appPreferences.signInAutomatically) { + serverRepository.closeSession() + } + } + } + key(current?.server?.id, current?.user?.id) { + LaunchedEffect(current?.user?.pin) { + if (current?.user?.pin?.isNotNullOrBlank() == true) { + // If user has a pin, then obscure the window in previews + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) } else { - Destination.ServerList + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + val initialDestination = + when { + current != null -> Destination.Home() + !appPreferences.signInAutomatically -> Destination.ServerList // TODO user list? + else -> Destination.ServerList } val backStack = rememberNavBackStack(initialDestination) navigationManager.backStack = backStack @@ -206,4 +231,14 @@ class MainActivity : AppCompatActivity() { appUpgradeHandler.run() } } + + override fun onRestart() { + super.onRestart() + val signInAutomatically = + runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true + Timber.i("onRestart: signInAutomatically=$signInAutomatically") + if (!signInAutomatically) { + serverRepository.closeSession() + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 8673d1a0..5dd76067 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -23,7 +23,7 @@ import java.util.UUID @Database( entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], - version = 9, + version = 10, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -32,6 +32,7 @@ import java.util.UUID AutoMigration(6, 7), AutoMigration(7, 8), AutoMigration(8, 9), + AutoMigration(9, 10), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index e44db6f1..3c2250fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -27,7 +27,7 @@ interface JellyfinServerDao { } } - @Insert(onConflict = OnConflictStrategy.Companion.IGNORE) + @Insert(onConflict = OnConflictStrategy.IGNORE) fun addUser(user: JellyfinUser): Long @Update diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index a381464d..eed4422f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -132,6 +132,10 @@ class ServerRepository return false } + fun closeSession() { + _current.value = null + } + /** * Given a successful [AuthenticationResult], switch to the user that just authenticated */ @@ -225,6 +229,21 @@ class ServerRepository } } + suspend fun setUserPin( + user: JellyfinUser, + pin: String?, + ) = withContext(Dispatchers.IO) { + val newUser = user.copy(pin = pin) + val updatedUser = serverDao.addOrUpdateUser(newUser) + if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { + // Updating current user, so push out the change + current.value?.let { + val newCurrent = it.copy(user = updatedUser) + _current.setValueOnMain(newCurrent) + } + } + } + companion object { fun getServerSharedPreferences(context: Context): SharedPreferences = context.getSharedPreferences( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index e7933992..0b8a3302 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -50,9 +50,10 @@ data class JellyfinUser( @ColumnInfo(index = true) val serverId: UUID, val accessToken: String?, + val pin: String? = null, ) { override fun toString(): String = - "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken=${accessToken.isNotNullOrBlank()})" + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" } data class JellyfinServerUsers( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 96c69bb5..4513dd4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -732,6 +732,23 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val SignInAuto = + AppSwitchPreference( + title = R.string.sign_in_auto, + defaultValue = true, + getter = { it.signInAutomatically }, + setter = { prefs, value -> + prefs.update { signInAutomatically = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val RequireProfilePin = + AppClickablePreference( + title = R.string.require_pin_code, + ) + val ImageDiskCacheSize = AppSliderPreference( title = R.string.image_cache_size, @@ -764,6 +781,7 @@ val basicPreferences = title = R.string.ui_interface, preferences = listOf( + AppPreference.SignInAuto, AppPreference.HomePageItems, AppPreference.CombineContinueNext, AppPreference.RewatchNextUp, @@ -796,6 +814,7 @@ val basicPreferences = title = R.string.profile_specific_settings, preferences = listOf( + AppPreference.RequireProfilePin, AppPreference.UserPinnedNavDrawerItems, ), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 1ad40c0e..079aedd0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -22,6 +22,7 @@ class AppPreferencesSerializer autoCheckForUpdates = AppPreference.AutoCheckForUpdates.defaultValue sendCrashReports = AppPreference.SendCrashReports.defaultValue debugLogging = AppPreference.DebugLogging.defaultValue + signInAutomatically = AppPreference.SignInAuto.defaultValue playbackPreferences = PlaybackPreferences diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 89e9bce2..44fd82f5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions @@ -135,6 +136,7 @@ suspend fun upgradeApp( } } } + if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { appPreferences.updateData { it.updateAdvancedPreferences { @@ -145,6 +147,7 @@ suspend fun upgradeApp( } } } + if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { appPreferences.updateData { it.updateMpvOptions { @@ -152,4 +155,12 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { + appPreferences.updateData { + it.update { + signInAutomatically = AppPreference.SignInAuto.defaultValue + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index f1ebfc06..93ad9946 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -19,6 +19,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -398,13 +400,21 @@ fun logTab( suspend fun onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) +fun Modifier.dimAndBlur(enabled: Boolean) = + this.ifElse( + enabled, + Modifier + .alpha(.5f) + .blur(16.dp), + ) + fun Response.toBaseItems( api: ApiClient, useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } @Composable -fun rememberBackDropImage(item: BaseItem) { +fun rememberBackDropImage(item: BaseItem): String? { val imageUrlService = LocalImageUrlService.current return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } } 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 c7334c1e..c10a5ccb 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 @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.preferences.uiPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus @@ -78,6 +79,8 @@ fun PreferencesContent( var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } + val currentUser by viewModel.currentUser.observeAsState() + var showPinFlow by remember { mutableStateOf(false) } val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } @@ -365,6 +368,19 @@ fun PreferencesContent( ) } + AppPreference.RequireProfilePin -> { + SwitchPreference( + title = stringResource(pref.title), + value = currentUser?.pin.isNotNullOrBlank(), + onClick = { + showPinFlow = true + }, + summaryOn = stringResource(R.string.enabled), + summaryOff = null, + modifier = Modifier, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -408,6 +424,22 @@ fun PreferencesContent( } } } + if (showPinFlow && currentUser != null) { + currentUser?.let { user -> + SetPinFlow( + currentPin = user.pin, + onAddPin = { + viewModel.setPin(user, it) + showPinFlow = false + }, + onRemovePin = { + viewModel.setPin(user, null) + showPinFlow = false + }, + onDismissRequest = { showPinFlow = false }, + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 33cd8b56..6f4ea0dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.isPinned +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.preferences.AppPreferences @@ -47,6 +48,8 @@ class PreferencesViewModel private lateinit var allNavDrawerItems: List val navDrawerPins = MutableLiveData>(mapOf()) + val currentUser get() = serverRepository.currentUser + init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> @@ -100,6 +103,15 @@ class PreferencesViewModel } } + fun setPin( + user: JellyfinUser, + pin: String?, + ) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + serverRepository.setUserPin(user, pin) + } + } + companion object { suspend fun resetSubtitleSettings(appPreferences: DataStore) { appPreferences.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt new file mode 100644 index 00000000..3f71e7e4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt @@ -0,0 +1,98 @@ +package com.github.damontecres.wholphin.ui.preferences + +import android.widget.Toast +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.setup.PinEntryCreate + +enum class PinFlowState { + ENTER_PIN_TO_REMOVE, + ENTER_PIN, + CONFIRM_PIN, +} + +@Composable +fun SetPinFlow( + currentPin: String?, + onAddPin: (String) -> Unit, + onRemovePin: () -> Unit, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + var flowState by + remember { + mutableStateOf(if (currentPin.isNullOrBlank()) PinFlowState.ENTER_PIN else PinFlowState.ENTER_PIN_TO_REMOVE) + } + var pendingPin by remember { mutableStateOf("") } + + AnimatedContent(flowState) { state -> + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + when (state) { + PinFlowState.ENTER_PIN_TO_REMOVE -> { + PinEntryCreate( + title = R.string.enter_pin, + onTextChange = { + if (it == currentPin) onRemovePin.invoke() + }, + onConfirm = null, + modifier = Modifier.padding(16.dp), + ) + } + + PinFlowState.ENTER_PIN -> { + PinEntryCreate( + title = R.string.enter_pin, + onTextChange = {}, + onConfirm = { + if (it.length >= 4) { + pendingPin = it + flowState = PinFlowState.CONFIRM_PIN + } else { + Toast + .makeText( + context, + context.getString(R.string.pin_too_short), + Toast.LENGTH_SHORT, + ).show() + } + }, + modifier = Modifier.padding(16.dp), + ) + } + + PinFlowState.CONFIRM_PIN -> { + PinEntryCreate( + title = R.string.confirm_pin, + onTextChange = {}, + onConfirm = { + if (it == pendingPin) { + onAddPin.invoke(pendingPin) + } else { + Toast + .makeText( + context, + context.getString(R.string.incorrect), + Toast.LENGTH_SHORT, + ).show() + flowState = PinFlowState.ENTER_PIN + } + }, + modifier = Modifier.padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index 4b5de09d..e81fbce1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -30,8 +30,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -58,8 +56,8 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.formatBytes -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.ExceptionHandler @@ -195,14 +193,7 @@ fun InstallUpdatePage( onCancel = { viewModel.navigationManager.goBack() }, - modifier = - modifier - .ifElse( - isDownloading, - Modifier - .blur(8.dp) - .alpha(.33f), - ), + modifier = modifier.dimAndBlur(isDownloading), ) } if (isDownloading) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt new file mode 100644 index 00000000..e6ca52a0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt @@ -0,0 +1,227 @@ +package com.github.damontecres.wholphin.ui.setup + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.playback.isEnterKey +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +@Composable +fun PinEntry( + onTextChange: (String) -> Unit, + onClickServerAuth: () -> Unit, + modifier: Modifier = Modifier, +) { + var input by remember { mutableStateOf("") } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.enter_pin), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + PinArrowRow(Modifier.align(Alignment.CenterHorizontally)) + PinEntryDots(input.length, Modifier.align(Alignment.CenterHorizontally)) + + Button( + onClick = onClickServerAuth, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .onKeyEvent { + if (it.type == KeyEventType.KeyUp) { + var str = input + str += + when (it.key) { + Key.DirectionUp -> "U" + Key.DirectionRight -> "R" + Key.DirectionDown -> "D" + Key.DirectionLeft -> "L" + else -> return@onKeyEvent false + } + onTextChange.invoke(str) + input = str + return@onKeyEvent true + } else { + return@onKeyEvent false + } + }, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.use_server_credentials), + ) + Text( + text = stringResource(R.string.will_remove_pin), + fontSize = 10.sp, + ) + } + } + } +} + +@Composable +fun PinEntryCreate( + @StringRes title: Int, + onTextChange: (String) -> Unit, + onConfirm: ((String) -> Unit)?, + modifier: Modifier = Modifier, +) { + var input by remember { mutableStateOf("") } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .onKeyEvent { + if (isEnterKey(it)) { + onConfirm?.invoke(input) + return@onKeyEvent true + } + if (it.type == KeyEventType.KeyUp) { + var str = input + str += + when (it.key) { + Key.DirectionUp -> "U" + Key.DirectionRight -> "R" + Key.DirectionDown -> "D" + Key.DirectionLeft -> "L" + else -> return@onKeyEvent false + } + onTextChange.invoke(str) + input = str + return@onKeyEvent true + } else { + return@onKeyEvent false + } + }.focusable(), + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + PinArrowRow(Modifier.align(Alignment.CenterHorizontally)) + PinEntryDots(input.length, Modifier.align(Alignment.CenterHorizontally)) + if (onConfirm != null) { + Text( + text = stringResource(R.string.press_enter_to_confirm), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } +} + +@Composable +fun PinArrowRow(modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + val arrows = + listOf(R.string.fa_arrow_left_long, R.string.fa_arrow_up_long, R.string.fa_arrow_right_long, R.string.fa_arrow_down_long) + arrows.forEach { + Text( + text = stringResource(it), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + fontFamily = FontAwesome, + ) + } + } +} + +@Composable +fun PinEntryDots( + count: Int, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = + modifier + .defaultMinSize(minWidth = 180.dp, minHeight = 40.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), + shape = CircleShape, + ).padding(vertical = 16.dp), + ) { + repeat(count) { + Box( + modifier = + Modifier + .padding(horizontal = 8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f)) + .size(8.dp), + ) + } + } +} + +@Composable +fun PinEntryDialog( + onDismissRequest: () -> Unit, + onTextChange: (String) -> Unit, + onClickServerAuth: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + PinEntry( + onTextChange = onTextChange, + onClickServerAuth = onClickServerAuth, + modifier = Modifier.padding(16.dp), + ) + } +} + +@PreviewTvSpec +@Composable +private fun PinEntryPreview() { + WholphinTheme { + PinEntry( + onTextChange = {}, + onClickServerAuth = {}, + modifier = Modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index ed5fb658..b8682b0a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -37,6 +37,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState @@ -60,7 +61,7 @@ fun SwitchServerContent( } Box( - modifier = modifier, + modifier = modifier.dimAndBlur(showAddServer), ) { Row( modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index f57e4256..8099b4e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -40,9 +40,11 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -84,10 +86,11 @@ fun SwitchUserContent( } } } + var switchUserWithPin by remember { mutableStateOf(null) } currentServer?.let { server -> Box( - modifier = modifier, + modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -116,7 +119,11 @@ fun SwitchUserContent( users = users, currentUser = currentUser, onSwitchUser = { user -> - viewModel.switchUser(user) + if (user.pin.isNotNullOrBlank()) { + switchUserWithPin = user + } else { + viewModel.switchUser(user) + } }, onAddUser = { showAddUser = true }, onRemoveUser = { user -> @@ -300,6 +307,18 @@ fun SwitchUserContent( } } } + switchUserWithPin?.let { user -> + PinEntryDialog( + onDismissRequest = { switchUserWithPin = null }, + onClickServerAuth = { + showAddUser = true + switchUserWithPin = null + }, + onTextChange = { + if (it == user.pin) viewModel.switchUser(user) + }, + ) + } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 7003217b..5345086e 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -151,4 +151,5 @@ message AppPreferences { bool send_crash_reports = 8; bool debug_logging = 9; AdvancedPreferences advanced_preferences = 10; + bool sign_in_automatically = 11; } diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 8fdae6dd..e027f30c 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -20,6 +20,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6698533..580d7144 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -331,6 +331,15 @@ Discard changes? Margin Verbose logging + Enter PIN + Sign in automatically + Login via server + Press center to confirm + Require PIN for profile + Confirm PIN + Incorrect + PIN must be 4 keys or longer + Will remove PIN Image disk cache size (MB) View options Columns @@ -339,6 +348,7 @@ Show details Image type + Disabled Lowest