Add setting to protect a user profile with a PIN (#356)

Allows for setting a per-user profile PIN that must be entered to switch
to the user. The PIN is a sequence of directional buttons, ie D-Pad
left/right/up/down.

This PIN is per device per user per server. The PIN is stored locally
and not sent nor shared with the server. If you forget your PIN, can
still access the app using server credentials which also removes the
PIN.

Additionally, there is another setting to disable automatically sign in.
When enabled (default) the last user is automatically restored, even if
there is a PIN. If disabled, a server & user must always be selected
when opening the app.

Closes #268
Closes #321
This commit is contained in:
damontecres 2025-12-04 14:37:27 -05:00 committed by GitHub
parent a294661520
commit 8ea84b3efe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 845 additions and 30 deletions

View file

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

View file

@ -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,6 +134,7 @@ class MainActivity : AppCompatActivity() {
) {
var isRestoringSession by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
if (appPreferences.signInAutomatically) {
try {
serverRepository.restoreSession(
appPreferences.currentServerId?.toUUIDOrNull(),
@ -137,6 +143,7 @@ class MainActivity : AppCompatActivity() {
} catch (ex: Exception) {
Timber.e(ex, "Exception restoring session")
}
}
isRestoringSession = false
}
val current by serverRepository.current.observeAsState()
@ -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()
}
}
}

View file

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

View file

@ -27,7 +27,7 @@ interface JellyfinServerDao {
}
}
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addUser(user: JellyfinUser): Long
@Update

View file

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

View file

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

View file

@ -732,6 +732,23 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled,
)
val SignInAuto =
AppSwitchPreference<AppPreferences>(
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<AppPreferences>(
title = R.string.require_pin_code,
)
val ImageDiskCacheSize =
AppSliderPreference<AppPreferences>(
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,
),
),

View file

@ -22,6 +22,7 @@ class AppPreferencesSerializer
autoCheckForUpdates = AppPreference.AutoCheckForUpdates.defaultValue
sendCrashReports = AppPreference.SendCrashReports.defaultValue
debugLogging = AppPreference.DebugLogging.defaultValue
signInAutomatically = AppPreference.SignInAuto.defaultValue
playbackPreferences =
PlaybackPreferences

View file

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

View file

@ -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 <T> 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<BaseItemDtoQueryResult>.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) }
}

View file

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

View file

@ -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<NavDrawerItem>
val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(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>) {
appPreferences.updateData {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -151,4 +151,5 @@ message AppPreferences {
bool send_crash_reports = 8;
bool debug_logging = 9;
AdvancedPreferences advanced_preferences = 10;
bool sign_in_automatically = 11;
}

View file

@ -20,6 +20,8 @@
<string name="fa_pause" translatable="false">&#xf04c;</string>
<string name="fa_arrow_down_long" translatable="false">&#xf175;</string>
<string name="fa_arrow_up_long" translatable="false">&#xf176;</string>
<string name="fa_arrow_left_long" translatable="false">&#xf177;</string>
<string name="fa_arrow_right_long" translatable="false">&#xf178;</string>
<string name="fa_angle_up" translatable="false">&#xf106;</string>
<string name="fa_angles_up" translatable="false">&#xf102;</string>
<string name="fa_angle_down" translatable="false">&#xf107;</string>

View file

@ -331,6 +331,15 @@
<string name="discard_change">Discard changes?</string>
<string name="subtitle_margin">Margin</string>
<string name="verbose_logging">Verbose logging</string>
<string name="enter_pin">Enter PIN</string>
<string name="sign_in_auto">Sign in automatically</string>
<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="confirm_pin">Confirm PIN</string>
<string name="incorrect">Incorrect</string>
<string name="pin_too_short">PIN must be 4 keys or longer</string>
<string name="will_remove_pin">Will remove PIN</string>
<string name="image_cache_size">Image disk cache size (MB)</string>
<string name="view_options">View options</string>
<string name="columns">Columns</string>
@ -339,6 +348,7 @@
<string name="show_details">Show details</string>
<string name="image_type">Image type</string>
<string-array name="theme_song_volume">
<item>Disabled</item>
<item>Lowest</item>