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