Support for custom pin

This commit is contained in:
greenart7c3
2024-10-25 08:41:13 -03:00
parent 472abf09cc
commit bbf6869152
10 changed files with 451 additions and 41 deletions
@@ -33,6 +33,8 @@ private const val DEBUG_PLAINTEXT_PREFERENCES = false
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
private object PrefKeys {
const val PIN = "pin"
const val USE_PIN = "use_pin"
const val CURRENT_ACCOUNT = "currently_logged_in_account"
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
@@ -115,6 +117,23 @@ object LocalPreferences {
putBoolean(PrefKeys.USE_AUTH, settings.useAuth)
putInt(PrefKeys.NOTIFICATION_TYPE, settings.notificationType.screenCode)
putInt(PrefKeys.BIOMETRICS_TYPE, settings.biometricsTimeType.screenCode)
putBoolean(PrefKeys.USE_PIN, settings.usePin)
}.apply()
}
fun loadPinFromEncryptedStorage(): String? {
val context = NostrSigner.getInstance()
return encryptedPreferences(context).getString(PrefKeys.PIN, null)
}
fun savePinToEncryptedStorage(pin: String?) {
val context = NostrSigner.getInstance()
encryptedPreferences(context).edit().apply {
if (pin == null) {
remove(PrefKeys.PIN)
} else {
putString(PrefKeys.PIN, pin)
}
}.apply()
}
@@ -138,6 +157,7 @@ object LocalPreferences {
useAuth = getBoolean(PrefKeys.USE_AUTH, false),
notificationType = parseNotificationType(getInt(PrefKeys.NOTIFICATION_TYPE, 1)),
biometricsTimeType = parseBiometricsTimeType(getInt(PrefKeys.BIOMETRICS_TYPE, 0)),
usePin = getBoolean(PrefKeys.USE_PIN, false),
)
}
}
@@ -404,4 +424,12 @@ object LocalPreferences {
return account
}
}
fun clearPin() {
val context = NostrSigner.getInstance()
encryptedPreferences(context).edit().apply {
remove(PrefKeys.USE_PIN)
}.apply()
NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy(usePin = false)
}
}
@@ -26,6 +26,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -35,6 +37,7 @@ import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.ui.AccountScreen
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
import com.greenart7c3.nostrsigner.ui.BiometricsTimeType
import com.greenart7c3.nostrsigner.ui.components.RandomPinInput
import com.greenart7c3.nostrsigner.ui.theme.NostrSignerTheme
import com.vitorpamplona.ammolite.service.HttpClientManager
import java.time.Duration
@@ -58,7 +61,6 @@ class MainActivity : AppCompatActivity() {
mainViewModel = MainViewModel(applicationContext)
HttpClientManager.setDefaultUserAgent("Amber/${BuildConfig.VERSION_NAME}")
setContent {
if (intent.isLaunchFromHistory()) {
Log.d("isLaunchFromHistory", "Cleared intent history")
@@ -78,6 +80,7 @@ class MainActivity : AppCompatActivity() {
val navController = rememberNavController()
mainViewModel.navController = navController
var isAuthenticated by remember { mutableStateOf(false) }
var showPinDialog by remember { mutableStateOf(false) }
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
@@ -89,43 +92,48 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
val lastAuthTime = NostrSigner.getInstance().settings.lastBiometricsTime
val whenToAsk = NostrSigner.getInstance().settings.biometricsTimeType
val settings = NostrSigner.getInstance().settings
val lastAuthTime = settings.lastBiometricsTime
val whenToAsk = settings.biometricsTimeType
val isTimeToAsk = when (whenToAsk) {
BiometricsTimeType.EVERY_TIME -> true
BiometricsTimeType.ONE_MINUTE -> minutesBetween(lastAuthTime, System.currentTimeMillis()) >= 1
BiometricsTimeType.FIVE_MINUTES -> minutesBetween(lastAuthTime, System.currentTimeMillis()) >= 5
BiometricsTimeType.TEN_MINUTES -> minutesBetween(lastAuthTime, System.currentTimeMillis()) >= 10
}
val shouldAuthenticate = NostrSigner.getInstance().settings.useAuth && isTimeToAsk
val shouldAuthenticate = (settings.useAuth || settings.usePin) && isTimeToAsk
if (!shouldAuthenticate) {
isAuthenticated = true
} else {
if (!isAuthenticated) {
launch(Dispatchers.Main) {
Biometrics.authenticate(
getString(R.string.authenticate),
this@MainActivity,
keyguardLauncher,
{
NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy(
lastBiometricsTime = System.currentTimeMillis(),
)
if (settings.usePin) {
showPinDialog = true
} else {
Biometrics.authenticate(
getString(R.string.authenticate),
this@MainActivity,
keyguardLauncher,
{
NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy(
lastBiometricsTime = System.currentTimeMillis(),
)
LocalPreferences.saveSettingsToEncryptedStorage(NostrSigner.getInstance().settings)
isAuthenticated = true
},
{ _, message ->
this@MainActivity.finish()
scope.launch {
Toast.makeText(
context,
message,
Toast.LENGTH_SHORT,
).show()
}
},
)
LocalPreferences.saveSettingsToEncryptedStorage(NostrSigner.getInstance().settings)
isAuthenticated = true
},
{ _, message ->
this@MainActivity.finish()
scope.launch {
Toast.makeText(
context,
message,
Toast.LENGTH_SHORT,
).show()
}
},
)
}
}
}
}
@@ -136,6 +144,50 @@ class MainActivity : AppCompatActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
if (showPinDialog) {
Dialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = {
showPinDialog = false
this@MainActivity.finish()
scope.launch {
Toast.makeText(
context,
getString(R.string.pin_does_not_match),
Toast.LENGTH_SHORT,
).show()
}
},
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
RandomPinInput(
text = getString(R.string.enter_pin),
onPinEntered = {
val pin = LocalPreferences.loadPinFromEncryptedStorage()
if (it == pin) {
NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy(
lastBiometricsTime = System.currentTimeMillis(),
)
LocalPreferences.saveSettingsToEncryptedStorage(NostrSigner.getInstance().settings)
isAuthenticated = true
showPinDialog = false
} else {
Toast.makeText(
context,
getString(R.string.pin_does_not_match),
Toast.LENGTH_SHORT,
).show()
}
},
)
}
}
}
if (!isAuthenticated) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -13,4 +13,5 @@ data class AmberSettings(
val useAuth: Boolean = false,
val notificationType: NotificationType = NotificationType.DIRECT,
val biometricsTimeType: BiometricsTimeType = BiometricsTimeType.EVERY_TIME,
val usePin: Boolean = false,
)
@@ -903,6 +903,35 @@ fun MainScreen(
}
},
)
composable(
Route.SetupPin.route,
content = {
SetupPinScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding),
navController = navController,
)
},
)
composable(
Route.ConfirmPin.route,
arguments = listOf(navArgument("pin") { type = NavType.StringType }),
content = {
it.arguments?.getString("pin")?.let { pin ->
ConfirmPinScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding),
accountStateViewModel = accountStateViewModel,
pin = pin,
navController = navController,
)
}
},
)
}
}
}
@@ -28,6 +28,7 @@ import com.greenart7c3.nostrsigner.NostrSigner
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.ui.components.AmberButton
import com.greenart7c3.nostrsigner.ui.components.TitleExplainer
import com.greenart7c3.nostrsigner.ui.navigation.Route
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -45,6 +46,7 @@ fun SecurityScreen(
TitleExplainer(stringResource(BiometricsTimeType.TEN_MINUTES.resourceId)),
)
var enableBiometrics by remember { mutableStateOf(NostrSigner.getInstance().settings.useAuth) }
var setupPin by remember { mutableStateOf(NostrSigner.getInstance().settings.usePin) }
var biometricsIndex by remember {
mutableIntStateOf(NostrSigner.getInstance().settings.biometricsTimeType.screenCode)
}
@@ -81,6 +83,38 @@ fun SecurityScreen(
},
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 8.dp)
.clickable {
if (setupPin) {
val pin = LocalPreferences.loadPinFromEncryptedStorage()
navController.navigate("${Route.ConfirmPin.route.split("/")[0]}/$pin")
} else {
navController.navigate(Route.SetupPin.route)
}
},
) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.setup_pin),
)
Switch(
checked = setupPin,
onCheckedChange = {
if (setupPin) {
val pin = LocalPreferences.loadPinFromEncryptedStorage()
navController.navigate("${Route.ConfirmPin.route.split("/")[0]}/$pin")
} else {
navController.navigate(Route.SetupPin.route)
}
},
)
}
Box(
Modifier
.padding(8.dp),
@@ -54,7 +54,6 @@ import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.NostrSigner
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.service.Biometrics
import com.greenart7c3.nostrsigner.service.NotificationDataSource
import com.greenart7c3.nostrsigner.ui.actions.ConnectOrbotDialog
import com.greenart7c3.nostrsigner.ui.actions.LogoutDialog
@@ -101,20 +100,18 @@ fun SettingsScreen(
modifier
.verticalScroll(rememberScrollState()),
) {
if (Biometrics.isFingerprintAvailable(context)) {
Box(
Modifier
.padding(8.dp),
) {
IconRow(
title = stringResource(R.string.security),
icon = Icons.Default.Security,
tint = MaterialTheme.colorScheme.onBackground,
onClick = {
navController.navigate(Route.Security.route)
},
)
}
Box(
Modifier
.padding(8.dp),
) {
IconRow(
title = stringResource(R.string.security),
icon = Icons.Default.Security,
tint = MaterialTheme.colorScheme.onBackground,
onClick = {
navController.navigate(Route.Security.route)
},
)
}
Box(
@@ -0,0 +1,72 @@
package com.greenart7c3.nostrsigner.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.NostrSigner
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.ui.components.RandomPinInput
import com.greenart7c3.nostrsigner.ui.navigation.Route
@Composable
fun SetupPinScreen(
modifier: Modifier = Modifier,
navController: NavController,
) {
Column(
modifier
.padding(16.dp),
) {
RandomPinInput { pin ->
navController.navigate("${Route.ConfirmPin.route.split("/")[0]}/$pin")
}
}
}
@Composable
fun ConfirmPinScreen(
modifier: Modifier = Modifier,
accountStateViewModel: AccountStateViewModel,
pin: String,
navController: NavController,
) {
val context = LocalContext.current
Column(
modifier
.padding(16.dp),
) {
RandomPinInput(
onPinEntered = { enteredPin ->
if (enteredPin != pin) {
accountStateViewModel.toast(context.getString(R.string.pin), context.getString(R.string.pin_does_not_match))
} else {
val usePin = NostrSigner.getInstance().settings.usePin
if (usePin) {
NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy(usePin = false)
LocalPreferences.saveSettingsToEncryptedStorage(NostrSigner.getInstance().settings)
LocalPreferences.savePinToEncryptedStorage(null)
navController.navigate(Route.Security.route) {
popUpTo(Route.Security.route) {
inclusive = true
}
}
} else {
NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy(usePin = true)
LocalPreferences.saveSettingsToEncryptedStorage(NostrSigner.getInstance().settings)
LocalPreferences.savePinToEncryptedStorage(pin)
navController.navigate(Route.Security.route) {
popUpTo(Route.Security.route) {
inclusive = true
}
}
}
}
},
)
}
}
@@ -0,0 +1,176 @@
package com.greenart7c3.nostrsigner.ui.components
import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Backspace
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.greenart7c3.nostrsigner.service.getAppCompatActivity
@Composable
fun RandomPinInput(
text: String? = null,
onPinEntered: (String) -> Unit,
) {
val randomNumbers = remember { (0..9).shuffled() }
var selectedPin by remember { mutableStateOf("") }
var obscureText by remember { mutableStateOf(true) }
val activity = LocalContext.current.getAppCompatActivity()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(key1 = lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
activity?.window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
}
Lifecycle.Event.ON_PAUSE -> {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
text?.let {
Text(
text = it,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = if (!obscureText) selectedPin else selectedPin.map { '*' }.joinToString(""),
)
if (selectedPin.isNotEmpty()) {
IconButton(
onClick = { obscureText = !obscureText },
) {
Icon(
imageVector = if (obscureText) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = "Show/Hide",
)
}
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxWidth(),
) {
itemsIndexed(randomNumbers.dropLast(1)) { _, number ->
Button(
onClick = {
if (selectedPin.length < 6) {
selectedPin += number.toString()
}
},
modifier = Modifier.padding(8.dp),
) {
Text(number.toString())
}
}
item {
Button(
onClick = {
if (selectedPin.isNotEmpty()) {
selectedPin = selectedPin.dropLast(1) // Remove last digit
}
},
modifier = Modifier.weight(1f).padding(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.Backspace,
contentDescription = "Erase",
)
}
}
item {
Button(
onClick = {
if (selectedPin.length < 6) {
selectedPin += randomNumbers.last().toString()
}
},
modifier = Modifier.weight(1f).padding(8.dp),
) {
Text(randomNumbers.last().toString())
}
}
item {
Button(
enabled = selectedPin.length >= 4,
onClick = {
if (selectedPin.length >= 4) {
onPinEntered(selectedPin) // Confirm the PIN
}
},
modifier = Modifier.weight(1f).padding(8.dp),
) {
Icon(
Icons.Default.Done,
contentDescription = "Done",
)
}
}
}
}
}
@@ -157,6 +157,20 @@ sealed class Route(
icon = Icons.Outlined.Settings,
selectedIcon = Icons.Default.Settings,
)
data object SetupPin : Route(
title = NostrSigner.getInstance().getString(R.string.setup_pin),
route = "SetupPin",
icon = Icons.Outlined.Settings,
selectedIcon = Icons.Default.Settings,
)
data object ConfirmPin : Route(
title = NostrSigner.getInstance().getString(R.string.confirm_pin),
route = "ConfirmPin/{pin}",
icon = Icons.Outlined.Settings,
selectedIcon = Icons.Default.Settings,
)
}
val routes = listOf(
@@ -179,4 +193,6 @@ val routes = listOf(
Route.Activity,
Route.RelayLogScreen,
Route.EditConfiguration,
Route.SetupPin,
Route.ConfirmPin,
)
+5
View File
@@ -409,4 +409,9 @@
<string name="edit_configuration_description">To create a new nsecbunker you need to give it a name and select one or more relays, that’s all!</string>
<string name="update">Update</string>
<string name="add">Add</string>
<string name="setup_pin">Setup PIN</string>
<string name="pin">Pin</string>
<string name="pin_does_not_match">Pin does not match</string>
<string name="confirm_pin">Confirm Pin</string>
<string name="enter_pin">Enter PIN</string>
</resources>