adds NFC-Based transient accounts.

Refactors the login screen
This commit is contained in:
Vitor Pamplona 2024-09-11 16:49:35 -04:00
parent f086c0fe10
commit b2c13089ce
12 changed files with 983 additions and 656 deletions

View File

@ -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,

View File

@ -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<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var localRelayServers: Set<String> = 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()
}
}

View File

@ -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))

View File

@ -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(

View File

@ -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<String>,
) {
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()
}
}

View File

@ -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<String>,
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,

View File

@ -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")
}
}
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -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),
)
}
}

View File

@ -979,4 +979,6 @@
<string name="torrent_no_apps">No torrent apps installed to open and download the file.</string>
<string name="select_list_to_filter">Select a list to filter the feed</string>
<string name="temporary_account">Log off on device lock</string>
</resources>

View File

@ -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