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

@ -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 ->
viewModel.switchUser(user)
if (user.pin.isNotNullOrBlank()) {
switchUserWithPin = user
} else {
viewModel.switchUser(user)
}
},
onAddUser = { showAddUser = true },
onRemoveUser = { user ->
@ -300,6 +307,18 @@ fun SwitchUserContent(
}
}
}
switchUserWithPin?.let { user ->
PinEntryDialog(
onDismissRequest = { switchUserWithPin = null },
onClickServerAuth = {
showAddUser = true
switchUserWithPin = null
},
onTextChange = {
if (it == user.pin) viewModel.switchUser(user)
},
)
}
}
}