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 { 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 7ca3323eb..7c121402c 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/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/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index abd33e1fa..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 @@ -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,14 +92,7 @@ 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) { - 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") @@ -105,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 { 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..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 @@ -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 @@ -52,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( 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..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 @@ -36,6 +37,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 @@ -74,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 @@ -308,6 +311,11 @@ fun NoteCompose( if (baseReward != null) { DisplayReward(baseReward, baseNote, account, navController) } + + val pow = noteEvent.getPoWRank() + if (pow > 1) { + DisplayPoW(pow) + } } } @@ -567,6 +575,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, @@ -797,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") }) ) } } 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..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 @@ -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,20 @@ 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 = try { + unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } + } catch (e: Exception) { + null + } + + account?.changeZapPaymentRequest( + Nip47URI( + walletConnectPubkey.text, + walletConnectRelay.text.ifBlank { null }, + privKey + ) + ) } else { account?.changeZapPaymentRequest(null) } @@ -106,7 +143,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 +190,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/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, 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 c7a820706..ce1372dfe 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 ec1b86a99..1a330edca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -259,9 +259,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