Add option to require server login for user profile (#1367)

## Description
This PR adds a new method of protecting a user profile when switching to
it: server login. When enabled, switching to the user requires
authenticating with the server again via Quick Connect or username &
password. This is basically the same as the official client.

### Related issues
Closes #321
Related to #1330 - might cause merge conflicts
Related to #1287 - definitely causes merge conflicts

### Testing
Emulator with various uses cases including auto sign-in, switching
between server login & PIN protection, and removing the protection

## Screenshots
N/A, the login looks identical as adding a new user

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-20 12:00:02 -04:00 committed by GitHub
parent 1463480e26
commit 45d466d76f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 813 additions and 35 deletions

View file

@ -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')"
]
}
}

View file

@ -434,8 +434,11 @@ class MainActivityViewModel
appUpgradeHandler.copySubfont(false) appUpgradeHandler.copySubfont(false)
val prefs = val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val userHasPin = serverRepository.currentUser.value?.hasPin == true val allowAutoSignIn =
if (prefs.signInAutomatically && !userHasPin) { serverRepository.currentUser.value?.let {
!it.hasPin && !it.requireLogin
} == true
if (prefs.signInAutomatically && allowAutoSignIn) {
val current = val current =
serverRepository.restoreSession( serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(), prefs.currentServerId?.toUUIDOrNull(),

View file

@ -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.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.ui.components.ViewOptions 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 kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber import timber.log.Timber
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
@Database( @Database(
@ -40,7 +48,7 @@ import java.util.UUID
SeerrUser::class, SeerrUser::class,
], ],
version = 31, version = 32,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -55,6 +63,7 @@ import java.util.UUID
AutoMigration(12, 20), AutoMigration(12, 20),
AutoMigration(20, 30), AutoMigration(20, 30),
AutoMigration(30, 31), AutoMigration(30, 31),
AutoMigration(31, 32),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -116,6 +125,27 @@ class Converters {
Timber.e(ex, "Error parsing view options") Timber.e(ex, "Error parsing view options")
null 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<ZonedDateTime> {
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 { object Migrations {

View file

@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.time.ZonedDateTime
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -167,6 +168,7 @@ class ServerRepository
suspend fun changeUser( suspend fun changeUser(
serverUrl: String, serverUrl: String,
authenticationResult: AuthenticationResult, authenticationResult: AuthenticationResult,
existingUser: JellyfinUser?,
) = withContext(ioDispatcher) { ) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken val accessToken = authenticationResult.accessToken
if (accessToken != null) { if (accessToken != null) {
@ -183,12 +185,24 @@ class ServerRepository
if (server != null) { if (server != null) {
val user = val user =
authedUser?.let { authedUser?.let {
JellyfinUser( if (existingUser != null) {
id = it.id, Timber.d("Re-using existing user")
name = it.name, existingUser.copy(
serverId = server.id, // If the server authenticated via the server, always remove the PIN
accessToken = accessToken, 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) { if (user != null) {
return@withContext changeUser(server, user) return@withContext changeUser(server, user)
@ -253,11 +267,12 @@ class ServerRepository
} }
} }
suspend fun setUserPin( suspend fun updateUserAuth(
user: JellyfinUser, user: JellyfinUser,
pin: String?, pin: String?,
) = withContext(ioDispatcher) { requireLogin: Boolean,
val newUser = user.copy(pin = pin) ) {
val newUser = user.copy(pin = pin, requireLogin = requireLogin)
val updatedUser = serverDao.addOrUpdateUser(newUser) val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
// Updating current user, so push out the change // Updating current user, so push out the change

View file

@ -1,4 +1,4 @@
@file:UseSerializers(UUIDSerializer::class) @file:UseSerializers(UUIDSerializer::class, ZonedDateTimeSerializer::class)
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
@ -10,11 +10,13 @@ import androidx.room.Ignore
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Relation import androidx.room.Relation
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.time.ZonedDateTime
import java.util.UUID import java.util.UUID
/** /**
@ -27,6 +29,7 @@ data class JellyfinServer(
val name: String?, val name: String?,
val url: String, val url: String,
val version: String?, val version: String?,
val lastUsed: ZonedDateTime? = null,
) { ) {
@get:Ignore @get:Ignore
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) } val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
@ -58,11 +61,14 @@ data class JellyfinUser(
val serverId: UUID, val serverId: UUID,
val accessToken: String?, val accessToken: String?,
val pin: String? = null, val pin: String? = null,
@ColumnInfo(defaultValue = "false")
val requireLogin: Boolean = false,
val lastUsed: ZonedDateTime? = null,
) { ) {
val hasPin: Boolean get() = pin.isNotNullOrBlank() val hasPin: Boolean get() = pin.isNotNullOrBlank()
override fun toString(): String = 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()})"
} }
/** /**

View file

@ -914,9 +914,9 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val RequireProfilePin = val ProtectProfilePreference =
AppClickablePreference<AppPreferences>( AppClickablePreference<AppPreferences>(
title = R.string.require_pin_code, title = R.string.profile_protection,
) )
val ImageDiskCacheSize = val ImageDiskCacheSize =
@ -1088,7 +1088,7 @@ val basicPreferences =
title = R.string.profile_specific_settings, title = R.string.profile_specific_settings,
preferences = preferences =
listOf( listOf(
AppPreference.RequireProfilePin, AppPreference.ProtectProfilePreference,
AppPreference.CustomizeHome, AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems, AppPreference.UserPinnedNavDrawerItems,
), ),

View file

@ -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.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull 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.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.playSoundOnFocus
@ -391,16 +390,45 @@ fun PreferencesContent(
) )
} }
AppPreference.RequireProfilePin -> { AppPreference.ProtectProfilePreference -> {
SwitchPreference( 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), title = stringResource(pref.title),
value = currentUser?.pin.isNotNullOrBlank(), summary = stringResource(summary),
onClick = { possibleValues =
showPinFlow = true 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,
) )
} }

View file

@ -101,7 +101,19 @@ class PreferencesViewModel
pin: String?, pin: String?,
) { ) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { 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)
} }
} }

View file

@ -21,6 +21,12 @@ enum class PinFlowState {
CONFIRM_PIN, CONFIRM_PIN,
} }
enum class ProfileProtection {
NONE,
PIN,
LOGIN,
}
@Composable @Composable
fun SetPinFlow( fun SetPinFlow(
currentPin: String?, currentPin: String?,

View file

@ -72,13 +72,19 @@ fun SwitchUserContent(
val currentUser by viewModel.serverRepository.currentUser.observeAsState() val currentUser by viewModel.serverRepository.currentUser.observeAsState()
var showAddUser by remember { mutableStateOf(false) } var showAddUser by remember { mutableStateOf(false) }
var username by remember(server) { mutableStateOf("") } var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) }
var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") }
fun showAddUserDialog(user: JellyfinUser?) { fun showAddUserDialog(user: JellyfinUser?) {
username = user?.name ?: "" addUser = user
showAddUser = true showAddUser = true
} }
fun hideAddUserDialog() {
addUser = null
showAddUser = false
}
LaunchedEffect(state.switchUserState) { LaunchedEffect(state.switchUserState) {
if (!showAddUser) { if (!showAddUser) {
when (val s = state.switchUserState) { when (val s = state.switchUserState) {
@ -136,7 +142,7 @@ fun SwitchUserContent(
users = state.users, users = state.users,
currentUser = currentUser, currentUser = currentUser,
onSwitchUser = { user -> onSwitchUser = { user ->
if (user.accessToken == null) { if (user.accessToken == null || user.requireLogin) {
showAddUserDialog(user) showAddUserDialog(user)
} else if (user.hasPin) { } else if (user.hasPin) {
switchUserWithPin = user switchUserWithPin = user
@ -174,13 +180,13 @@ fun SwitchUserContent(
viewModel.clearSwitchUserState() viewModel.clearSwitchUserState()
viewModel.resetAttempts() viewModel.resetAttempts()
if (useQuickConnect) { if (useQuickConnect) {
viewModel.initiateQuickConnect(server) viewModel.initiateQuickConnect(server, addUser)
} }
} }
BasicDialog( BasicDialog(
onDismissRequest = { onDismissRequest = {
viewModel.cancelQuickConnect() viewModel.cancelQuickConnect()
showAddUser = false hideAddUserDialog()
}, },
properties = properties =
DialogProperties( DialogProperties(
@ -244,6 +250,7 @@ fun SwitchUserContent(
val onSubmit = { val onSubmit = {
viewModel.login( viewModel.login(
server, server,
addUser,
username, username,
password, password,
) )

View file

@ -138,6 +138,7 @@ class SwitchUserViewModel
fun login( fun login(
server: JellyfinServer, server: JellyfinServer,
existingUser: JellyfinUser?,
username: String, username: String,
password: String, password: String,
) { ) {
@ -149,7 +150,8 @@ class SwitchUserViewModel
username = username, username = username,
password = password, password = password,
) )
val current = serverRepository.changeUser(server.url, authenticationResult) val current =
serverRepository.changeUser(server.url, authenticationResult, existingUser)
setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error logging in user") 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?.cancel()
quickConnectJob = quickConnectJob =
viewModelScope.launchIO { viewModelScope.launchIO {
@ -187,7 +192,12 @@ class SwitchUserViewModel
val authenticationResult by api.userApi.authenticateWithQuickConnect( val authenticationResult by api.userApi.authenticateWithQuickConnect(
QuickConnectDto(secret = quickConnectStatus.secret), QuickConnectDto(secret = quickConnectStatus.secret),
) )
val current = serverRepository.changeUser(server.url, authenticationResult) val current =
serverRepository.changeUser(
server.url,
authenticationResult,
existingUser,
)
setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
} catch (_: CancellationException) { } catch (_: CancellationException) {
// no-op, user may have canceled // no-op, user may have canceled

View file

@ -386,6 +386,7 @@
<string name="use_server_credentials">Login via server</string> <string name="use_server_credentials">Login via server</string>
<string name="press_enter_to_confirm">Press center to confirm</string> <string name="press_enter_to_confirm">Press center to confirm</string>
<string name="require_pin_code">Require PIN for profile</string> <string name="require_pin_code">Require PIN for profile</string>
<string name="require_login">Require login for profile</string>
<string name="confirm_pin">Confirm PIN</string> <string name="confirm_pin">Confirm PIN</string>
<string name="incorrect">Incorrect</string> <string name="incorrect">Incorrect</string>
<string name="pin_too_short">PIN must be 4 keys or longer</string> <string name="pin_too_short">PIN must be 4 keys or longer</string>
@ -778,6 +779,7 @@
<string name="skip_behavior_summary">For intros, credits, etc</string> <string name="skip_behavior_summary">For intros, credits, etc</string>
<string name="play_with">Play with</string> <string name="play_with">Play with</string>
<string name="transcoding">Transcoding</string> <string name="transcoding">Transcoding</string>
<string name="profile_protection">Profile protection</string>
<string name="continue_watching_click_behavior">Continue watching/Next up click behavior</string> <string name="continue_watching_click_behavior">Continue watching/Next up click behavior</string>
<string name="continue_watching_click_summary_on">Play on click</string> <string name="continue_watching_click_summary_on">Play on click</string>
<string name="continue_watching_click_summary_off">Show details on click</string> <string name="continue_watching_click_summary_off">Show details on click</string>