Merge branch 'vitorpamplona:main' into main

This commit is contained in:
believethehype
2023-03-28 21:15:22 +02:00
committed by GitHub
14 changed files with 474 additions and 316 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 107 versionCode 108
versionName "0.30.0" versionName "0.30.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -8,10 +8,10 @@ import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray 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.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.toHex import nostr.postr.toHex
@@ -215,7 +215,7 @@ object LocalPreferences {
val zapPaymentRequestServer = try { val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Contact::class.java) gson.fromJson(it, Nip47URI::class.java)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTrace() e.printStackTrace()
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -56,7 +57,7 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(), var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language, var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L), var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var zapPaymentRequest: Contact? = null, var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false, var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null var backupContactList: ContactListEvent? = null
@@ -169,7 +170,7 @@ class Account(
if (!isWriteable()) return if (!isWriteable()) return
zapPaymentRequest?.let { 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) Client.send(event, it.relayUri)
} }
@@ -572,7 +573,7 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
fun changeZapPaymentRequest(newServer: Contact?) { fun changeZapPaymentRequest(newServer: Nip47URI?) {
zapPaymentRequest = newServer zapPaymentRequest = newServer
live.invalidateData() live.invalidateData()
saveable.invalidateData() saveable.invalidateData()
@@ -3,11 +3,12 @@ package com.vitorpamplona.amethyst.ui.note
import android.net.Uri import android.net.Uri
import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey 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. // Rename to the corect nip number when ready.
object Nip47 { 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 // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri) val url = Uri.parse(uri)
@@ -25,7 +26,8 @@ object Nip47 {
} }
val relay = url.getQueryParameter("relay") val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Contact(pubkeyHex, relay) return Nip47URI(pubkeyHex, relay, secret)
} }
} }
@@ -56,6 +56,27 @@ open class Event(
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags } override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1) override fun firstIsTaggedHashes(hashtags: Set<String>) = 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? { override fun getReward(): BigDecimal? {
return try { return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull() tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
@@ -32,4 +32,5 @@ interface EventInterface {
fun hashtags(): List<String> fun hashtags(): List<String>
fun getReward(): BigDecimal? fun getReward(): BigDecimal?
fun getPoWRank(): Int
} }
@@ -83,6 +83,8 @@ class Relay(
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try { try {
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
val type = msg[0].asString val type = msg[0].asString
@@ -90,14 +92,7 @@ class Relay(
when (type) { when (type) {
"EVENT" -> { "EVENT" -> {
// Log.w("Relay", "Relay onEVENT $url, $channel") // Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounterInBytes += text.bytesUsedInMemory() listeners.forEach { it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient)) }
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) }
} }
"EOSE" -> listeners.forEach { "EOSE" -> listeners.forEach {
// Log.w("Relay", "Relay onEOSE $url, $channel") // Log.w("Relay", "Relay onEOSE $url, $channel")
@@ -105,7 +100,6 @@ class Relay(
} }
"NOTICE" -> listeners.forEach { "NOTICE" -> listeners.forEach {
// Log.w("Relay", "Relay onNotice $url, $channel") // 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)) it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
} }
"OK" -> listeners.forEach { "OK" -> listeners.forEach {
@@ -43,8 +43,10 @@ fun ExpandableRichTextViewer(
var showFullText by remember { mutableStateOf(false) } var showFullText by remember { mutableStateOf(false) }
// Cuts the text in the first space after 350 // Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH) val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val whereToCut = if (firstSpaceAfterCut < 0) SHORT_TEXT_LENGTH else firstSpaceAfterCut 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) { val text = if (showFullText) {
content content
@@ -52,24 +54,23 @@ fun ExpandableRichTextViewer(
content.take(whereToCut) content.take(whereToCut)
} }
Box(contentAlignment = Alignment.BottomCenter) { Box {
// CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
RichTextViewer( RichTextViewer(
text, text,
canPreview, canPreview,
modifier, modifier.align(Alignment.TopStart),
tags, tags,
backgroundColor, backgroundColor,
accountViewModel, accountViewModel,
navController navController
) )
// }
if (content.length > whereToCut && !showFullText) { if (content.length > whereToCut && !showFullText) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.background( .background(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
@@ -4,6 +4,8 @@ import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* 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.CircleShape
import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Bolt
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment 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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get import androidx.core.graphics.get
@@ -74,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
import kotlin.math.ceil
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -308,6 +311,11 @@ fun NoteCompose(
if (baseReward != null) { if (baseReward != null) {
DisplayReward(baseReward, baseNote, account, navController) 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 @Composable
fun DisplayReward( fun DisplayReward(
baseReward: BigDecimal, baseReward: BigDecimal,
@@ -797,28 +819,35 @@ private fun RelayBadges(baseNote: Note) {
var expanded by remember { mutableStateOf(false) } 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 val uri = LocalUriHandler.current
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) { Spacer(Modifier.height(10.dp))
relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://") LazyVerticalGrid(
Box( columns = GridCells.Fixed(3),
Modifier contentPadding = PaddingValues(start = 4.dp, end = 4.dp),
.size(15.dp) modifier = Modifier.height(height),
.padding(1.dp) userScrollEnabled = false
) { ) {
items(relaysToDisplay.size) {
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
Box(Modifier.padding(1.dp).size(15.dp)) {
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico", robot = "https://$url/favicon.ico",
model = "https://$url/favicon.ico", model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon), contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier modifier = Modifier
.fillMaxSize(1f) .width(13.dp)
.height(13.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
// .border(1.dp, Color.Red)
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://" + url) }) .clickable(onClick = { uri.openUri("https://$url") })
) )
} }
} }
@@ -1,6 +1,17 @@
package com.vitorpamplona.amethyst.ui.note 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 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.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider import androidx.compose.material.Divider
@@ -23,12 +36,14 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType 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.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -48,11 +66,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account 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.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope
class UpdateZapAmountViewModel : ViewModel() { class UpdateZapAmountViewModel : ViewModel() {
private var account: Account? = null private var account: Account? = null
@@ -61,12 +83,14 @@ class UpdateZapAmountViewModel : ViewModel() {
var amountSet by mutableStateOf(listOf<Long>()) var amountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
fun load(account: Account) { fun load(account: Account) {
this.account = account this.account = account
this.amountSet = account.zapAmountChoices this.amountSet = account.zapAmountChoices
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.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<Long> { fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
@@ -90,7 +114,20 @@ class UpdateZapAmountViewModel : ViewModel() {
account?.changeZapAmounts(amountSet) account?.changeZapAmounts(amountSet)
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { 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 { } else {
account?.changeZapPaymentRequest(null) account?.changeZapPaymentRequest(null)
} }
@@ -106,7 +143,8 @@ class UpdateZapAmountViewModel : ViewModel() {
return ( return (
amountSet != account?.zapAmountChoices || amountSet != account?.zapAmountChoices ||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || 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)) Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth()) { Column(
Column(modifier = Modifier.animateContentSize()) { modifier = Modifier.verticalScroll(rememberScrollState())
FlowRow( ) {
modifier = Modifier.fillMaxWidth(), Row(modifier = Modifier.fillMaxWidth()) {
horizontalArrangement = Arrangement.Center Column(modifier = Modifier.animateContentSize()) {
) { FlowRow(
postViewModel.amountSet.forEach { amountInSats -> modifier = Modifier.fillMaxWidth(),
Button( horizontalArrangement = Arrangement.Center
modifier = Modifier.padding(horizontal = 3.dp), ) {
shape = RoundedCornerShape(20.dp), postViewModel.amountSet.forEach { amountInSats ->
colors = ButtonDefaults.buttonColors( Button(
backgroundColor = MaterialTheme.colors.primary modifier = Modifier.padding(horizontal = 3.dp),
), shape = RoundedCornerShape(20.dp),
onClick = { colors = ButtonDefaults.buttonColors(
postViewModel.removeAmount(amountInSats) 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
)
}
) )
} }
} },
} visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
}
}
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 ?: "")
}
} 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<Intent, ActivityResult>,
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()
}
}
@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -70,14 +69,6 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
verticalArrangement = Arrangement.SpaceBetween verticalArrangement = Arrangement.SpaceBetween
) { ) {
if (presenting) { if (presenting) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 30.dp, vertical = 10.dp)
) {
}
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
RobohashAsyncImageProxy( RobohashAsyncImageProxy(
@@ -95,14 +86,11 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text( Text(
user.bestDisplayName() ?: "", user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp), modifier = Modifier.padding(top = 5.dp),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 18.sp fontSize = 18.sp
) )
} }
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
@@ -1,20 +1,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.app.Activity import android.app.Activity
import android.app.KeyguardManager
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.Intent
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.note.authenticate
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nostr.postr.toNsec import nostr.postr.toNsec
@@ -122,7 +115,14 @@ private fun NSecCopyButton(
Button( Button(
modifier = Modifier.padding(horizontal = 3.dp), modifier = Modifier.padding(horizontal = 3.dp),
onClick = { 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), shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@@ -151,90 +151,6 @@ fun Context.getFragmentActivity(): FragmentActivity? {
return null return null
} }
private fun authenticatedCopyNSec(
context: Context,
scope: CoroutineScope,
account: Account,
clipboardManager: ClipboardManager,
keyguardLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>
) {
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( private fun copyNSec(
context: Context, context: Context,
scope: CoroutineScope, scope: CoroutineScope,
@@ -77,7 +77,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
// Awaits for the event to come back to LocalCache. // Awaits for the event to come back to LocalCache.
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
delay(1000) delay(5000)
onProgress(0f) onProgress(0f)
} }
} else { } else {
+4 -1
View File
@@ -259,9 +259,12 @@
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string> <string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
<string name="wallet_connect_service">Wallet Connect Service</string> <string name="wallet_connect_service">Wallet Connect Service</string>
<string name="wallet_connect_service_explainer">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.</string> <string name="wallet_connect_service_explainer">Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible</string>
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string> <string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string> <string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">Show secret key</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex private key</string>
<string name="pledge_amount_in_sats">Pledge Amount in Sats</string> <string name="pledge_amount_in_sats">Pledge Amount in Sats</string>