From b2c13089ce16810d86209b02c2c15bb2dc908c27 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 11 Sep 2024 16:49:35 -0400 Subject: [PATCH] adds NFC-Based transient accounts. Refactors the login screen --- .../amethyst/LocalPreferences.kt | 279 +++--- .../amethyst/model/AccountSettings.kt | 20 +- .../amethyst/ui/navigation/DrawerContent.kt | 13 +- .../ui/screen/AccountStateViewModel.kt | 34 +- .../ui/screen/loggedIn/AccountViewModel.kt | 15 +- .../ui/screen/loggedIn/ConnectOrbotDialog.kt | 34 +- .../ui/screen/loggedOff/AcceptTerms.kt | 75 ++ .../ui/screen/loggedOff/LoginScreen.kt | 920 +++++++++++------- .../ui/screen/loggedOff/OrbotCheckBox.kt | 69 ++ .../ui/screen/loggedOff/SignUpScreen.kt | 152 ++- amethyst/src/main/res/values/strings.xml | 2 + .../quartz/encoders/Nip19Bech32.kt | 26 + 12 files changed, 983 insertions(+), 656 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ee0a90908..2495dc81e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -72,6 +72,7 @@ data class AccountInfo( val npub: String, val hasPrivKey: Boolean, val loggedInWithExternalSigner: Boolean, + val isTransient: Boolean = false, ) private object PrefKeys { @@ -135,16 +136,18 @@ object LocalPreferences { return currentAccount } - private suspend fun updateCurrentAccount(npub: String?) { - if (npub == null) { + private suspend fun updateCurrentAccount(info: AccountInfo?) { + if (info == null) { currentAccount = null withContext(Dispatchers.IO) { encryptedPreferences().edit().clear().apply() } - } else if (currentAccount != npub) { - currentAccount = npub - withContext(Dispatchers.IO) { - encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() + } else if (currentAccount != info.npub) { + currentAccount = info.npub + if (!info.isTransient) { + withContext(Dispatchers.IO) { + encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) }.apply() + } } } } @@ -192,8 +195,12 @@ object LocalPreferences { encryptedPreferences() .edit() - .apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) } - .apply() + .apply { + putString( + PrefKeys.ALL_ACCOUNT_INFO, + Event.mapper.writeValueAsString(accounts.filter { !it.isTransient }), + ) + }.apply() } } @@ -212,12 +219,13 @@ object LocalPreferences { npub, accountSettings.isWriteable(), accountSettings.externalSignerPackageName != null, + accountSettings.transientAccount, ) - updateCurrentAccount(npub) + updateCurrentAccount(accInfo) addAccount(accInfo) } - suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo.npub) + suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo) /** Removes the account from the app level shared preferences */ private suspend fun removeAccount(accountInfo: AccountInfo) { @@ -267,7 +275,7 @@ object LocalPreferences { if (savedAccounts().isEmpty()) { updateCurrentAccount(null) } else if (currentAccount() == accountInfo.npub) { - updateCurrentAccount(savedAccounts().elementAt(0).npub) + updateCurrentAccount(savedAccounts().elementAt(0)) } } } @@ -281,145 +289,147 @@ object LocalPreferences { suspend fun saveToEncryptedStorage(settings: AccountSettings) { Log.d("LocalPreferences", "Saving to encrypted storage") - withContext(Dispatchers.IO) { - val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub()) - prefs - .edit() - .apply { - putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null) - if (settings.externalSignerPackageName != null) { - remove(PrefKeys.NOSTR_PRIVKEY) - putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName) - } else { - remove(PrefKeys.SIGNER_PACKAGE_NAME) - settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } - } - settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays)) - putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom) - putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers) - putString( - PrefKeys.LANGUAGE_PREFS, - Event.mapper.writeValueAsString(settings.languagePreferences), - ) - putString(PrefKeys.TRANSLATE_TO, settings.translateTo) - putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value)) - putString( - PrefKeys.REACTION_CHOICES, - Event.mapper.writeValueAsString(settings.reactionChoices.value), - ) - putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name) - putString( - PrefKeys.DEFAULT_FILE_SERVER, - Event.mapper.writeValueAsString(settings.defaultFileServer), - ) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value) - putString( - PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, - settings.defaultNotificationFollowList.value, - ) - putString( - PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, - settings.defaultDiscoveryFollowList.value, - ) - putString( - PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, - Event.mapper.writeValueAsString(settings.zapPaymentRequest), - ) - if (settings.backupContactList != null) { + if (!settings.transientAccount) { + withContext(Dispatchers.IO) { + val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub()) + prefs + .edit() + .apply { + putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null) + if (settings.externalSignerPackageName != null) { + remove(PrefKeys.NOSTR_PRIVKEY) + putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName) + } else { + remove(PrefKeys.SIGNER_PACKAGE_NAME) + settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } + } + settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } + putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays)) + putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom) + putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers) putString( - PrefKeys.LATEST_CONTACT_LIST, - Event.mapper.writeValueAsString(settings.backupContactList), + PrefKeys.LANGUAGE_PREFS, + Event.mapper.writeValueAsString(settings.languagePreferences), ) - } else { - remove(PrefKeys.LATEST_CONTACT_LIST) - } - - if (settings.backupUserMetadata != null) { + putString(PrefKeys.TRANSLATE_TO, settings.translateTo) + putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value)) putString( - PrefKeys.LATEST_USER_METADATA, - Event.mapper.writeValueAsString(settings.backupUserMetadata), + PrefKeys.REACTION_CHOICES, + Event.mapper.writeValueAsString(settings.reactionChoices.value), ) - } else { - remove(PrefKeys.LATEST_USER_METADATA) - } - - if (settings.backupDMRelayList != null) { + putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name) putString( - PrefKeys.LATEST_DM_RELAY_LIST, - Event.mapper.writeValueAsString(settings.backupDMRelayList), + PrefKeys.DEFAULT_FILE_SERVER, + Event.mapper.writeValueAsString(settings.defaultFileServer), ) - } else { - remove(PrefKeys.LATEST_DM_RELAY_LIST) - } - - if (settings.backupNIP65RelayList != null) { + putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value) + putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value) putString( - PrefKeys.LATEST_NIP65_RELAY_LIST, - Event.mapper.writeValueAsString(settings.backupNIP65RelayList), + PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, + settings.defaultNotificationFollowList.value, ) - } else { - remove(PrefKeys.LATEST_NIP65_RELAY_LIST) - } - - if (settings.backupSearchRelayList != null) { putString( - PrefKeys.LATEST_SEARCH_RELAY_LIST, - Event.mapper.writeValueAsString(settings.backupSearchRelayList), + PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, + settings.defaultDiscoveryFollowList.value, ) - } else { - remove(PrefKeys.LATEST_SEARCH_RELAY_LIST) - } - - if (settings.backupMuteList != null) { putString( - PrefKeys.LATEST_MUTE_LIST, - Event.mapper.writeValueAsString(settings.backupMuteList), + PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, + Event.mapper.writeValueAsString(settings.zapPaymentRequest), ) - } else { - remove(PrefKeys.LATEST_MUTE_LIST) - } - - if (settings.backupPrivateHomeRelayList != null) { - putString( - PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, - Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList), - ) - } else { - remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) - } - - putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) - putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) - putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) - putBoolean(PrefKeys.USE_PROXY, settings.proxy != null) - putInt(PrefKeys.PROXY_PORT, settings.proxyPort) - putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports) - putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers) - - val regularMap = - settings.lastReadPerRoute.value.mapValues { - it.value.value + if (settings.backupContactList != null) { + putString( + PrefKeys.LATEST_CONTACT_LIST, + Event.mapper.writeValueAsString(settings.backupContactList), + ) + } else { + remove(PrefKeys.LATEST_CONTACT_LIST) } - putString( - PrefKeys.LAST_READ_PER_ROUTE, - Event.mapper.writeValueAsString(regularMap), - ) - putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) + if (settings.backupUserMetadata != null) { + putString( + PrefKeys.LATEST_USER_METADATA, + Event.mapper.writeValueAsString(settings.backupUserMetadata), + ) + } else { + remove(PrefKeys.LATEST_USER_METADATA) + } - if (settings.showSensitiveContent.value == null) { - remove(PrefKeys.SHOW_SENSITIVE_CONTENT) - } else { - putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!) - } + if (settings.backupDMRelayList != null) { + putString( + PrefKeys.LATEST_DM_RELAY_LIST, + Event.mapper.writeValueAsString(settings.backupDMRelayList), + ) + } else { + remove(PrefKeys.LATEST_DM_RELAY_LIST) + } - putString( - PrefKeys.PENDING_ATTESTATIONS, - Event.mapper.writeValueAsString(settings.pendingAttestations.value), - ) - }.apply() + if (settings.backupNIP65RelayList != null) { + putString( + PrefKeys.LATEST_NIP65_RELAY_LIST, + Event.mapper.writeValueAsString(settings.backupNIP65RelayList), + ) + } else { + remove(PrefKeys.LATEST_NIP65_RELAY_LIST) + } + + if (settings.backupSearchRelayList != null) { + putString( + PrefKeys.LATEST_SEARCH_RELAY_LIST, + Event.mapper.writeValueAsString(settings.backupSearchRelayList), + ) + } else { + remove(PrefKeys.LATEST_SEARCH_RELAY_LIST) + } + + if (settings.backupMuteList != null) { + putString( + PrefKeys.LATEST_MUTE_LIST, + Event.mapper.writeValueAsString(settings.backupMuteList), + ) + } else { + remove(PrefKeys.LATEST_MUTE_LIST) + } + + if (settings.backupPrivateHomeRelayList != null) { + putString( + PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, + Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList), + ) + } else { + remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) + } + + putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) + putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) + putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) + putBoolean(PrefKeys.USE_PROXY, settings.proxy != null) + putInt(PrefKeys.PROXY_PORT, settings.proxyPort) + putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports) + putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers) + + val regularMap = + settings.lastReadPerRoute.value.mapValues { + it.value.value + } + + putString( + PrefKeys.LAST_READ_PER_ROUTE, + Event.mapper.writeValueAsString(regularMap), + ) + putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) + + if (settings.showSensitiveContent.value == null) { + remove(PrefKeys.SHOW_SENSITIVE_CONTENT) + } else { + putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!) + } + + putString( + PrefKeys.PENDING_ATTESTATIONS, + Event.mapper.writeValueAsString(settings.pendingAttestations.value), + ) + }.apply() + } } } @@ -550,6 +560,7 @@ object LocalPreferences { return@with AccountSettings( keyPair = keyPair, + transientAccount = false, externalSignerPackageName = externalSignerPackageName, localRelays = localRelays, localRelayServers = localRelayServers, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index ce9aa6caf..28aa5610f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -113,6 +113,7 @@ val KIND3_FOLLOWS = " All Follows " @Stable class AccountSettings( val keyPair: KeyPair, + val transientAccount: Boolean = false, var externalSignerPackageName: String? = null, var localRelays: Set = Constants.defaultRelays.toSet(), var localRelayServers: Set = setOf(), @@ -246,14 +247,17 @@ class AccountSettings( fun isProxyEnabled() = proxy != null - fun updateProxy( - enabled: Boolean, - portNumber: String, - ) { - val port = portNumber.toIntOrNull() ?: return - if (proxyPort != port || isProxyEnabled() != enabled) { - proxyPort = portNumber.toInt() - proxy = HttpClientManager.initProxy(enabled, "127.0.0.1", proxyPort) + fun disableProxy() { + if (isProxyEnabled()) { + proxy = HttpClientManager.initProxy(false, "127.0.0.1", proxyPort) + saveAccountSettings() + } + } + + fun enableProxy(portNumber: Int) { + if (proxyPort != portNumber || !isProxyEnabled()) { + proxyPort = portNumber + proxy = HttpClientManager.initProxy(true, "127.0.0.1", proxyPort) saveAccountSettings() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index f3e88a08e..5760feff6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -445,13 +445,6 @@ fun ListContent( var checked by remember { mutableStateOf(accountViewModel.account.settings.proxy != null) } var disconnectTorDialog by remember { mutableStateOf(false) } var conectOrbotDialogOpen by remember { mutableStateOf(false) } - val proxyPort = - remember { - mutableStateOf( - accountViewModel.account.settings.proxyPort - .toString(), - ) - } val context = LocalContext.current @@ -575,7 +568,7 @@ fun ListContent( conectOrbotDialogOpen = false disconnectTorDialog = false checked = true - accountViewModel.enableTor(true, proxyPort) + accountViewModel.enableTor(it) }, onError = { accountViewModel.toast( @@ -583,7 +576,7 @@ fun ListContent( it, ) }, - proxyPort, + currentPortNumber = accountViewModel.account.settings.proxyPort, ) } @@ -597,7 +590,7 @@ fun ListContent( onClick = { disconnectTorDialog = false checked = false - accountViewModel.enableTor(false, proxyPort) + accountViewModel.disableTor() }, ) { Text(text = stringRes(R.string.yes)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 18eec226d..778dbf7b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -94,6 +94,7 @@ class AccountStateViewModel : ViewModel() { key: String, useProxy: Boolean, proxyPort: Int, + transientAccount: Boolean, loginWithExternalSigner: Boolean = false, packageName: String = "", ) = withContext(Dispatchers.IO) { @@ -121,6 +122,7 @@ class AccountStateViewModel : ViewModel() { if (loginWithExternalSigner) { AccountSettings( keyPair = KeyPair(pubKey = pubKeyParsed), + transientAccount = transientAccount, externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }, proxy = proxy, proxyPort = proxyPort, @@ -128,24 +130,28 @@ class AccountStateViewModel : ViewModel() { } else if (key.startsWith("nsec")) { AccountSettings( keyPair = KeyPair(privKey = key.bechToBytes()), + transientAccount = transientAccount, proxy = proxy, proxyPort = proxyPort, ) } else if (key.contains(" ") && CryptoUtils.isValidMnemonic(key)) { AccountSettings( keyPair = KeyPair(privKey = CryptoUtils.privateKeyFromMnemonic(key)), + transientAccount = transientAccount, proxy = proxy, proxyPort = proxyPort, ) } else if (pubKeyParsed != null) { AccountSettings( keyPair = KeyPair(pubKey = pubKeyParsed), + transientAccount = transientAccount, proxy = proxy, proxyPort = proxyPort, ) } else { AccountSettings( keyPair = KeyPair(Hex.decode(key)), + transientAccount = transientAccount, proxy = proxy, proxyPort = proxyPort, ) @@ -194,6 +200,7 @@ class AccountStateViewModel : ViewModel() { password: String, useProxy: Boolean, proxyPort: Int, + transientAccount: Boolean, loginWithExternalSigner: Boolean = false, packageName: String = "", onError: (String?) -> Unit, @@ -213,26 +220,20 @@ class AccountStateViewModel : ViewModel() { onError("Could not decrypt key with provided password") Log.e("Login", "Could not decrypt ncryptsec") } else { - loginSync(newKey, useProxy, proxyPort, loginWithExternalSigner, packageName) { - onError(null) - } + loginSync(newKey, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError) } } else if (EMAIL_PATTERN.matcher(key).matches()) { Nip05NostrAddressVerifier().verifyNip05( key, onSuccess = { publicKey -> - loginSync(Hex.decode(publicKey).toNpub(), useProxy, proxyPort, loginWithExternalSigner, packageName) { - onError(null) - } + loginSync(Hex.decode(publicKey).toNpub(), useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError) }, onError = { onError(it) }, ) } else { - loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName) { - onError(null) - } + loginSync(key, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError) } } } @@ -241,12 +242,13 @@ class AccountStateViewModel : ViewModel() { key: String, useProxy: Boolean, proxyPort: Int, + transientAccount: Boolean, loginWithExternalSigner: Boolean = false, packageName: String = "", - onError: () -> Unit, + onError: (String) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName, onError) + loginSync(key, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError) } } @@ -254,16 +256,17 @@ class AccountStateViewModel : ViewModel() { key: String, useProxy: Boolean, proxyPort: Int, + transientAccount: Boolean, loginWithExternalSigner: Boolean = false, packageName: String = "", - onError: () -> Unit, + onError: (String) -> Unit, ) { try { - loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) + loginAndStartUI(key, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName) } catch (e: Exception) { if (e is CancellationException) throw e Log.e("Login", "Could not sign in", e) - onError() + onError("Could not sign in: " + e.message) } } @@ -273,12 +276,15 @@ class AccountStateViewModel : ViewModel() { name: String? = null, ) { viewModelScope.launch(Dispatchers.IO) { + _accountContent.update { AccountState.Loading } + val keyPair = KeyPair() val tempSigner = NostrSignerSync(keyPair) val accountSettings = AccountSettings( keyPair = keyPair, + transientAccount = false, backupUserMetadata = MetadataEvent.newUser(name, tempSigner), backupContactList = ContactListEvent.createFromScratch( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 606c3bb32..51a614025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -26,7 +26,6 @@ import android.util.Log import android.util.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -1222,12 +1221,16 @@ class AccountViewModel( } } - fun enableTor( - checked: Boolean, - portNumber: MutableState, - ) { + fun disableTor() { viewModelScope.launch(Dispatchers.IO) { - account.settings.updateProxy(checked, portNumber.value) + account.settings.disableProxy() + Amethyst.instance.serviceManager.forceRestart() + } + } + + fun enableTor(portNumber: Int) { + viewModelScope.launch(Dispatchers.IO) { + account.settings.enableProxy(portNumber) Amethyst.instance.serviceManager.forceRestart() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt index 9f5710037..491d31fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -35,7 +35,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,9 +62,9 @@ import kotlinx.coroutines.CancellationException @Composable fun ConnectOrbotDialog( onClose: () -> Unit, - onPost: () -> Unit, + onPost: (port: Int) -> Unit, onError: (String) -> Unit, - portNumber: MutableState, + currentPortNumber: Int?, ) { Dialog( onDismissRequest = onClose, @@ -74,6 +74,13 @@ fun ConnectOrbotDialog( Column( modifier = Modifier.padding(10.dp), ) { + val proxyPort = + remember { + mutableStateOf( + currentPortNumber?.toString() ?: "", + ) + } + Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -85,15 +92,16 @@ fun ConnectOrbotDialog( UseOrbotButton( onPost = { - try { - Integer.parseInt(portNumber.value) - } catch (e: Exception) { - if (e is CancellationException) throw e - onError(toastMessage) - return@UseOrbotButton - } + val port = + try { + Integer.parseInt(proxyPort.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + onError(toastMessage) + return@UseOrbotButton + } - onPost() + onPost(port) }, isActive = true, ) @@ -137,8 +145,8 @@ fun ConnectOrbotDialog( horizontalArrangement = Arrangement.Center, ) { OutlinedTextField( - value = portNumber.value, - onValueChange = { portNumber.value = it }, + value = proxyPort.value, + onValueChange = { proxyPort.value = it }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.None, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt new file mode 100644 index 000000000..6e67a0672 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun AcceptTerms( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + + val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) + val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) + + val annotatedTermsString = + buildAnnotatedString { + withStyle(regularText) { append(stringRes(R.string.i_accept_the)) } + + withStyle(clickableTextStyle) { + pushStringAnnotation("openTerms", "") + append(stringRes(R.string.terms_of_use)) + pop() + } + } + + val uri = LocalUriHandler.current + + ClickableText( + annotatedTermsString, + ) { spanOffset -> + annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { span -> + if (span.tag == "openTerms") { + runCatching { + uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index aed967394..638567fc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedOff import android.app.Activity +import android.content.Intent import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -33,11 +34,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -66,6 +67,8 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInWindow @@ -73,18 +76,15 @@ 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.platform.LocalUriHandler import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString 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.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -94,8 +94,8 @@ import com.vitorpamplona.amethyst.service.PackageUtils import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -104,10 +104,12 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.SignerType import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.UUID @@ -123,7 +125,6 @@ fun LoginPage() { ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginPage( accountStateViewModel: AccountStateViewModel, @@ -135,12 +136,12 @@ fun LoginPage( val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } var termsAcceptanceIsRequired by remember { mutableStateOf("") } - val uri = LocalUriHandler.current val context = LocalContext.current - var dialogOpen by remember { mutableStateOf(false) } val useProxy = remember { mutableStateOf(false) } val proxyPort = remember { mutableStateOf("9050") } - var connectOrbotDialogOpen by remember { mutableStateOf(false) } + val isNFCOrQR = remember { mutableStateOf(false) } + val isTemporary = remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() var loginWithExternalSigner by remember { mutableStateOf(false) } @@ -154,84 +155,29 @@ fun LoginPage( } } + val passwordFocusRequester = remember { FocusRequester() } + if (loginWithExternalSigner) { - val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } - val id = remember { UUID.randomUUID().toString() } + PrepareExternalSignerReceiver { pubkey, packageName -> + key.value = TextFieldValue(pubkey) + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) + } - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - scope.launch(Dispatchers.Main) { - Toast - .makeText( - Amethyst.instance, - "Sign request rejected", - Toast.LENGTH_SHORT, - ).show() - } - } else { - result.data?.let { externalSignerLauncher.newResult(it) } - } - }, - ) + if (key.value.text.isBlank()) { + errorMessage = stringRes(context, R.string.key_is_required) + } - val activity = getActivity() as MainActivity - - DisposableEffect(launcher, activity, externalSignerLauncher) { - externalSignerLauncher.registerLauncher( - launcher = { - try { - activity.prepareToLaunchSigner() - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - scope.launch(Dispatchers.Main) { - Toast - .makeText( - Amethyst.instance, - R.string.error_opening_external_signer, - Toast.LENGTH_SHORT, - ).show() - } - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - onDispose { externalSignerLauncher.clearLauncher() } - } - - LaunchedEffect(loginWithExternalSigner, externalSignerLauncher) { - externalSignerLauncher.openSignerApp( - "", - SignerType.GET_PUBLIC_KEY, - "", - id, - ) { result -> - val split = result.split("-") - val pubkey = split.first() - val packageName = if (split.size > 1) split[1] else "" - key.value = TextFieldValue(pubkey) - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountStateViewModel.login( - key.value.text, - useProxy.value, - proxyPort.value.toInt(), - true, - packageName, - ) { - errorMessage = stringRes(context, R.string.invalid_key) - } + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountStateViewModel.login( + key = key.value.text, + useProxy = useProxy.value, + proxyPort = proxyPort.value.toInt(), + transientAccount = isTemporary.value, + loginWithExternalSigner = true, + packageName = packageName, + ) { + errorMessage = stringRes(context, R.string.invalid_key) } } } @@ -241,6 +187,7 @@ fun LoginPage( modifier = Modifier .fillMaxSize() + .imePadding() .verticalScroll(rememberScrollState()) .padding(Size20dp), verticalArrangement = Arrangement.Center, @@ -255,129 +202,51 @@ fun LoginPage( Spacer(modifier = Modifier.height(40.dp)) - var showCharsKey by remember { mutableStateOf(false) } - 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) - } - } - }, + KeyTextField( value = key.value, - onValueChange = { - key.value = it + onValueChange = { value, isQr -> + key.value = value + if (isQr) { + isNFCOrQR.value = true + isTemporary.value = true + } if (errorMessage.isNotEmpty()) { errorMessage = "" } }, - keyboardOptions = - KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.nsec_npub_hex_private_key), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsKey = !showCharsKey }) { - Icon( - imageVector = - if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showCharsKey) { - stringRes(R.string.show_password) - } else { - stringRes( - 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), - contentDescription = - stringRes( - R.string.login_with_qr_code, - ), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - visualTransformation = - if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } + ) { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) + } - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } + if (key.value.text.isBlank()) { + errorMessage = stringRes(context, R.string.key_is_required) + } - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = stringRes(context, R.string.password_is_required) + } - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login( + key = key.value.text, + password = password.value.text, + useProxy = useProxy.value, + proxyPort = proxyPort.value.toInt(), + transientAccount = isTemporary.value, + ) { + processingLogin = false + errorMessage = + if (it != null) { + stringRes(context, R.string.invalid_key_with_message, it) + } else { + stringRes(context, R.string.invalid_key) } - }, - ), - ) + } + } + } + if (errorMessage.isNotBlank()) { Text( text = errorMessage, @@ -389,20 +258,7 @@ fun LoginPage( Spacer(modifier = Modifier.height(10.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) - } - } - }, + PasswordField( value = password.value, onValueChange = { password.value = it @@ -410,141 +266,68 @@ fun LoginPage( errorMessage = "" } }, - keyboardOptions = - KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.ncryptsec_password), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsPassword = !showCharsPassword }) { - Icon( - imageVector = - if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showCharsPassword) { - stringRes(R.string.show_password) - } else { - stringRes( - R.string.hide_password, - ) - }, - ) - } + passwordFocusRequester, + ) { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = stringRes(context, R.string.key_is_required) + } + + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = stringRes(context, R.string.password_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt(), isTemporary.value) { + processingLogin = false + errorMessage = + if (it != null) { + stringRes(context, R.string.invalid_key_with_message, it) + } else { + stringRes(context, R.string.invalid_key) + } } - }, - visualTransformation = - if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } - } - }, - ), - ) + } + } } Spacer(modifier = Modifier.height(10.dp)) if (PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true - } - }, - ) + OrbotCheckBox( + currentPort = proxyPort.value.toIntOrNull(), + useProxy = useProxy.value, + onCheckedChange = { + useProxy.value = it + }, + onError = { + scope.launch { + Toast + .makeText( + context, + it, + Toast.LENGTH_LONG, + ).show() + } + }, + ) + } - Text(stringRes(R.string.connect_via_tor)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast - .makeText( - context, - it, - Toast.LENGTH_LONG, - ).show() - } - }, - proxyPort, - ) - } + if (isNFCOrQR.value) { + OfferTemporaryAccount( + checked = isTemporary.value, + onCheckedChange = { isTemporary.value = it }, + ) } if (isFirstLogin) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, - ) - - val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) - - val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) - - val annotatedTermsString = - buildAnnotatedString { - withStyle(regularText) { append(stringRes(R.string.i_accept_the)) } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringRes(R.string.terms_of_use)) - pop() - } - } - - ClickableText( - text = annotatedTermsString, - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { span -> - if (span.tag == "openTerms") { - runCatching { - uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") - } - } - } - } - } + AcceptTerms( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it }, + ) if (termsAcceptanceIsRequired.isNotBlank()) { Text( @@ -558,8 +341,9 @@ fun LoginPage( Spacer(modifier = Modifier.height(10.dp)) Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { - Button( + LoginButton( enabled = acceptedTerms.value, + processingLogin = processingLogin, onClick = { if (!acceptedTerms.value) { termsAcceptanceIsRequired = @@ -576,7 +360,7 @@ fun LoginPage( if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { processingLogin = true - accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt(), isTemporary.value) { processingLogin = false errorMessage = if (it != null) { @@ -587,41 +371,21 @@ fun LoginPage( } } }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Row(modifier = Modifier.padding(horizontal = 40.dp)) { - if (processingLogin) { - LoadingAnimation() - Spacer(modifier = DoubleHorzSpacer) - } - Text(stringRes(R.string.login)) - } - } + ) } if (PackageUtils.isExternalSignerInstalled(context)) { Box(modifier = Modifier.padding(40.dp, 20.dp, 40.dp, 0.dp)) { - Button( + LoginWithAmberButton( enabled = acceptedTerms.value, onClick = { if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - stringRes(context, R.string.acceptance_of_terms_is_required) - return@Button + termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) + } else { + loginWithExternalSigner = true } - - loginWithExternalSigner = true - return@Button }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.login_with_external_signer), - modifier = Modifier.padding(horizontal = 40.dp), - ) - } + ) } } @@ -632,16 +396,418 @@ fun LoginPage( Spacer(modifier = Modifier.height(Size20dp)) Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - OutlinedButton( - onClick = onWantsToLogin, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.sign_up), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } + SignUpButton(onWantsToLogin) + } + } + + OpenURIIfNotLoggedIn { + key.value = TextFieldValue(it) + acceptedTerms.value = true + isNFCOrQR.value = true + isTemporary.value = true + if (it.startsWith("ncryptsec1")) { + delay(300) + passwordFocusRequester.requestFocus() + } + } +} + +@Composable +fun OfferTemporaryAccount( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + + Text(stringRes(R.string.temporary_account)) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PasswordField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + passwordFocusRequester: FocusRequester, + onGo: () -> Unit, +) { + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it)) }, + ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it)) }, + ) + + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword + + var showCharsPassword by remember { mutableStateOf(false) } + OutlinedTextField( + modifier = + Modifier + .focusRequester(passwordFocusRequester) + .onGloballyPositioned { coordinates -> + autofillNodePassword.boundingBox = coordinates.boundsInWindow() + }.onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodePassword) + } else { + cancelAutofillForNode(autofillNodePassword) + } + } + }, + value = value, + onValueChange = onValueChange, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringRes(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringRes(R.string.show_password) + } else { + stringRes( + R.string.hide_password, + ) + }, + ) + } + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + onGo() + }, + ), + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun KeyTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue, throughQR: Boolean) -> Unit, + onLogin: () -> Unit, +) { + var dialogOpen by remember { mutableStateOf(false) } + + var showCharsKey by remember { mutableStateOf(false) } + + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it), false) }, + ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it), false) }, + ) + + 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) + } + } + }, + value = value, + onValueChange = { onValueChange(it, false) }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringRes(R.string.nsec_npub_hex_private_key), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsKey = !showCharsKey }) { + Icon( + imageVector = + if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsKey) { + stringRes(R.string.show_password) + } else { + stringRes( + R.string.hide_password, + ) + }, + ) + } + } + }, + leadingIcon = { + if (dialogOpen) { + SimpleQrCodeScanner { + dialogOpen = false + if (!it.isNullOrEmpty()) { + onValueChange(TextFieldValue(it), true) + } + } + } + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + contentDescription = + stringRes( + R.string.login_with_qr_code, + ), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + visualTransformation = + if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + onLogin() + }, + ), + ) +} + +@Composable +private fun PrepareExternalSignerReceiver(onLogin: (pubkey: String, packageName: String) -> Unit) { + val scope = rememberCoroutineScope() + val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } + val id = remember { UUID.randomUUID().toString() } + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> + if (result.resultCode != Activity.RESULT_OK) { + scope.launch(Dispatchers.Main) { + Toast + .makeText( + Amethyst.instance, + "Sign request rejected", + Toast.LENGTH_SHORT, + ).show() + } + } else { + result.data?.let { externalSignerLauncher.newResult(it) } + } + }, + ) + + val activity = getActivity() as MainActivity + + DisposableEffect(launcher, activity, externalSignerLauncher) { + externalSignerLauncher.registerLauncher( + launcher = { + try { + activity.prepareToLaunchSigner() + launcher.launch(it) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Signer", "Error opening Signer app", e) + scope.launch(Dispatchers.Main) { + Toast + .makeText( + Amethyst.instance, + R.string.error_opening_external_signer, + Toast.LENGTH_SHORT, + ).show() + } + } + }, + contentResolver = Amethyst.instance::contentResolverFn, + ) + onDispose { externalSignerLauncher.clearLauncher() } + } + + LaunchedEffect(externalSignerLauncher) { + externalSignerLauncher.openSignerApp( + "", + SignerType.GET_PUBLIC_KEY, + "", + id, + ) { result -> + val split = result.split("-") + val pubkey = split.first() + val packageName = if (split.size > 1) split[1] else "" + + onLogin(pubkey, packageName) + } + } +} + +@Composable +private fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) { + val context = LocalContext.current + val activity = context.getActivity() + val scope = rememberCoroutineScope() + + var currentIntentNextPage by remember { + val uri = + activity.intent + ?.data + ?.toString() + ?.ifBlank { null } + + activity.intent.data = null + + mutableStateOf(uri) + } + + currentIntentNextPage?.let { intentNextPage -> + var nip19 by remember { + mutableStateOf( + Nip19Bech32.tryParseAndClean(currentIntentNextPage), + ) + } + + LaunchedEffect(intentNextPage) { + if (nip19 != null) { + nip19?.let { + scope.launch { + onNewNIP19(it) + } + nip19 = null + } + } else { + scope.launch { + Toast + .makeText( + context, + stringRes(context, R.string.invalid_nip19_uri_description, intentNextPage), + Toast.LENGTH_SHORT, + ).show() + } + } + + currentIntentNextPage = null + } + } + + DisposableEffect(activity) { + val consumer = + Consumer { intent -> + val uri = intent.data?.toString() + if (!uri.isNullOrBlank()) { + val newNip19 = Nip19Bech32.tryParseAndClean(uri) + if (newNip19 != null) { + scope.launch { + onNewNIP19(newNip19) + } + } else { + scope.launch { + delay(1000) + Toast + .makeText( + context, + stringRes(context, R.string.invalid_nip19_uri_description, uri), + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + activity.addOnNewIntentListener(consumer) + onDispose { activity.removeOnNewIntentListener(consumer) } + } +} + +@Composable +fun SignUpButton(onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.sign_up), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} + +@Composable +fun LoginWithAmberButton( + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.login_with_external_signer), + modifier = Modifier.padding(horizontal = 40.dp), + ) + } +} + +@Composable +fun LoginButton( + enabled: Boolean, + processingLogin: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Row(modifier = Modifier.padding(horizontal = 40.dp)) { + if (processingLogin) { + LoadingAnimation() + Spacer(modifier = DoubleHorzSpacer) + } + Text(stringRes(R.string.login)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt new file mode 100644 index 000000000..4ac380941 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun OrbotCheckBox( + currentPort: Int?, + useProxy: Boolean, + onCheckedChange: (Boolean) -> Unit, + onError: (String) -> Unit, +) { + var connectOrbotDialogOpen by remember { mutableStateOf(false) } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } + }, + ) + + Text(stringRes(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + onCheckedChange(true) + }, + onError = onError, + currentPort, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt index 718e49f1f..9af95c790 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt @@ -25,20 +25,18 @@ import androidx.compose.foundation.Image 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -53,13 +51,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -68,7 +62,6 @@ import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.service.PackageUtils import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -100,7 +93,6 @@ fun SignUpPage( val acceptedTerms = remember { mutableStateOf(false) } var termsAcceptanceIsRequired by remember { mutableStateOf("") } - val uri = LocalUriHandler.current val context = LocalContext.current val useProxy = remember { mutableStateOf(false) } val proxyPort = remember { mutableStateOf("9050") } @@ -111,6 +103,7 @@ fun SignUpPage( modifier = Modifier .fillMaxSize() + .imePadding() .verticalScroll(rememberScrollState()) .padding(Size20dp), verticalArrangement = Arrangement.Center, @@ -176,39 +169,10 @@ fun SignUpPage( Spacer(modifier = Modifier.height(10.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, - ) - - val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) - - val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) - - val annotatedTermsString = - buildAnnotatedString { - withStyle(regularText) { append(stringRes(R.string.i_accept_the)) } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringRes(R.string.terms_of_use)) - pop() - } - } - - ClickableText( - text = annotatedTermsString, - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { span -> - if (span.tag == "openTerms") { - runCatching { - uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") - } - } - } - } - } + AcceptTerms( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it }, + ) if (termsAcceptanceIsRequired.isNotBlank()) { Text( @@ -219,45 +183,29 @@ fun SignUpPage( } if (PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true - } - }, - ) - - Text(stringRes(R.string.connect_via_tor)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast - .makeText( - context, - it, - Toast.LENGTH_LONG, - ).show() - } - }, - proxyPort, - ) - } + OrbotCheckBox( + currentPort = proxyPort.value.toIntOrNull(), + useProxy = useProxy.value, + onCheckedChange = { + useProxy.value = it + }, + onError = { + scope.launch { + Toast + .makeText( + context, + it, + Toast.LENGTH_LONG, + ).show() + } + }, + ) } Spacer(modifier = Modifier.height(Size10dp)) Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - Button( + SignUpButton( enabled = acceptedTerms.value, onClick = { if (!acceptedTerms.value) { @@ -272,14 +220,7 @@ fun SignUpPage( accountStateViewModel.newKey(useProxy.value, proxyPort.value.toInt(), displayName.value.text) } }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.create_account), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } + ) } Spacer(modifier = Modifier.height(Size40dp)) @@ -289,16 +230,39 @@ fun SignUpPage( Spacer(modifier = Modifier.height(Size20dp)) Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - OutlinedButton( - onClick = onWantsToLogin, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.login), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } + LoginButton(onWantsToLogin) } } } + +@Composable +fun LoginButton(onWantsToLogin: () -> Unit) { + OutlinedButton( + onClick = onWantsToLogin, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.login), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} + +@Composable +fun SignUpButton( + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.create_account), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2afbb3ad2..8a62400f4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -979,4 +979,6 @@ No torrent apps installed to open and download the file. Select a list to filter the feed + + Log off on device lock diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt index ee4f57a26..06dc00ed0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt @@ -48,6 +48,12 @@ object Nip19Bech32 { KIND(3), } + val nip19PlusNip46regex = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1|nembed1|ncryptsec1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + Pattern.CASE_INSENSITIVE, + ) + val nip19regex = Pattern.compile( "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1|nembed1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", @@ -110,6 +116,26 @@ object Nip19Bech32 { val event: Event, ) : Entity + fun tryParseAndClean(uri: String?): String? { + if (uri == null) return null + + try { + val matcher = nip19PlusNip46regex.matcher(uri) + if (!matcher.find()) { + return null + } + + val type = matcher.group(2) // npub1 + val key = matcher.group(3) // bech32 + + return type + key + } catch (e: Throwable) { + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) + } + + return null + } + fun uriToRoute(uri: String?): ParseReturn? { if (uri == null) return null