Skip to content

Commit 57977c0

Browse files
committed
Allow alpha-numeric PINs
1 parent 9ec12bc commit 57977c0

11 files changed

Lines changed: 249 additions & 12 deletions

File tree

app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ fun PinVerificationContent(
118118
visualTransformation = PasswordVisualTransformation(),
119119
singleLine = true,
120120
keyboardOptions = KeyboardOptions(
121-
keyboardType = KeyboardType.NumberPassword,
121+
keyboardType = if (uiState.isAlphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword,
122122
imeAction = ImeAction.Done
123123
),
124124
keyboardActions = KeyboardActions(

app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationViewModel.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import com.darkrockstudios.app.securecamera.R
88
import com.darkrockstudios.app.securecamera.encryption.VideoEncryptionService
99
import com.darkrockstudios.app.securecamera.gallery.vibrateDevice
1010
import com.darkrockstudios.app.securecamera.navigation.Introduction
11+
import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource
1112
import com.darkrockstudios.app.securecamera.usecases.InvalidateSessionUseCase
1213
import com.darkrockstudios.app.securecamera.usecases.PinSizeUseCase
1314
import com.darkrockstudios.app.securecamera.usecases.SecurityResetUseCase
1415
import com.darkrockstudios.app.securecamera.usecases.VerifyPinUseCase
1516
import kotlinx.coroutines.Dispatchers
1617
import kotlinx.coroutines.delay
18+
import kotlinx.coroutines.flow.first
1719
import kotlinx.coroutines.flow.update
1820
import kotlinx.coroutines.launch
1921
import kotlinx.coroutines.withContext
@@ -25,6 +27,7 @@ class PinVerificationViewModel(
2527
private val securityResetUseCase: SecurityResetUseCase,
2628
private val verifyPinUseCase: VerifyPinUseCase,
2729
private val pinSizeUseCase: PinSizeUseCase,
30+
private val appSettingsDataSource: AppSettingsDataSource,
2831
) : BaseViewModel<PinVerificationUiState>() {
2932

3033
override fun createState() = PinVerificationUiState()
@@ -40,12 +43,15 @@ class PinVerificationViewModel(
4043
val remainingBackoff = authRepository.calculateRemainingBackoffSeconds()
4144
val isBackoffActive = remainingBackoff > 0
4245

46+
val isAlphanumericEnabled = appSettingsDataSource.alphanumericPinEnabled.first()
47+
4348
_uiState.update {
4449
it.copy(
4550
failedAttempts = failedAttempts,
4651
remainingBackoffSeconds = remainingBackoff,
4752
isBackoffActive = isBackoffActive,
48-
error = if (isBackoffActive) PinVerificationError.INVALID_PIN else PinVerificationError.NONE
53+
error = if (isBackoffActive) PinVerificationError.INVALID_PIN else PinVerificationError.NONE,
54+
isAlphanumericPinEnabled = isAlphanumericEnabled
4955
)
5056
}
5157

@@ -76,7 +82,13 @@ class PinVerificationViewModel(
7682

7783
fun validatePin(newPin: String): Boolean {
7884
val pinSize = pinSizeUseCase.getPinSizeRange()
79-
return if (newPin.length <= pinSize.max() && newPin.all { char -> char.isDigit() }) {
85+
val isAlphanumeric = uiState.value.isAlphanumericPinEnabled
86+
val isValid = if (isAlphanumeric) {
87+
newPin.all { it.isLetterOrDigit() }
88+
} else {
89+
newPin.all { it.isDigit() }
90+
}
91+
return if (newPin.length <= pinSize.max() && isValid) {
8092
clearError()
8193
true
8294
} else {
@@ -173,5 +185,6 @@ data class PinVerificationUiState(
173185
val isVerifying: Boolean = false,
174186
val failedAttempts: Int = 0,
175187
val isBackoffActive: Boolean = false,
176-
val remainingBackoffSeconds: Int = 0
188+
val remainingBackoffSeconds: Int = 0,
189+
val isAlphanumericPinEnabled: Boolean = false
177190
)

app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ interface IntroductionViewModel {
1515
fun createPin(pin: String, confirmPin: String)
1616
fun toggleBiometricsRequired()
1717
fun toggleEphemeralKey()
18+
fun toggleAlphanumericPin()
19+
fun setShowAlphanumericHelpDialog(show: Boolean)
1820
}
1921

2022
data class IntroductionUiState(
@@ -27,4 +29,6 @@ data class IntroductionUiState(
2729
val currentPage: Int = 0,
2830
val isCreatingPin: Boolean = false,
2931
val pinSize: IntRange,
32+
val alphanumericPinEnabled: Boolean = false,
33+
val showAlphanumericHelpDialog: Boolean = false,
3034
)

app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModelImpl.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.*
77
import androidx.lifecycle.viewModelScope
88
import com.darkrockstudios.app.securecamera.BaseViewModel
99
import com.darkrockstudios.app.securecamera.R
10+
import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource
1011
import com.darkrockstudios.app.securecamera.security.HardwareSchemeConfig
1112
import com.darkrockstudios.app.securecamera.security.SecurityLevel
1213
import com.darkrockstudios.app.securecamera.security.SecurityLevelDetector
@@ -27,6 +28,7 @@ class IntroductionViewModelImpl(
2728
private val pinStrengthCheck: PinStrengthCheckUseCase,
2829
private val createPinUseCase: CreatePinUseCase,
2930
private val pinSizeUseCase: PinSizeUseCase,
31+
private val appSettingsDataSource: AppSettingsDataSource,
3032
) : BaseViewModel<IntroductionUiState>(), IntroductionViewModel {
3133

3234
override fun createState() = IntroductionUiState(
@@ -106,7 +108,7 @@ class IntroductionViewModelImpl(
106108
return
107109
}
108110

109-
val strongPin = pinStrengthCheck.isPinStrongEnough(pin)
111+
val strongPin = pinStrengthCheck.isPinStrongEnough(pin, uiState.value.alphanumericPinEnabled)
110112
if (strongPin.not()) {
111113
_uiState.update { it.copy(errorMessage = appContext.getString(R.string.pin_creation_error_weak_pin)) }
112114
return
@@ -131,4 +133,16 @@ class IntroductionViewModelImpl(
131133
override fun toggleEphemeralKey() {
132134
_uiState.update { it.copy(ephemeralKey = it.ephemeralKey.not()) }
133135
}
136+
137+
override fun toggleAlphanumericPin() {
138+
val newValue = !uiState.value.alphanumericPinEnabled
139+
_uiState.update { it.copy(alphanumericPinEnabled = newValue) }
140+
viewModelScope.launch {
141+
appSettingsDataSource.setAlphanumericPinEnabled(newValue)
142+
}
143+
}
144+
145+
override fun setShowAlphanumericHelpDialog(show: Boolean) {
146+
_uiState.update { it.copy(showAlphanumericHelpDialog = show) }
147+
}
134148
}

app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.Preview.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ private class DummyIntroductionViewModel(initial: IntroductionUiState) : Introdu
2020
override fun createPin(pin: String, confirmPin: String) {}
2121
override fun toggleBiometricsRequired() {}
2222
override fun toggleEphemeralKey() {}
23+
override fun toggleAlphanumericPin() {}
24+
override fun setShowAlphanumericHelpDialog(show: Boolean) {}
2325
}
2426

2527
@Preview(name = "Pin Creation", showBackground = true)

app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.kt

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.compose.foundation.rememberScrollState
55
import androidx.compose.foundation.text.KeyboardOptions
66
import androidx.compose.foundation.verticalScroll
77
import androidx.compose.material.icons.Icons
8+
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
89
import androidx.compose.material.icons.filled.Pin
910
import androidx.compose.material.icons.filled.Visibility
1011
import androidx.compose.material.icons.filled.VisibilityOff
@@ -85,19 +86,51 @@ fun PinCreationContent(
8586
modifier = Modifier.padding(bottom = 24.dp)
8687
)
8788

89+
// Alpha-numeric PIN checkbox row
90+
Row(
91+
modifier = Modifier
92+
.fillMaxWidth()
93+
.padding(bottom = 16.dp),
94+
verticalAlignment = Alignment.CenterVertically
95+
) {
96+
Checkbox(
97+
checked = uiState.alphanumericPinEnabled,
98+
onCheckedChange = { viewModel.toggleAlphanumericPin() },
99+
enabled = !uiState.isCreatingPin
100+
)
101+
Text(
102+
text = stringResource(R.string.pin_creation_alphanumeric_label),
103+
style = MaterialTheme.typography.bodyMedium,
104+
modifier = Modifier.weight(1f)
105+
)
106+
IconButton(
107+
onClick = { viewModel.setShowAlphanumericHelpDialog(true) }
108+
) {
109+
Icon(
110+
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
111+
contentDescription = stringResource(R.string.pin_creation_alphanumeric_help_description)
112+
)
113+
}
114+
}
115+
88116
// PIN input
89117
OutlinedTextField(
90118
value = pin,
91119
onValueChange = { newPin ->
92-
if (newPin.length <= uiState.pinSize.max() && newPin.all { char -> char.isDigit() }) {
120+
val isValid = if (uiState.alphanumericPinEnabled) {
121+
newPin.all { char -> char.isLetterOrDigit() }
122+
} else {
123+
newPin.all { char -> char.isDigit() }
124+
}
125+
if (newPin.length <= uiState.pinSize.max() && isValid) {
93126
pin = newPin
94127
}
95128
},
96129
enabled = !uiState.isCreatingPin,
97130
label = { Text(stringResource(R.string.pin_creation_hint)) },
98131
visualTransformation = if (pinVisible) VisualTransformation.None else PasswordVisualTransformation(),
99132
keyboardOptions = KeyboardOptions(
100-
keyboardType = KeyboardType.NumberPassword,
133+
keyboardType = if (uiState.alphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword,
101134
imeAction = ImeAction.Next
102135
),
103136
trailingIcon = {
@@ -117,19 +150,35 @@ fun PinCreationContent(
117150
.padding(bottom = 16.dp)
118151
)
119152

153+
// Short PIN warning
154+
if (pin.isNotEmpty() && pin.length < 6) {
155+
Text(
156+
text = stringResource(R.string.pin_creation_short_warning),
157+
style = MaterialTheme.typography.bodySmall,
158+
color = MaterialTheme.colorScheme.error,
159+
textAlign = TextAlign.Center,
160+
modifier = Modifier.padding(bottom = 8.dp)
161+
)
162+
}
163+
120164
// Confirm PIN input
121165
OutlinedTextField(
122166
value = confirmPin,
123167
onValueChange = { newConfirmPin ->
124-
if (newConfirmPin.length <= uiState.pinSize.max() && newConfirmPin.all { char -> char.isDigit() }) {
168+
val isValid = if (uiState.alphanumericPinEnabled) {
169+
newConfirmPin.all { char -> char.isLetterOrDigit() }
170+
} else {
171+
newConfirmPin.all { char -> char.isDigit() }
172+
}
173+
if (newConfirmPin.length <= uiState.pinSize.max() && isValid) {
125174
confirmPin = newConfirmPin
126175
}
127176
},
128177
enabled = !uiState.isCreatingPin,
129178
label = { Text(stringResource(R.string.pin_creation_confirm_hint)) },
130179
visualTransformation = PasswordVisualTransformation(),
131180
keyboardOptions = KeyboardOptions(
132-
keyboardType = KeyboardType.NumberPassword,
181+
keyboardType = if (uiState.alphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword,
133182
imeAction = ImeAction.Done
134183
),
135184
singleLine = true,
@@ -186,4 +235,31 @@ fun PinCreationContent(
186235
title = R.string.pin_create_notification_rationale_title,
187236
text = R.string.pin_create_notification_rationale_text,
188237
)
238+
239+
// Alpha-numeric PIN help dialog
240+
if (uiState.showAlphanumericHelpDialog) {
241+
AlphanumericPinHelpDialog(
242+
onDismiss = { viewModel.setShowAlphanumericHelpDialog(false) }
243+
)
244+
}
245+
}
246+
247+
@Composable
248+
private fun AlphanumericPinHelpDialog(
249+
onDismiss: () -> Unit
250+
) {
251+
AlertDialog(
252+
onDismissRequest = onDismiss,
253+
title = {
254+
Text(text = stringResource(R.string.pin_alphanumeric_help_title))
255+
},
256+
text = {
257+
Text(text = stringResource(R.string.pin_alphanumeric_help_message))
258+
},
259+
confirmButton = {
260+
TextButton(onClick = onDismiss) {
261+
Text(stringResource(R.string.ok_button))
262+
}
263+
}
264+
)
189265
}

app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppSettingsDataSource.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ interface AppSettingsDataSource {
2727
val enableFaceTracking: Flow<Boolean>
2828
val enableFaceTrackingDefault: Boolean
2929

30+
/**
31+
* Enable alpha-numeric PINs (letters + digits instead of digits only)
32+
*/
33+
val alphanumericPinEnabled: Flow<Boolean>
34+
val alphanumericPinEnabledDefault: Boolean
35+
3036
/**
3137
* Get the session timeout preference
3238
*/
@@ -60,6 +66,11 @@ interface AppSettingsDataSource {
6066
*/
6167
suspend fun setEnableFaceTracking(enable: Boolean)
6268

69+
/**
70+
* Set the alpha-numeric PIN enabled preference
71+
*/
72+
suspend fun setAlphanumericPinEnabled(enabled: Boolean)
73+
6374
/**
6475
* Get the current failed PIN attempts count
6576
*/

app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/PreferencesAppSettingsDataSource.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class PreferencesAppSettingsDataSource(
3636
private val SANITIZE_FILE_NAME = booleanPreferencesKey("sanitize_file_name")
3737
private val SANITIZE_METADATA = booleanPreferencesKey("sanitize_metadata")
3838
private val FACE_TRACKING_ENABLED = booleanPreferencesKey("face_tracking_enabled")
39+
private val ALPHANUMERIC_PIN_ENABLED = booleanPreferencesKey("alphanumeric_pin_enabled")
3940
private val FAILED_PIN_ATTEMPTS = stringPreferencesKey("failed_pin_attempts")
4041
private val LAST_FAILED_ATTEMPT_TIMESTAMP = stringPreferencesKey("last_failed_attempt_timestamp")
4142
private val SESSION_TIMEOUT = stringPreferencesKey("session_timeout")
@@ -101,6 +102,15 @@ class PreferencesAppSettingsDataSource(
101102
}
102103
override val enableFaceTrackingDefault = true
103104

105+
/**
106+
* Enable alpha-numeric PIN preference
107+
*/
108+
override val alphanumericPinEnabled: Flow<Boolean> = dataStore.data
109+
.map { preferences ->
110+
preferences[ALPHANUMERIC_PIN_ENABLED] ?: alphanumericPinEnabledDefault
111+
}
112+
override val alphanumericPinEnabledDefault = false
113+
104114
/**
105115
* Get the session timeout preference
106116
*/
@@ -156,6 +166,15 @@ class PreferencesAppSettingsDataSource(
156166
}
157167
}
158168

169+
/**
170+
* Set the alpha-numeric PIN enabled preference
171+
*/
172+
override suspend fun setAlphanumericPinEnabled(enabled: Boolean) {
173+
dataStore.edit { preferences ->
174+
preferences[ALPHANUMERIC_PIN_ENABLED] = enabled
175+
}
176+
}
177+
159178
/**
160179
* Get the current failed PIN attempts count
161180
*/

0 commit comments

Comments
 (0)