From 56e83d3fe334d065027355bee4f2452162cd2ff7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Mar 2023 09:54:01 -0400 Subject: [PATCH 01/10] Adds special private key for Wallet Connect --- .../amethyst/LocalPreferences.kt | 4 +- .../vitorpamplona/amethyst/model/Account.kt | 7 +- .../vitorpamplona/amethyst/service/Nip47.kt | 8 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 540 ++++++++++++------ .../ui/screen/loggedIn/AccountBackupDialog.kt | 102 +--- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 5 +- 7 files changed, 394 insertions(+), 274 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c4c661ece..172647486 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -8,10 +8,10 @@ import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.toByteArray -import com.vitorpamplona.amethyst.service.model.Contact import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent +import com.vitorpamplona.amethyst.ui.note.Nip47URI import fr.acinq.secp256k1.Hex import nostr.postr.Persona import nostr.postr.toHex @@ -215,7 +215,7 @@ object LocalPreferences { val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { - gson.fromJson(it, Contact::class.java) + gson.fromJson(it, Nip47URI::class.java) } } catch (e: Throwable) { e.printStackTrace() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 0e2c36ccd..2fa0088c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.note.Nip47URI import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -56,7 +57,7 @@ class Account( var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), - var zapPaymentRequest: Contact? = null, + var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var backupContactList: ContactListEvent? = null @@ -169,7 +170,7 @@ class Account( if (!isWriteable()) return zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, loggedIn.privKey!!) + val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) Client.send(event, it.relayUri) } @@ -572,7 +573,7 @@ class Account( saveable.invalidateData() } - fun changeZapPaymentRequest(newServer: Contact?) { + fun changeZapPaymentRequest(newServer: Nip47URI?) { zapPaymentRequest = newServer live.invalidateData() saveable.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 0be86829c..8b38638b3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -3,11 +3,12 @@ package com.vitorpamplona.amethyst.ui.note import android.net.Uri import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.toHexKey -import com.vitorpamplona.amethyst.service.model.Contact + +data class Nip47URI(val pubKeyHex: String, val relayUri: String?, val secret: String?) // Rename to the corect nip number when ready. object Nip47 { - fun parse(uri: String): Contact? { + fun parse(uri: String): Nip47URI { // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D val url = Uri.parse(uri) @@ -25,7 +26,8 @@ object Nip47 { } val relay = url.getQueryParameter("relay") + val secret = url.getQueryParameter("secret") - return Contact(pubkeyHex, relay) + return Nip47URI(pubkeyHex, relay, secret) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 3f9b2b5fb..68817ce59 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -1,6 +1,17 @@ package com.vitorpamplona.amethyst.ui.note +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.os.Build import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,8 +23,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider @@ -23,12 +36,14 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,9 +51,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -48,11 +66,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.model.Contact +import com.vitorpamplona.amethyst.model.decodePublicKey +import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope class UpdateZapAmountViewModel : ViewModel() { private var account: Account? = null @@ -61,12 +83,14 @@ class UpdateZapAmountViewModel : ViewModel() { var amountSet by mutableStateOf(listOf()) var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue("")) + var walletConnectSecret by mutableStateOf(TextFieldValue("")) fun load(account: Account) { this.account = account this.amountSet = account.zapAmountChoices this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") } fun toListOfAmounts(commaSeparatedAmounts: String): List { @@ -90,7 +114,16 @@ class UpdateZapAmountViewModel : ViewModel() { account?.changeZapAmounts(amountSet) if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { - account?.changeZapPaymentRequest(Contact(walletConnectPubkey.text, walletConnectRelay.text)) + val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } + val privKey = unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } + + account?.changeZapPaymentRequest( + Nip47URI( + walletConnectPubkey.text, + walletConnectRelay.text.ifBlank { null }, + privKey + ) + ) } else { account?.changeZapPaymentRequest(null) } @@ -106,7 +139,8 @@ class UpdateZapAmountViewModel : ViewModel() { return ( amountSet != account?.zapAmountChoices || walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || - walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") + walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || + walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "") ) } } @@ -152,180 +186,344 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { Spacer(modifier = Modifier.height(10.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - postViewModel.amountSet.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.primary - ), - onClick = { - postViewModel.removeAmount(amountInSats) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + postViewModel.amountSet.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + onClick = { + postViewModel.removeAmount(amountInSats) + } + ) { + Text( + "⚡ ${ + showAmount( + amountInSats.toBigDecimal().setScale(1) + ) + } ✖", + color = Color.White, + textAlign = TextAlign.Center + ) } - ) { - Text( - "⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))} ✖", - color = Color.White, - textAlign = TextAlign.Center + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { + postViewModel.nextAmount = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + + Button( + onClick = { postViewModel.addAmount() }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } + + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = 0.25.dp + ) + + var qrScanning by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.wallet_connect_service), + Modifier.weight(1f) + ) + IconButton(onClick = { + qrScanning = true + }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.wallet_connect_service_explainer), + Modifier.weight(1f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 14.sp + ) + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + try { + val contact = Nip47.parse(it) + if (contact != null) { + postViewModel.walletConnectPubkey = + TextFieldValue(contact.pubKeyHex) + postViewModel.walletConnectRelay = + TextFieldValue(contact.relayUri ?: "") + postViewModel.walletConnectSecret = + TextFieldValue(contact.secret ?: "") + } + } catch (e: IllegalArgumentException) { + scope.launch { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT) + .show() + } + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, + value = postViewModel.walletConnectPubkey, + onValueChange = { + postViewModel.walletConnectPubkey = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None + ), + placeholder = { + Text( + text = "npub, hex", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectRelay, + onValueChange = { postViewModel.walletConnectRelay = it }, + placeholder = { + Text( + text = "relay.server.com", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1 + ) + }, + singleLine = true + ) + } + + var showPassword by remember { + mutableStateOf(false) + } + + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + showPassword = true + } + } + + val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectSecret, + onValueChange = { postViewModel.walletConnectSecret = it }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go + ), + placeholder = { + Text( + text = stringResource(R.string.wallet_connect_service_secret_placeholder), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + trailingIcon = { + IconButton(onClick = { + if (!showPassword) { + authenticate(authTitle, context, scope, keyguardLauncher) { + showPassword = true + } + } else { + showPassword = false + } + }) { + Icon( + imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password + ) + } ) } - } - } - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, - value = postViewModel.nextAmount, - onValueChange = { - postViewModel.nextAmount = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 10.dp) - .weight(1f) - ) - - Button( - onClick = { postViewModel.addAmount() }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.add), color = Color.White) - } - } - - Divider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = 0.25.dp - ) - - var qrScanning by remember { mutableStateOf(false) } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(id = R.string.wallet_connect_service), Modifier.weight(1f)) - IconButton(onClick = { - qrScanning = true - }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colors.primary + }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation() ) } } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(id = R.string.wallet_connect_service_explainer), - Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontSize = 14.sp - ) - } - - if (qrScanning) { - SimpleQrCodeScanner { - qrScanning = false - if (!it.isNullOrEmpty()) { - try { - val contact = Nip47.parse(it) - if (contact != null) { - postViewModel.walletConnectPubkey = TextFieldValue(contact.pubKeyHex) - postViewModel.walletConnectRelay = TextFieldValue(contact.relayUri ?: "") - } - } catch (e: IllegalArgumentException) { - scope.launch { - Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() - } - } - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, - value = postViewModel.walletConnectPubkey, - onValueChange = { - postViewModel.walletConnectPubkey = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None - ), - placeholder = { - Text( - text = "npub, hex", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectRelay, - onValueChange = { postViewModel.walletConnectRelay = it }, - placeholder = { - Text( - text = "relay.server.com", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - maxLines = 1 - ) - }, - singleLine = true - ) - } } } } } + +fun authenticate( + title: String, + context: Context, + scope: CoroutineScope, + keyguardLauncher: ManagedActivityResultLauncher, + onApproved: () -> Unit +) { + val fragmentContext = context.getFragmentActivity()!! + val keyguardManager = + fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + + if (!keyguardManager.isDeviceSecure) { + onApproved() + return + } + + @Suppress("DEPRECATION") + fun keyguardPrompt() { + val intent = keyguardManager.createConfirmDeviceCredentialIntent( + context.getString(R.string.app_name_release), + title + ) + + keyguardLauncher.launch(intent) + } + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + keyguardPrompt() + return + } + + val biometricManager = BiometricManager.from(context) + val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.app_name_release)) + .setSubtitle(title) + .setAllowedAuthenticators(authenticators) + .build() + + val biometricPrompt = BiometricPrompt( + fragmentContext, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + + when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt() + BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt() + else -> + scope.launch { + Toast.makeText( + context, + "${context.getString(R.string.biometric_error)}: $errString", + Toast.LENGTH_SHORT + ).show() + } + } + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + scope.launch { + Toast.makeText( + context, + context.getString(R.string.biometric_authentication_failed), + Toast.LENGTH_SHORT + ).show() + } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onApproved() + } + } + ) + + when (biometricManager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) + else -> keyguardPrompt() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 1ae127357..5567625e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -1,20 +1,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.app.Activity -import android.app.KeyguardManager import android.content.Context import android.content.ContextWrapper -import android.content.Intent -import android.os.Build import android.widget.Toast -import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricPrompt import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -54,6 +46,7 @@ import com.halilibo.richtext.ui.resolveDefaults import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.note.authenticate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import nostr.postr.toNsec @@ -122,7 +115,14 @@ private fun NSecCopyButton( Button( modifier = Modifier.padding(horizontal = 3.dp), onClick = { - authenticatedCopyNSec(context, scope, account, clipboardManager, keyguardLauncher) + authenticate( + title = context.getString(R.string.copy_my_secret_key), + context = context, + scope = scope, + keyguardLauncher = keyguardLauncher + ) { + copyNSec(context, scope, account, clipboardManager) + } }, shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.buttonColors( @@ -151,90 +151,6 @@ fun Context.getFragmentActivity(): FragmentActivity? { return null } -private fun authenticatedCopyNSec( - context: Context, - scope: CoroutineScope, - account: Account, - clipboardManager: ClipboardManager, - keyguardLauncher: ManagedActivityResultLauncher -) { - val fragmentContext = context.getFragmentActivity()!! - val keyguardManager = - fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - - if (!keyguardManager.isDeviceSecure) { - copyNSec(context, scope, account, clipboardManager) - return - } - - @Suppress("DEPRECATION") - fun keyguardPrompt() { - val intent = keyguardManager.createConfirmDeviceCredentialIntent( - context.getString(R.string.app_name_release), - context.getString(R.string.copy_my_secret_key) - ) - - keyguardLauncher.launch(intent) - } - - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { - keyguardPrompt() - return - } - - val biometricManager = BiometricManager.from(context) - val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(context.getString(R.string.app_name_release)) - .setSubtitle(context.getString(R.string.copy_my_secret_key)) - .setAllowedAuthenticators(authenticators) - .build() - - val biometricPrompt = BiometricPrompt( - fragmentContext, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - - when (errorCode) { - BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt() - BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt() - else -> - scope.launch { - Toast.makeText( - context, - "${context.getString(R.string.biometric_error)}: $errString", - Toast.LENGTH_SHORT - ).show() - } - } - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - scope.launch { - Toast.makeText( - context, - context.getString(R.string.biometric_authentication_failed), - Toast.LENGTH_SHORT - ).show() - } - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - copyNSec(context, scope, account, clipboardManager) - } - } - ) - - when (biometricManager.canAuthenticate(authenticators)) { - BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) - else -> keyguardPrompt() - } -} - private fun copyNSec( context: Context, scope: CoroutineScope, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index aee875037..30ebf2b30 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -77,7 +77,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { // Awaits for the event to come back to LocalCache. viewModelScope.launch(Dispatchers.IO) { - delay(1000) + delay(5000) onProgress(0f) } } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e0428a04..0e8a60f8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -258,9 +258,12 @@ Remove from Public Bookmarks Wallet Connect Service - Uses your private key to pay zaps without leaving the app. Anyone with access to your Nostr private key will be able to spend your wallet\'s balance. Only keep funds you are ok to lose and use a private relay if possible. The relay operator can see your payments metadata. + Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible Wallet Connect Pubkey Wallet Connect Relay + Wallet Connect Secret + Show secret key + nsec / hex private key Pledge Amount in Sats From 53393a350c7f1e2ccad56e71a841cfecc60da096 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Mar 2023 09:58:45 -0400 Subject: [PATCH 02/10] Fixes Possible crash when typing invalid keys --- .../vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 68817ce59..ef6e46360 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -115,7 +115,11 @@ class UpdateZapAmountViewModel : ViewModel() { if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } - val privKey = unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } + val privKey = try { + unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } + } catch (e: Exception) { + null + } account?.changeZapPaymentRequest( Nip47URI( From e1f23130548fcef2e7e2dc396440eb060c7db79f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Mar 2023 10:10:04 -0400 Subject: [PATCH 03/10] v0.30.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index bce82dc7b..d324adec4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 107 - versionName "0.30.0" + versionCode 108 + versionName "0.30.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From cdcf6f9b0abfcd8da67038d342a7ab39ee9155ee Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 28 Mar 2023 08:46:07 -0400 Subject: [PATCH 04/10] Adds PoW Display to notes --- .../amethyst/service/model/Event.kt | 21 +++++++++++++++++++ .../amethyst/service/model/EventInterface.kt | 1 + .../amethyst/ui/note/NoteCompose.kt | 20 ++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index d5594a068..9b7e232ae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -56,6 +56,27 @@ open class Event( override fun isTaggedHashes(hashtags: Set) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags } override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1) + override fun getPoWRank(): Int { + var rank = 0 + for (i in 0..id.length) { + if (id[i] == '0') { + rank += 4 + } else if (id[i] in '4'..'7') { + rank += 1 + break + } else if (id[i] in '2'..'3') { + rank += 2 + break + } else if (id[i] == '1') { + rank += 3 + break + } else { + break + } + } + return rank + } + override fun getReward(): BigDecimal? { return try { tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt index f91027df6..07194a0bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -32,4 +32,5 @@ interface EventInterface { fun hashtags(): List fun getReward(): BigDecimal? + fun getPoWRank(): Int } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 5b160fd78..10f5a6f4e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.get @@ -308,6 +309,11 @@ fun NoteCompose( if (baseReward != null) { DisplayReward(baseReward, baseNote, account, navController) } + + val pow = noteEvent.getPoWRank() + if (pow > 1) { + DisplayPoW(pow) + } } } @@ -567,6 +573,20 @@ fun DisplayUncitedHashtags( } } +@Composable +fun DisplayPoW( + pow: Int +) { + Text( + "PoW-$pow", + color = MaterialTheme.colors.primary.copy( + alpha = 0.52f + ), + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) +} + @Composable fun DisplayReward( baseReward: BigDecimal, From 6b61132f15d769098a0f5a698a7a7ecd39c6c1a8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 28 Mar 2023 08:47:39 -0400 Subject: [PATCH 05/10] Makes the Scan QR Button available in smaller screens. --- .../amethyst/ui/qrcode/ShowQRDialog.kt | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index ee60d69f1..b78c33323 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -70,14 +69,6 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { verticalArrangement = Arrangement.SpaceBetween ) { if (presenting) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 30.dp, vertical = 10.dp) - ) { - } - Column(modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { RobohashAsyncImageProxy( @@ -95,14 +86,11 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Text( user.bestDisplayName() ?: "", - modifier = Modifier.padding(top = 7.dp), + modifier = Modifier.padding(top = 5.dp), fontWeight = FontWeight.Bold, fontSize = 18.sp ) } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text(" @${user.bestUsername()}", color = Color.LightGray) - } Row( horizontalArrangement = Arrangement.Center, From ceab807f9839d5f814a452f700187f2011ed6489 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 28 Mar 2023 08:59:08 -0400 Subject: [PATCH 06/10] BugFix for cutting links in half when they are the last word in a post. --- .../amethyst/ui/components/ExpandableRichTextViewer.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 175d5bd8e..652639542 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -43,8 +43,10 @@ fun ExpandableRichTextViewer( var showFullText by remember { mutableStateOf(false) } // Cuts the text in the first space after 350 - val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH) - val whereToCut = if (firstSpaceAfterCut < 0) SHORT_TEXT_LENGTH else firstSpaceAfterCut + val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + + val whereToCut = minOf(firstSpaceAfterCut, firstNewLineAfterCut) val text = if (showFullText) { content From 9ae860a66a12532c671dbbbdd144b9e7823d4e58 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 28 Mar 2023 09:01:26 -0400 Subject: [PATCH 07/10] Moving byte count to count all messages from relays, including errors. --- .../java/com/vitorpamplona/amethyst/service/relays/Relay.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index abd33e1fa..d8904137b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -83,6 +83,8 @@ class Relay( } override fun onMessage(webSocket: WebSocket, text: String) { + eventDownloadCounterInBytes += text.bytesUsedInMemory() + try { val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray val type = msg[0].asString @@ -90,7 +92,6 @@ class Relay( when (type) { "EVENT" -> { // Log.w("Relay", "Relay onEVENT $url, $channel") - eventDownloadCounterInBytes += text.bytesUsedInMemory() val event = Event.fromJson(msg[2], Client.lenient) if (event.kind == 23195 || event.kind == 23196) { From 8eb3a47844cdabf99cc555b5f6450296d6b88d40 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 28 Mar 2023 09:01:47 -0400 Subject: [PATCH 08/10] Removing unnecessary logs from the Relay class --- .../com/vitorpamplona/amethyst/service/relays/Relay.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index d8904137b..c4f819831 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -92,13 +92,7 @@ class Relay( when (type) { "EVENT" -> { // Log.w("Relay", "Relay onEVENT $url, $channel") - val event = Event.fromJson(msg[2], Client.lenient) - - if (event.kind == 23195 || event.kind == 23196) { - println("AAAAA ${event.toJson()}") - } - - listeners.forEach { it.onEvent(this@Relay, channel, event) } + listeners.forEach { it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient)) } } "EOSE" -> listeners.forEach { // Log.w("Relay", "Relay onEOSE $url, $channel") @@ -106,7 +100,6 @@ class Relay( } "NOTICE" -> listeners.forEach { // Log.w("Relay", "Relay onNotice $url, $channel") - // "channel" being the second string in the string array ... it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { From 9590eecacdb8187afa0fb7b7dc1efc6cfc8d95fb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 28 Mar 2023 10:55:30 -0400 Subject: [PATCH 09/10] Turning Relay icon FlowRow into a LazyVerticalGrid to see if improves rendering speed. --- .../amethyst/ui/note/NoteCompose.kt | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 10f5a6f4e..d91dbc4e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -4,6 +4,8 @@ import android.content.Intent import android.graphics.Bitmap import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -13,7 +15,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment @@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.math.BigDecimal +import kotlin.math.ceil @OptIn(ExperimentalFoundationApi::class) @Composable @@ -817,28 +819,35 @@ private fun RelayBadges(baseNote: Note) { var expanded by remember { mutableStateOf(false) } - val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3) + val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList() + val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp val uri = LocalUriHandler.current - FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) { - relaysToDisplay.forEach { - val url = it.removePrefix("wss://").removePrefix("ws://") - Box( - Modifier - .size(15.dp) - .padding(1.dp) - ) { + Spacer(Modifier.height(10.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(start = 4.dp, end = 4.dp), + modifier = Modifier.height(height), + userScrollEnabled = false + ) { + items(relaysToDisplay.size) { + val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://") + + Box(Modifier.padding(1.dp).size(15.dp)) { RobohashFallbackAsyncImage( robot = "https://$url/favicon.ico", model = "https://$url/favicon.ico", contentDescription = stringResource(R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), modifier = Modifier - .fillMaxSize(1f) + .width(13.dp) + .height(13.dp) .clip(shape = CircleShape) + // .border(1.dp, Color.Red) .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://" + url) }) + .clickable(onClick = { uri.openUri("https://$url") }) ) } } From 73cabc1b2518e7ff548617ed2e4620bb9d2798e1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 28 Mar 2023 11:34:20 -0400 Subject: [PATCH 10/10] Fixing the position of the text in short messages with expandable content. --- .../amethyst/ui/components/ExpandableRichTextViewer.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 652639542..3b62e83bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -54,24 +54,23 @@ fun ExpandableRichTextViewer( content.take(whereToCut) } - Box(contentAlignment = Alignment.BottomCenter) { - // CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Box { RichTextViewer( text, canPreview, - modifier, + modifier.align(Alignment.TopStart), tags, backgroundColor, accountViewModel, navController ) - // } if (content.length > whereToCut && !showFullText) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier + .align(Alignment.BottomCenter) .fillMaxWidth() .background( brush = Brush.verticalGradient(