From bcd3b1892a808e94240f1e8f49b202aa38360851 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 2 Aug 2024 13:28:18 -0300 Subject: [PATCH] login redesign --- .../nostrsigner/LocalPreferences.kt | 8 + .../greenart7c3/nostrsigner/models/Account.kt | 2 + .../nostrsigner/ui/AccountScreen.kt | 2 +- .../nostrsigner/ui/AccountStateViewModel.kt | 116 +- .../greenart7c3/nostrsigner/ui/LoginScreen.kt | 1069 ++++++++++++----- .../ui/actions/AccountsBottomSheet.kt | 4 +- app/src/main/res/values/strings.xml | 14 +- 7 files changed, 915 insertions(+), 300 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index 17eaa28e..93975094 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -52,6 +52,8 @@ private object PrefKeys { const val USE_AUTH = "use_auth" const val BIOMETRICS_TYPE = "biometrics_type" const val LAST_BIOMETRICS_TIME = "last_biometrics_time" + const val SIGN_POLICY = "sign_policy" + const val SEED_WORDS = "seed_words" } @Immutable @@ -285,6 +287,8 @@ object LocalPreferences { putString(PrefKeys.ACCOUNT_NAME, account.name) putString(PrefKeys.LANGUAGE_PREFS, account.language) putBoolean(PrefKeys.ALLOW_NEW_CONNECTIONS, account.allowNewConnections) + putInt(PrefKeys.SIGN_POLICY, account.signPolicy) + putStringSet(PrefKeys.SEED_WORDS, account.seedWords.toSet()) }.apply() } @@ -428,6 +432,8 @@ object LocalPreferences { val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort) val language = getString(PrefKeys.LANGUAGE_PREFS, null) val allowNewConnections = getBoolean(PrefKeys.ALLOW_NEW_CONNECTIONS, false) + val signPolicy = getInt(PrefKeys.SIGN_POLICY, 0) + val seedWords = getStringSet(PrefKeys.SEED_WORDS, null) ?: emptySet() HttpClientManager.setDefaultProxy(proxy) val account = Account( @@ -437,6 +443,8 @@ object LocalPreferences { proxyPort = proxyPort, language = language, allowNewConnections = allowNewConnections, + signPolicy = signPolicy, + seedWords = seedWords, ) accountCache.put(npub, account) return account diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt index f0b42e40..c47c46bb 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt @@ -17,6 +17,8 @@ class Account( var proxyPort: Int, var language: String?, var allowNewConnections: Boolean, + var signPolicy: Int, + var seedWords: Set, ) { val saveable: AccountLiveData = AccountLiveData(this) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt index bca7dc4d..b7aa3c49 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt @@ -71,7 +71,7 @@ fun AccountScreen( ) { state -> when (state) { is AccountState.LoggedOff -> { - LoginPage(accountStateViewModel) + MainLoginPage(accountStateViewModel) } is AccountState.LoggedIn -> { var intentData by remember { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt index 026ce6b2..3877c007 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt @@ -108,26 +108,121 @@ class AccountStateViewModel(npub: String?) : ViewModel() { tryLoginExistingAccount(route, npub) } + fun isValidKey(key: String, password: String): Boolean { + try { + val account = + if (key.startsWith("ncryptsec")) { + val newKey = + CryptoUtils.decryptNIP49(key, password) + ?: throw Exception("Could not decrypt key with provided password") + Account( + KeyPair(Hex.decode(newKey)), + name = "", + useProxy = false, + proxyPort = 0, + language = null, + allowNewConnections = false, + signPolicy = 0, + seedWords = emptySet(), + ) + } else if (key.startsWith("nsec")) { + Account( + KeyPair(privKey = key.bechToBytes()), + name = "", + useProxy = false, + proxyPort = 0, + language = null, + allowNewConnections = false, + signPolicy = 0, + seedWords = emptySet(), + ) + } else if (key.contains(" ") && CryptoUtils.isValidMnemonic(key)) { + val keyPair = KeyPair(privKey = CryptoUtils.privateKeyFromMnemonic(key)) + Account( + keyPair, + name = "", + useProxy = false, + proxyPort = 0, + language = null, + allowNewConnections = false, + signPolicy = 0, + seedWords = emptySet(), + ) + } else { + Account( + KeyPair(Hex.decode(key)), + name = "", + useProxy = false, + proxyPort = 0, + language = null, + allowNewConnections = false, + signPolicy = 0, + seedWords = emptySet(), + ) + } + return account.keyPair.privKey != null + } catch (e: Exception) { + return false + } + } + fun startUI( key: String, password: String, route: String?, useProxy: Boolean, proxyPort: Int, + signPolicy: Int, ) { val account = if (key.startsWith("ncryptsec")) { val newKey = CryptoUtils.decryptNIP49(key, password) ?: throw Exception("Could not decrypt key with provided password") - Account(KeyPair(Hex.decode(newKey)), name = "", useProxy = useProxy, proxyPort = proxyPort, language = null, allowNewConnections = false) + Account( + KeyPair(Hex.decode(newKey)), + name = "", + useProxy = useProxy, + proxyPort = proxyPort, + language = null, + allowNewConnections = false, + signPolicy = signPolicy, + seedWords = emptySet(), + ) } else if (key.startsWith("nsec")) { - Account(KeyPair(privKey = key.bechToBytes()), name = "", useProxy = useProxy, proxyPort = proxyPort, language = null, allowNewConnections = false) + Account( + KeyPair(privKey = key.bechToBytes()), + name = "", + useProxy = useProxy, + proxyPort = proxyPort, + language = null, + allowNewConnections = false, + signPolicy = signPolicy, + seedWords = emptySet(), + ) } else if (key.contains(" ") && CryptoUtils.isValidMnemonic(key)) { val keyPair = KeyPair(privKey = CryptoUtils.privateKeyFromMnemonic(key)) - Account(keyPair, name = "", useProxy = useProxy, proxyPort = proxyPort, language = null, allowNewConnections = false) + Account( + keyPair, + name = "", + useProxy = useProxy, + proxyPort = proxyPort, + language = null, + allowNewConnections = false, + signPolicy = signPolicy, + seedWords = emptySet(), + ) } else { - Account(KeyPair(Hex.decode(key)), name = "", useProxy = useProxy, proxyPort = proxyPort, language = null, allowNewConnections = false) + Account( + KeyPair(Hex.decode(key)), + name = "", + useProxy = useProxy, + proxyPort = proxyPort, + language = null, + allowNewConnections = false, + signPolicy = signPolicy, + seedWords = emptySet(), + ) } LocalPreferences.updatePrefsForLogin(NostrSigner.getInstance(), account) @@ -137,8 +232,19 @@ class AccountStateViewModel(npub: String?) : ViewModel() { fun newKey( useProxy: Boolean, proxyPort: Int, + signPolicy: Int, + seedWords: Set, ) { - val account = Account(KeyPair(), name = "", useProxy = useProxy, proxyPort = proxyPort, language = null, allowNewConnections = false) + val account = Account( + KeyPair(), + name = "", + useProxy = useProxy, + proxyPort = proxyPort, + language = null, + allowNewConnections = false, + signPolicy = signPolicy, + seedWords = seedWords, + ) LocalPreferences.updatePrefsForLogin(NostrSigner.getInstance(), account) startUI(account, null) } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt index 9d9e8499..48674169 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt @@ -1,7 +1,11 @@ package com.greenart7c3.nostrsigner.ui -import android.util.Log import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,7 +17,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -23,15 +31,18 @@ import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.Checkbox +import androidx.compose.material3.ElevatedButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -41,30 +52,495 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.AutofillNode import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.greenart7c3.nostrsigner.BuildConfig import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.service.PackageUtils import com.greenart7c3.nostrsigner.ui.actions.ConnectOrbotDialog -import com.greenart7c3.nostrsigner.ui.theme.Size35dp +import com.greenart7c3.nostrsigner.ui.components.TitleExplainer +import com.vitorpamplona.quartz.crypto.CryptoUtils.random +import com.vitorpamplona.quartz.crypto.nip06.Bip39Mnemonics +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MainLoginPage( + accountViewModel: AccountStateViewModel, +) { + val scope = rememberCoroutineScope() + val state = rememberPagerState { + 3 + } + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = state, + userScrollEnabled = false, + ) { + when (it) { + 0 -> { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + ) { + Box(modifier = Modifier.height(0.dp)) + + Column( + modifier = Modifier + .padding(20.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = "NostrSigner", + modifier = Modifier + .size(200.dp) + .clip(CircleShape) + .background(colorResource(id = R.color.amber)), + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.app_name), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Row( + modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ElevatedButton( + onClick = { + scope.launch { + state.animateScrollToPage(1) + } + }, + + modifier = Modifier + .height(50.dp), + ) { + Text(text = stringResource(R.string.add_a_key)) + } + + Button( + modifier = Modifier + .height(50.dp), + onClick = { + scope.launch { + state.animateScrollToPage(2) + } + }, + ) { + Text(stringResource(R.string.generate_a_new_key)) + } + } + } + } + } + 1 -> { + LoginPage( + accountViewModel = accountViewModel, + ) + } + 2 -> { + val useProxy = remember { mutableStateOf(false) } + val proxyPort = remember { mutableStateOf("9050") } + var connectOrbotDialogOpen by remember { mutableStateOf(false) } + var seedWords by remember { mutableStateOf(setOf()) } + val pageState = rememberPagerState { + 3 + } + val context = LocalContext.current + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + val entropy = random(16) + seedWords = Bip39Mnemonics.toMnemonics(entropy).toSet() + } + } + + HorizontalPager( + state = pageState, + modifier = Modifier.fillMaxSize(), + userScrollEnabled = false, + ) { currentPage -> + when (currentPage) { + 0 -> { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.seed_words_title), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.seed_words_explainer), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + val firstColumnWords = seedWords.mapIndexedNotNull { + index, word -> + if (index <= 5) { + word + } else { + null + } + } + val secondColumnWords = seedWords.mapIndexedNotNull { + index, word -> + if (index > 5) { + word + } else { + null + } + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + firstColumnWords.forEachIndexed { index, word -> + OutlinedTextField( + word, + onValueChange = {}, + modifier = Modifier.padding(8.dp), + readOnly = true, + prefix = { + Text("${index + 1} - ") + }, + ) + } + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + secondColumnWords.forEachIndexed { index, word -> + OutlinedTextField( + word, + onValueChange = {}, + readOnly = true, + prefix = { + Text("${index + 1 + firstColumnWords.size} - ") + }, + modifier = Modifier.padding(8.dp), + ) + } + } + } + Row( + Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Button( + modifier = Modifier + .height(50.dp), + onClick = { + scope.launch { + @Suppress("KotlinConstantConditions") + if (BuildConfig.FLAVOR != "offline" && PackageUtils.isOrbotInstalled(context)) { + pageState.animateScrollToPage(1) + } else { + pageState.animateScrollToPage(2) + } + } + }, + ) { + Text(text = stringResource(R.string.next)) + } + } + } + } + 1 -> { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = "NostrSigner", + modifier = Modifier + .size(150.dp) + .clip(CircleShape) + .background(colorResource(id = R.color.amber)), + ) + + Spacer(modifier = Modifier.height(40.dp)) + + @Suppress("KotlinConstantConditions") + if (BuildConfig.FLAVOR != "offline" && PackageUtils.isOrbotInstalled(context)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + if (!useProxy.value) { + connectOrbotDialogOpen = true + } else { + useProxy.value = false + } + }, + ) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { value -> + if (value) { + connectOrbotDialogOpen = true + } else { + useProxy.value = false + } + }, + ) + + Text(stringResource(R.string.connect_through_your_orbot_setup)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Button( + modifier = Modifier + .height(50.dp), + onClick = { + scope.launch { + pageState.animateScrollToPage(2) + } + }, + ) { + Text(text = stringResource(R.string.next)) + } + } + } + } + } + else -> { + SignPolicyScreen( + accountViewModel = accountViewModel, + key = "", + password = "", + useProxy = useProxy.value, + proxyPort = proxyPort.value.toInt(), + seedWords = seedWords, + ) + } + } + } + } + } + } +} + +@Composable +fun SignPolicyScreen( + accountViewModel: AccountStateViewModel, + key: String, + password: String, + useProxy: Boolean, + proxyPort: Int, + seedWords: Set, +) { + val radioOptions = listOf( + TitleExplainer( + title = stringResource(R.string.sign_policy_basic), + explainer = stringResource(R.string.sign_policy_basic_explainer), + ), + TitleExplainer( + title = stringResource(R.string.sign_policy_manual), + explainer = stringResource(R.string.sign_policy_manual_explainer), + ), + ) + var selectedOption by remember { mutableIntStateOf(0) } + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + ) { + Box(modifier = Modifier.height(0.dp)) + + Column( + modifier = Modifier + .padding(20.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = "NostrSigner", + modifier = Modifier + .size(150.dp) + .clip(CircleShape) + .background(colorResource(id = R.color.amber)), + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.sign_policy_title), + style = MaterialTheme.typography.titleLarge, + fontSize = 28.sp, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.sign_policy_explainer), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(40.dp)) + + radioOptions.forEachIndexed { index, option -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = selectedOption == index, + onClick = { + selectedOption = index + }, + ) + .border( + width = 1.dp, + color = if (selectedOption == index) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + }, + shape = RoundedCornerShape(8.dp), + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selectedOption == index, + onClick = { + selectedOption = index + }, + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = option.title, + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.titleLarge, + ) + option.explainer?.let { + Text( + text = it, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } + } + Spacer(modifier = Modifier.height(40.dp)) + Button( + onClick = { + if (key.isBlank()) { + accountViewModel.newKey( + useProxy = useProxy, + proxyPort = proxyPort, + signPolicy = selectedOption, + seedWords = seedWords, + ) + } else { + accountViewModel.startUI( + key, + password, + null, + useProxy = useProxy, + proxyPort = proxyPort, + signPolicy = selectedOption, + ) + } + }, + ) { + Text(text = stringResource(R.string.finish)) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @Composable fun LoginPage(accountViewModel: AccountStateViewModel) { + val pageState = rememberPagerState { + 2 + } + val key = remember { mutableStateOf(TextFieldValue("")) } var errorMessage by remember { mutableStateOf("") } var dialogOpen by remember { @@ -83,312 +559,323 @@ fun LoginPage(accountViewModel: AccountStateViewModel) { var connectOrbotDialogOpen by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - Column( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Center, - ) { - // The first child is glued to the top. - // Hence we have nothing at the top, an empty box is used. - Box(modifier = Modifier.height(0.dp)) + HorizontalPager( + state = pageState, + modifier = Modifier.fillMaxSize(), + ) { currentPage -> + when (currentPage) { + 0 -> { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + ) { + // The first child is glued to the top. + // Hence we have nothing at the top, an empty box is used. + Box(modifier = Modifier.height(0.dp)) - // The second child, this column, is centered vertically. - Column( - modifier = Modifier - .padding(20.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(40.dp)) + // The second child, this column, is centered vertically. + Column( + modifier = Modifier + .padding(20.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = "NostrSigner", + modifier = Modifier + .size(150.dp) + .clip(CircleShape) + .background(colorResource(id = R.color.amber)), + ) - var showPassword by remember { - mutableStateOf(false) - } + Spacer(modifier = Modifier.height(40.dp)) - var showCharsPassword by remember { mutableStateOf(false) } - - val autofillNodeKey = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { key.value = TextFieldValue(it) }, - ) - - val autofillNodePassword = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { key.value = TextFieldValue(it) }, - ) - - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNodeKey - LocalAutofillTree.current += autofillNodePassword - - OutlinedTextField( - modifier = Modifier - .onGloballyPositioned { coordinates -> - autofillNodeKey.boundingBox = coordinates.boundsInWindow() - } - .onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodeKey) - } else { - cancelAutofillForNode(autofillNodeKey) - } + var showPassword by remember { + mutableStateOf(false) } - }, - value = key.value, - onValueChange = { key.value = it }, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringResource(R.string.nsec), - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassword) { - stringResource(R.string.show_password) - } else { - stringResource(R.string.hide_password) + + var showCharsPassword by remember { mutableStateOf(false) } + + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { key.value = TextFieldValue(it) }, + ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { key.value = TextFieldValue(it) }, + ) + + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword + + Text( + stringResource(R.string.add_your_key), + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) + + Spacer(modifier = Modifier.height(40.dp)) + + OutlinedTextField( + modifier = Modifier + .onGloballyPositioned { coordinates -> + autofillNodeKey.boundingBox = coordinates.boundsInWindow() + } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodeKey) + } else { + cancelAutofillForNode(autofillNodeKey) + } + } }, + shape = RoundedCornerShape(18.dp), + value = key.value, + onValueChange = { key.value = it }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.nsec), + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource(R.string.hide_password) + }, + ) + } + } + }, + leadingIcon = { + if (dialogOpen) { + SimpleQrCodeScanner { + dialogOpen = false + if (!it.isNullOrEmpty()) { + key.value = TextFieldValue(it) + } + } + } + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = KeyboardActions( + onGo = { + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + if (accountViewModel.isValidKey(key.value.text, password.value.text)) { + scope.launch { + pageState.animateScrollToPage(1) + } + } else { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + ), + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, ) } - } - }, - leadingIcon = { - if (dialogOpen) { - SimpleQrCodeScanner { - dialogOpen = false - if (!it.isNullOrEmpty()) { - key.value = TextFieldValue(it) - } - } - } - IconButton(onClick = { dialogOpen = true }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = KeyboardActions( - onGo = { - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) + + Spacer(modifier = Modifier.height(20.dp)) + + if (needsPassword.value) { + OutlinedTextField( + modifier = Modifier + .onGloballyPositioned { coordinates -> + autofillNodePassword.boundingBox = coordinates.boundsInWindow() + } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodePassword) + } else { + cancelAutofillForNode(autofillNodePassword) + } + } + }, + shape = RoundedCornerShape(18.dp), + value = password.value, + onValueChange = { + password.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.ncryptsec_password), + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (showCharsPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, + ) + } + } + }, + visualTransformation = if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = KeyboardActions( + onGo = { + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + if (accountViewModel.isValidKey(key.value.text, password.value.text)) { + scope.launch { + pageState.animateScrollToPage(1) + } + } else { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + ), + ) } - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = context.getString(R.string.password_is_required) - } + Spacer(modifier = Modifier.height(10.dp)) - if (key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - try { - accountViewModel.startUI( - key.value.text, - password.value.text, - null, - useProxy = useProxy.value, - proxyPort = proxyPort.value.toInt(), - ) - } catch (e: Exception) { - Log.e("Login", "Could not sign in", e) - errorMessage = context.getString(R.string.invalid_key) - } - } - }, - ), - ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - if (needsPassword.value) { - OutlinedTextField( - modifier = Modifier - .onGloballyPositioned { coordinates -> - autofillNodePassword.boundingBox = coordinates.boundsInWindow() - } - .onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodePassword) - } else { - cancelAutofillForNode(autofillNodePassword) - } - } - }, - value = password.value, - onValueChange = { - password.value = it - if (errorMessage.isNotEmpty()) { - errorMessage = "" - } - }, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringResource(R.string.ncryptsec_password), - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsPassword = !showCharsPassword }) { - Icon( - imageVector = if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showCharsPassword) { - stringResource(R.string.show_password) - } else { - stringResource( - R.string.hide_password, - ) + @Suppress("KotlinConstantConditions") + if (BuildConfig.FLAVOR != "offline" && PackageUtils.isOrbotInstalled(context)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } else { + useProxy.value = false + } }, ) + + Text(stringResource(R.string.connect_through_your_orbot_setup)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) } } - }, - visualTransformation = if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = KeyboardActions( - onGo = { - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = context.getString(R.string.password_is_required) - } + Spacer(modifier = Modifier.height(10.dp)) - if (key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - try { - accountViewModel.startUI( - key.value.text, - password.value.text, - null, - useProxy = useProxy.value, - proxyPort = proxyPort.value.toInt(), - ) - } catch (e: Exception) { - Log.e("Login", "Could not sign in", e) - errorMessage = context.getString(R.string.invalid_key) - } + Row( + modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + if (accountViewModel.isValidKey(key.value.text, password.value.text)) { + scope.launch { + pageState.animateScrollToPage(1) + } + } else { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + modifier = Modifier + .height(50.dp), + ) { + Text(text = stringResource(R.string.next)) } - }, - ), + } + } + } + } + else -> { + SignPolicyScreen( + accountViewModel = accountViewModel, + key = key.value.text, + password = password.value.text, + useProxy = useProxy.value, + proxyPort = proxyPort.value.toInt(), + seedWords = emptySet(), ) } - - Spacer(modifier = Modifier.height(10.dp)) - - @Suppress("KotlinConstantConditions") - if (BuildConfig.FLAVOR != "offline" && PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true - } else { - useProxy.value = false - } - }, - ) - - Text(stringResource(R.string.connect_through_your_orbot_setup)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast.makeText( - context, - it, - Toast.LENGTH_LONG, - ) - .show() - } - }, - proxyPort, - ) - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { - Button( - onClick = { - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = context.getString(R.string.password_is_required) - } - - if (key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - try { - accountViewModel.startUI( - key.value.text, - password.value.text, - null, - useProxy = useProxy.value, - proxyPort = proxyPort.value.toInt(), - ) - } catch (e: Exception) { - Log.e("Login", "Could not sign in", e) - errorMessage = context.getString(R.string.invalid_key) - } - } - }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - ) { - Text(text = stringResource(R.string.login)) - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - TextButton( - modifier = Modifier - .padding(30.dp) - .fillMaxWidth(), - onClick = { - accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) - }, - ) { - Text(stringResource(R.string.generate_a_new_key)) - } } } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt index c834bd01..533b5e6a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt @@ -47,7 +47,7 @@ import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.service.toShortenHex import com.greenart7c3.nostrsigner.ui.AccountStateViewModel -import com.greenart7c3.nostrsigner.ui.LoginPage +import com.greenart7c3.nostrsigner.ui.MainLoginPage import com.greenart7c3.nostrsigner.ui.components.ActiveMarker import com.greenart7c3.nostrsigner.ui.components.CloseButton import com.greenart7c3.nostrsigner.ui.components.PostButton @@ -165,7 +165,7 @@ fun AccountsBottomSheet( ) { Surface(modifier = Modifier.fillMaxSize()) { Box { - LoginPage(accountStateViewModel) + MainLoginPage(accountStateViewModel) TopAppBar( title = { Text(text = stringResource(R.string.add_new_account)) }, navigationIcon = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbd09104..4204f6fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,7 +26,7 @@ Invalid key Key is required Login - Generate a new key + Create a key Backup Keys Read your public key No permissions granted @@ -335,4 +335,16 @@ Nip %1$s not found Nip not found for the event kind %1$s Use secret to connect to the application + Add a key + Next + Add your key + Approve basic actions + Recommended for most people. This will minimize the number of interruptions during your app usage. + Manually approve each app + Recommended for privacy-minded people who would like control over each app. Choosing this will prompt you set a preference every time you try a new app. + Select a signing policy + Should I approve nostr events automatically or would you like to review them for each app? + Finish + Your recovery phrase + These words let you recreate your account in case you lose access. Write them down in a safe place