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)
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(),

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.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<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 {

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.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

View file

@ -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()})"
}
/**

View file

@ -914,9 +914,9 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled,
)
val RequireProfilePin =
val ProtectProfilePreference =
AppClickablePreference<AppPreferences>(
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,
),

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.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,
)
}

View file

@ -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)
}
}

View file

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

View file

@ -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<JellyfinUser?>(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,
)

View file

@ -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

View file

@ -386,6 +386,7 @@
<string name="use_server_credentials">Login via server</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_login">Require login for profile</string>
<string name="confirm_pin">Confirm PIN</string>
<string name="incorrect">Incorrect</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="play_with">Play with</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_summary_on">Play on click</string>
<string name="continue_watching_click_summary_off">Show details on click</string>