diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json new file mode 100644 index 000000000..5e58b40ee --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json @@ -0,0 +1,659 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "934c38e7f4eb68c9a223ddb5c7e3e35d", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` 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" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "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, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` 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" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "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, `order` INTEGER NOT NULL DEFAULT -1, 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 + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "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" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), 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": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "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, '934c38e7f4eb68c9a223ddb5c7e3e35d')" + ] + } +} 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 1fa290d04..61a4ffc1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -434,8 +434,11 @@ class MainActivityViewModel appUpgradeHandler.copySubfont(false) val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val userHasPin = serverRepository.currentUser.value?.hasPin == true - if (prefs.signInAutomatically && !userHasPin) { + val allowAutoSignIn = + serverRepository.currentUser.value?.let { + !it.hasPin && !it.requireLogin + } == true + if (prefs.signInAutomatically && allowAutoSignIn) { val current = serverRepository.restoreSession( prefs.currentServerId?.toUUIDOrNull(), 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 b3f33bedb..02a56245f 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 @@ -19,11 +19,19 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.ui.components.ViewOptions +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.toUUID import timber.log.Timber +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.util.UUID @Database( @@ -40,7 +48,7 @@ import java.util.UUID SeerrUser::class, ], - version = 31, + version = 32, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -55,6 +63,7 @@ import java.util.UUID AutoMigration(12, 20), AutoMigration(20, 30), AutoMigration(30, 31), + AutoMigration(31, 32), ], ) @TypeConverters(Converters::class) @@ -116,6 +125,27 @@ class Converters { Timber.e(ex, "Error parsing view options") null } + + @TypeConverter + fun convertToString(dateTime: ZonedDateTime): String = DateTimeFormatter.ISO_ZONED_DATE_TIME.format(dateTime) + + @TypeConverter + fun convertToLocalDateTime(dateTime: String): ZonedDateTime = ZonedDateTime.parse(dateTime, DateTimeFormatter.ISO_ZONED_DATE_TIME) +} + +class ZonedDateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(ZonedDateTime::class.qualifiedName!!, PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ZonedDateTime = + ZonedDateTime.parse(decoder.decodeString(), DateTimeFormatter.ISO_ZONED_DATE_TIME) + + override fun serialize( + encoder: Encoder, + value: ZonedDateTime, + ) { + encoder.encodeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value)) + } } object Migrations { 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 7e32f2e56..25be2d024 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 @@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber +import java.time.ZonedDateTime import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -167,6 +168,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, + existingUser: JellyfinUser?, ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { @@ -183,12 +185,24 @@ class ServerRepository if (server != null) { val user = authedUser?.let { - JellyfinUser( - id = it.id, - name = it.name, - serverId = server.id, - accessToken = accessToken, - ) + if (existingUser != null) { + Timber.d("Re-using existing user") + existingUser.copy( + // If the server authenticated via the server, always remove the PIN + pin = null, + accessToken = accessToken, + lastUsed = ZonedDateTime.now(), + ) + } else { + Timber.d("Creating new user") + JellyfinUser( + id = it.id, + name = it.name, + serverId = server.id, + accessToken = accessToken, + lastUsed = ZonedDateTime.now(), + ) + } } if (user != null) { return@withContext changeUser(server, user) @@ -253,11 +267,12 @@ class ServerRepository } } - suspend fun setUserPin( + suspend fun updateUserAuth( user: JellyfinUser, pin: String?, - ) = withContext(ioDispatcher) { - val newUser = user.copy(pin = pin) + requireLogin: Boolean, + ) { + val newUser = user.copy(pin = pin, requireLogin = requireLogin) val updatedUser = serverDao.addOrUpdateUser(newUser) if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { // Updating current user, so push out the change 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 0b1759eed..be966fc40 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 @@ -1,4 +1,4 @@ -@file:UseSerializers(UUIDSerializer::class) +@file:UseSerializers(UUIDSerializer::class, ZonedDateTimeSerializer::class) package com.github.damontecres.wholphin.data.model @@ -10,11 +10,13 @@ import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation +import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.time.ZonedDateTime import java.util.UUID /** @@ -27,6 +29,7 @@ data class JellyfinServer( val name: String?, val url: String, val version: String?, + val lastUsed: ZonedDateTime? = null, ) { @get:Ignore val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) } @@ -58,11 +61,14 @@ data class JellyfinUser( val serverId: UUID, val accessToken: String?, val pin: String? = null, + @ColumnInfo(defaultValue = "false") + val requireLogin: Boolean = false, + val lastUsed: ZonedDateTime? = null, ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() override fun toString(): String = - "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" } /** 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 e59e2dd08..fffdb7eca 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 @@ -914,9 +914,9 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) - val RequireProfilePin = + val ProtectProfilePreference = AppClickablePreference( - title = R.string.require_pin_code, + title = R.string.profile_protection, ) val ImageDiskCacheSize = @@ -1088,7 +1088,7 @@ val basicPreferences = title = R.string.profile_specific_settings, preferences = listOf( - AppPreference.RequireProfilePin, + AppPreference.ProtectProfilePreference, AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, ), 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 52d182e11..945523c98 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 @@ -67,7 +67,6 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -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 @@ -391,16 +390,45 @@ fun PreferencesContent( ) } - AppPreference.RequireProfilePin -> { - SwitchPreference( + AppPreference.ProtectProfilePreference -> { + val summary = + when { + currentUser?.requireLogin == true -> R.string.require_login + currentUser?.hasPin == true -> R.string.require_pin_code + else -> R.string.none + } + ChoicePreference( title = stringResource(pref.title), - value = currentUser?.pin.isNotNullOrBlank(), - onClick = { - showPinFlow = true + summary = stringResource(summary), + possibleValues = + listOf( + stringResource(R.string.none), + stringResource(R.string.require_pin_code), + stringResource(R.string.require_login), + ), + selectedIndex = + when { + currentUser?.requireLogin == true -> ProfileProtection.LOGIN + currentUser?.hasPin == true -> ProfileProtection.PIN + else -> ProfileProtection.NONE + }.ordinal, + onValueChange = { + currentUser?.let { user -> + when (ProfileProtection.entries[it]) { + ProfileProtection.NONE -> { + viewModel.removeLoginAndPin(user) + } + + ProfileProtection.PIN -> { + showPinFlow = true + } + + ProfileProtection.LOGIN -> { + viewModel.setRequireLogin(user) + } + } + } }, - summaryOn = stringResource(R.string.enabled), - summaryOff = null, - modifier = Modifier, ) } 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 32d085a5d..a55ddf9ef 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 @@ -101,7 +101,19 @@ class PreferencesViewModel pin: String?, ) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - serverRepository.setUserPin(user, pin) + serverRepository.updateUserAuth(user, pin, false) + } + } + + fun setRequireLogin(user: JellyfinUser) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + serverRepository.updateUserAuth(user, null, true) + } + } + + fun removeLoginAndPin(user: JellyfinUser) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + serverRepository.updateUserAuth(user, null, false) } } 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 index 3f71e7e4f..2c6bcd55d 100644 --- 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 @@ -21,6 +21,12 @@ enum class PinFlowState { CONFIRM_PIN, } +enum class ProfileProtection { + NONE, + PIN, + LOGIN, +} + @Composable fun SetPinFlow( currentPin: String?, 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 8b2245ced..13ea2b86d 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 @@ -72,13 +72,19 @@ fun SwitchUserContent( val currentUser by viewModel.serverRepository.currentUser.observeAsState() var showAddUser by remember { mutableStateOf(false) } - var username by remember(server) { mutableStateOf("") } + var addUser by remember(server) { mutableStateOf(null) } + var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") } fun showAddUserDialog(user: JellyfinUser?) { - username = user?.name ?: "" + addUser = user showAddUser = true } + fun hideAddUserDialog() { + addUser = null + showAddUser = false + } + LaunchedEffect(state.switchUserState) { if (!showAddUser) { when (val s = state.switchUserState) { @@ -136,7 +142,7 @@ fun SwitchUserContent( users = state.users, currentUser = currentUser, onSwitchUser = { user -> - if (user.accessToken == null) { + if (user.accessToken == null || user.requireLogin) { showAddUserDialog(user) } else if (user.hasPin) { switchUserWithPin = user @@ -174,13 +180,13 @@ fun SwitchUserContent( viewModel.clearSwitchUserState() viewModel.resetAttempts() if (useQuickConnect) { - viewModel.initiateQuickConnect(server) + viewModel.initiateQuickConnect(server, addUser) } } BasicDialog( onDismissRequest = { viewModel.cancelQuickConnect() - showAddUser = false + hideAddUserDialog() }, properties = DialogProperties( @@ -244,6 +250,7 @@ fun SwitchUserContent( val onSubmit = { viewModel.login( server, + addUser, username, password, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 9a1f06a29..fce4a01c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -138,6 +138,7 @@ class SwitchUserViewModel fun login( server: JellyfinServer, + existingUser: JellyfinUser?, username: String, password: String, ) { @@ -149,7 +150,8 @@ class SwitchUserViewModel username = username, password = password, ) - val current = serverRepository.changeUser(server.url, authenticationResult) + val current = + serverRepository.changeUser(server.url, authenticationResult, existingUser) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) } catch (ex: Exception) { Timber.e(ex, "Error logging in user") @@ -162,7 +164,10 @@ class SwitchUserViewModel } } - fun initiateQuickConnect(server: JellyfinServer) { + fun initiateQuickConnect( + server: JellyfinServer, + existingUser: JellyfinUser?, + ) { quickConnectJob?.cancel() quickConnectJob = viewModelScope.launchIO { @@ -187,7 +192,12 @@ class SwitchUserViewModel val authenticationResult by api.userApi.authenticateWithQuickConnect( QuickConnectDto(secret = quickConnectStatus.secret), ) - val current = serverRepository.changeUser(server.url, authenticationResult) + val current = + serverRepository.changeUser( + server.url, + authenticationResult, + existingUser, + ) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) } catch (_: CancellationException) { // no-op, user may have canceled diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9642625bd..fe7836157 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -386,6 +386,7 @@ Login via server Press center to confirm Require PIN for profile + Require login for profile Confirm PIN Incorrect PIN must be 4 keys or longer @@ -778,6 +779,7 @@ For intros, credits, etc Play with Transcoding + Profile protection Continue watching/Next up click behavior Play on click Show details on click