Moves OkHttp to the coroutines version and turns all their usage into suspend functions

This commit is contained in:
Vitor Pamplona
2025-07-09 15:14:58 -04:00
parent b502ab7de8
commit 267d4c505a
47 changed files with 1552 additions and 1317 deletions
+1
View File
@@ -222,6 +222,7 @@ dependencies {
// Websockets API // Websockets API
implementation libs.okhttp implementation libs.okhttp
implementation libs.okhttpCoroutines
// Encrypted Key Storage // Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx implementation libs.androidx.security.crypto.ktx
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.CashuProcessor import com.vitorpamplona.amethyst.service.cashu.CashuParser
import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -38,7 +38,7 @@ class CashuBTest {
@Test() @Test()
fun parseCashuA() { fun parseCashuA() {
runBlocking { runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0] val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
assertEquals(cashuTokenA, parsed.token) assertEquals(cashuTokenA, parsed.token)
assertEquals("https://8333.space:3338", parsed.mint) assertEquals("https://8333.space:3338", parsed.mint)
@@ -59,7 +59,7 @@ class CashuBTest {
@Test() @Test()
fun parseCashuB() = fun parseCashuB() =
runBlocking { runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB1, parsed[0].token) assertEquals(cashuTokenB1, parsed[0].token)
assertEquals("http://localhost:3338", parsed[0].mint) assertEquals("http://localhost:3338", parsed[0].mint)
@@ -84,7 +84,7 @@ class CashuBTest {
@Test() @Test()
fun parseCashuB2() = fun parseCashuB2() =
runBlocking { runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB2, parsed[0].token) assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint) assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
@@ -58,7 +58,6 @@ class DMFileDecryptionTest {
val request = val request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url) .url(url)
.get() .get()
.build() .build()
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@Suppress("SENSELESS_COMPARISON")
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark" val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
fun debugState(context: Context) { fun debugState(context: Context) {
@@ -142,10 +143,10 @@ fun debugState(context: Context) {
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L }) .sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
qttNotes.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) -> qttNotes.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ") Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB ")
} }
qttAddressables.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) -> qttAddressables.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ") Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB ")
} }
} }
@@ -167,6 +167,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
@@ -625,19 +626,23 @@ class Account(
} }
fun createZapRequestFor( fun createZapRequestFor(
userPubKeyHex: String, user: User,
message: String = "", message: String = "",
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
onReady: (LnZapRequestEvent) -> Unit, onReady: (LnZapRequestEvent) -> Unit,
) { ) {
val relays = nip65RelayList.inboxFlow.value + user.inboxRelays()
LnZapRequestEvent.create( LnZapRequestEvent.create(
userPubKeyHex, userHex = user.pubkeyHex,
nip65RelayList.inboxFlow.value.toSet(), relays = relays,
signer, signer = signer,
message, message = message,
zapType, zapType = zapType,
onReady = onReady, ) { zapRequest ->
) LocalCache.justConsumeMyOwnEvent(zapRequest)
onReady(zapRequest)
}
} }
suspend fun report( suspend fun report(
@@ -1989,6 +1994,21 @@ class Account(
} }
} }
suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? =
if (event.isPrivateZap()) {
if (isWriteable()) {
tryAndWait { continuation ->
event.decryptPrivateZap(signer) {
continuation.resume(it)
}
}
} else {
null
}
} else {
null
}
fun isAllHidden(users: Set<HexKey>): Boolean = users.all { isHidden(it) } fun isAllHidden(users: Set<HexKey>): Boolean = users.all { isHidden(it) }
fun isHidden(user: User) = isHidden(user.pubkeyHex) fun isHidden(user: User) = isHidden(user.pubkeyHex)
@@ -628,6 +628,7 @@ object LocalCache : ILocalCache {
wasVerified: Boolean, wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified) ) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("DEPRECATION")
fun consume( fun consume(
event: TorrentCommentEvent, event: TorrentCommentEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -682,6 +683,7 @@ object LocalCache : ILocalCache {
wasVerified: Boolean, wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified) ) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("DEPRECATION")
fun consume( fun consume(
event: GitReplyEvent, event: GitReplyEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -778,6 +780,7 @@ object LocalCache : ILocalCache {
return false return false
} }
@Suppress("DEPRECATION")
fun computeReplyTo(event: Event): List<Note> = fun computeReplyTo(event: Event): List<Note> =
when (event) { when (event) {
is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
@@ -1318,6 +1321,7 @@ object LocalCache : ILocalCache {
return false return false
} }
@Suppress("DEPRECATION")
private fun deleteNote(deleteNote: Note) { private fun deleteNote(deleteNote: Note) {
val deletedEvent = deleteNote.event val deletedEvent = deleteNote.event
@@ -3057,6 +3061,7 @@ object LocalCache : ILocalCache {
} }
} }
@Suppress("DEPRECATION")
private fun justConsumeInnerInner( private fun justConsumeInnerInner(
event: Event, event: Event,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -1,426 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: List<Proof>,
)
@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)
object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
checkNotInMainThread()
val newCachuData = CashuProcessor().parse(token)
cashuCache.put(token, newCachuData)
}
return cashuCache[token]
}
}
class CashuProcessor {
@Serializable
class V3Token(
val unit: String?,
val memo: String?,
val token: List<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
if (cashuToken.startsWith("cashuA")) {
return parseCashuA(cashuToken)
}
if (cashuToken.startsWith("cashuB")) {
return parseCashuB(cashuToken)
}
return GenericLoadable.Error("Could not parse this cashu token")
}
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
val converted =
cashu.token.map { token ->
val proofs = token.proofs
val mint = token.mint
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
@Serializable
class V4Token(
// mint
val m: String,
// unit
val u: String,
// memo
val d: String? = null,
val t: Array<V4T>?,
)
@Serializable
class V4T(
// identifier
@ByteString
val i: ByteArray,
val p: Array<V4Proof>,
)
@Serializable
class V4Proof(
// amount
val a: Int,
// secret
val s: String,
// signature
@ByteString
val c: ByteArray,
// no idea what this is
val d: V4DleqProof? = null,
// witness
val w: String? = null,
)
@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)
@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
val converted =
v4proofs.map { id ->
val proofs =
id.p.map {
Proof(
it.a,
id.i.toHexKey(),
it.s,
it.c.toHexKey(),
)
}
val mint = v4Token.m
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
runCatching {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
okHttpClient = okHttpClient,
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
okHttpClient = okHttpClient,
onSuccess = { fees ->
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
okHttpClient = okHttpClient,
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, okHttpClient, onSuccess, onError, context)
},
onProgress = {},
onError = onError,
context = context,
)
},
onError = onError,
context,
)
},
onProgress = {},
onError = onError,
context = context,
)
}
}
fun feeCalculator(
mintAddress: String,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
try {
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost,
)
} else {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
private fun meltInvoice(
token: CashuToken,
invoice: String,
fees: Int,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
try {
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(
context,
R.string.cashu_successful_redemption_explainer,
token.totalAmount.toString(),
fees.toString(),
),
)
} else {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
}
@@ -20,18 +20,18 @@
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05 import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync
class Nip05NostrAddressVerifier { class Nip05NostrAddressVerifier {
suspend fun fetchNip05Json( suspend fun fetchNip05Json(
nip05: String, nip05: String,
okttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
onSuccess: suspend (String) -> Unit, onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
@@ -45,30 +45,22 @@ class Nip05NostrAddressVerifier {
} }
try { try {
val request = val request = Request.Builder().url(url).build()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
okttpClient(url)
.newBuilder()
.followRedirects(false)
.build()
.newCall(request)
.execute()
.use {
checkNotInMainThread()
if (it.isSuccessful) { // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
onSuccess(it.body.string()) val client = okHttpClient(url).newBuilder().followRedirects(false).build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
onSuccess(response.body.string())
} else { } else {
onError( onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct", "Could not resolve $nip05. Error: ${response.code}. Check if the server is up and if the address $nip05 is correct",
) )
} }
} }
}
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}") onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}")
@@ -77,7 +69,7 @@ class Nip05NostrAddressVerifier {
suspend fun verifyNip05( suspend fun verifyNip05(
nip05: String, nip05: String,
okttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
onSuccess: suspend (String) -> Unit, onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
) { ) {
@@ -86,7 +78,7 @@ class Nip05NostrAddressVerifier {
fetchNip05Json( fetchNip05Json(
nip05, nip05,
okttpClient, okHttpClient,
onSuccess = { onSuccess = {
checkNotInMainThread() checkNotInMainThread()
@@ -22,18 +22,18 @@ package com.vitorpamplona.amethyst.service
import android.util.Log import android.util.Log
import android.util.LruCache import android.util.LruCache
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener.onError
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okhttp3.Call import kotlinx.coroutines.Dispatchers
import okhttp3.Callback import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.coroutines.executeAsync
import java.io.IOException
object Nip11CachedRetriever { object Nip11CachedRetriever {
sealed class RetrieveResult( sealed class RetrieveResult(
@@ -160,7 +160,6 @@ class Nip11Retriever {
onInfo: (Nip11RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit, onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
) { ) {
checkNotInMainThread()
val url = relay.toHttp() val url = relay.toHttp()
try { try {
val request: Request = val request: Request =
@@ -170,22 +169,16 @@ class Nip11Retriever {
.url(url) .url(url)
.build() .build()
okHttpClient(url) val client = okHttpClient(url)
.newCall(request)
.enqueue( client.newCall(request).executeAsync().use { response ->
object : Callback { withContext(Dispatchers.IO) {
override fun onResponse( val body = response.body.string()
call: Call,
response: Response,
) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try { try {
if (it.isSuccessful) { if (response.isSuccessful) {
onInfo(Nip11RelayInformation.fromJson(body)) onInfo(Nip11RelayInformation.fromJson(body))
} else { } else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -198,16 +191,6 @@ class Nip11Retriever {
} }
} }
} }
override fun onFailure(
call: Call,
e: IOException,
) {
Log.e("RelayInfoFail", "${relay.url} unavailable", e)
onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
},
)
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service
import android.util.Log import android.util.Log
import android.util.LruCache import android.util.LruCache
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import okhttp3.EventListener import okhttp3.EventListener
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol import okhttp3.Protocol
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync
import okio.ByteString.Companion.toByteString import okio.ByteString.Companion.toByteString
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -49,12 +49,10 @@ object OnlineChecker {
return false return false
} }
fun isOnline( suspend fun isOnline(
url: String?, url: String?,
okttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
): Boolean { ): Boolean {
checkNotInMainThread()
if (url.isNullOrBlank()) return false if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online return checkOnlineCache.get(url).online
@@ -66,7 +64,6 @@ object OnlineChecker {
val request = val request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.replace("wss+livekit://", "wss://")) .url(url.replace("wss+livekit://", "wss://"))
.header("Upgrade", "websocket") .header("Upgrade", "websocket")
.header("Connection", "Upgrade") .header("Connection", "Upgrade")
@@ -76,29 +73,23 @@ object OnlineChecker {
.build() .build()
val client = val client =
okttpClient(url) okHttpClient(url)
.newBuilder() .newBuilder()
.eventListener(EventListener.NONE) .eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1)) .protocols(listOf(Protocol.HTTP_1_1))
.build() .build()
client.newCall(request).execute().use { client.newCall(request).executeAsync().use { it.isSuccessful }
checkNotInMainThread()
it.isSuccessful
}
} else { } else {
val request = val request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url) .url(url)
.get() .get()
.build() .build()
val client = okHttpClient(url)
okttpClient(url).newCall(request).execute().use { client.newCall(request).executeAsync().use { it.isSuccessful }
checkNotInMainThread()
it.isSuccessful
}
} }
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
@@ -29,23 +29,25 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.collectSuccessfulOperations import com.vitorpamplona.quartz.utils.collectSuccessfulOperationsReturning
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import kotlin.coroutines.resume
import kotlin.math.round import kotlin.math.round
class ZapPaymentHandler( class ZapPaymentHandler(
@@ -53,12 +55,25 @@ class ZapPaymentHandler(
) { ) {
@Immutable @Immutable
data class Payable( data class Payable(
val info: BaseZapSplitSetup, val info: MyZapSplitSetup,
val user: User?,
val amountMilliSats: Long, val amountMilliSats: Long,
val invoice: String, val invoice: String,
) )
data class UnverifiedZapSplitSetup(
val lnAddress: String?,
val weight: Double = 1.0,
val relay: NormalizedRelayUrl? = null,
val user: User? = null,
)
data class MyZapSplitSetup(
val lnAddress: String,
val weight: Double = 1.0,
val relay: NormalizedRelayUrl? = null,
val user: User? = null,
)
suspend fun zap( suspend fun zap(
note: Note, note: Note,
amountMilliSats: Long, amountMilliSats: Long,
@@ -75,69 +90,106 @@ class ZapPaymentHandler(
val noteEvent = note.event val noteEvent = note.event
val zapSplitSetup = noteEvent?.zapSplitSetup() val zapSplitSetup = noteEvent?.zapSplitSetup()
val zapsToSend = val unverifiedZapsToSend =
if (!zapSplitSetup.isNullOrEmpty()) { if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup zapSplitSetup.map { setup ->
when (setup) {
is ZapSplitSetupLnAddress -> {
UnverifiedZapSplitSetup(
lnAddress = setup.lnAddress,
weight = setup.weight,
)
}
is ZapSplitSetup -> {
val user = LocalCache.checkGetOrCreateUser(setup.pubKeyHex)
UnverifiedZapSplitSetup(
lnAddress = user?.info?.lnAddress(),
weight = setup.weight,
relay = setup.relay,
user = user,
)
}
}
}
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
noteEvent.hosts().map { ZapSplitSetup(it.pubKey, it.relayHint, weight = 1.0) } noteEvent.hosts().map {
val user = LocalCache.checkGetOrCreateUser(it.pubKey)
val lnAddress = user?.info?.lnAddress()
UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user)
}
} else if (noteEvent is AppDefinitionEvent) { } else if (noteEvent is AppDefinitionEvent) {
val appLud16 = noteEvent.appMetaData()?.lnAddress() val appLud16 = noteEvent.appMetaData()?.lnAddress()
if (appLud16 != null) { if (appLud16 != null) {
listOf(ZapSplitSetupLnAddress(appLud16, weight = 1.0)) listOf(UnverifiedZapSplitSetup(appLud16))
} else { } else {
val lud16 = note.author?.info?.lnAddress() val lud16 = note.author?.info?.lnAddress()
listOf(UnverifiedZapSplitSetup(lud16))
if (lud16.isNullOrBlank()) {
if (showErrorIfNoLnAddress) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
note.author,
)
}
return@withContext
}
listOf(ZapSplitSetupLnAddress(lud16, weight = 1.0))
} }
} else { } else {
val lud16 = note.author?.info?.lnAddress() listOf(UnverifiedZapSplitSetup(note.author?.info?.lnAddress()))
}
if (lud16.isNullOrBlank()) {
if (showErrorIfNoLnAddress) { if (showErrorIfNoLnAddress) {
onError( val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() }
stringRes(context, R.string.missing_lud16), errors.forEach {
val message =
if (it.user != null) {
stringRes( stringRes(
context, context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
), it.user.toBestDisplayName(),
note.author,
) )
} } else {
return@withContext stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
} }
listOf(ZapSplitSetupLnAddress(lud16, weight = 1.0)) onError(
stringRes(context, R.string.missing_lud16),
message,
it.user,
)
}
}
val zapsToSend =
unverifiedZapsToSend.mapNotNull {
if (it.lnAddress != null) {
MyZapSplitSetup(
it.lnAddress,
it.weight,
it.relay,
it.user,
)
} else {
null
}
} }
onProgress(0.02f) onProgress(0.02f)
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
if (splitZapRequestPairs.isEmpty()) { val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend)
if (splitZapRequests.isEmpty()) {
onProgress(0.00f) onProgress(0.00f)
return@signAllZapRequests return@withContext
} else { } else {
onProgress(0.05f) onProgress(0.05f)
} }
assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, okHttpClient, onError, onProgress = { val payables =
onProgress(it * 0.7f + 0.05f) // keeps within range. assembleAllInvoices(
}, context) { payables -> requests = splitZapRequests,
totalAmountMilliSats = amountMilliSats,
message = message,
okHttpClient = okHttpClient,
onError = onError,
onProgress = { onProgress(it * 0.7f + 0.05f) },
context = context,
)
if (payables.isEmpty()) { if (payables.isEmpty()) {
onProgress(0.00f) onProgress(0.00f)
return@assembleAllInvoices return@withContext
} else { } else {
onProgress(0.75f) onProgress(0.75f)
} }
@@ -145,19 +197,13 @@ class ZapPaymentHandler(
if (account.hasWalletConnectSetup()) { if (account.hasWalletConnectSetup()) {
payViaNWC(payables, note, onError = onError, onProgress = { payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range. onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context) { }, context)
// onProgress(1f) // onProgress(1f)
}
} else { } else {
onPayViaIntent( onPayViaIntent(payables.toImmutableList())
payables.toImmutableList(),
)
onProgress(0f) onProgress(0f)
} }
} }
}
}
private fun calculateZapValue( private fun calculateZapValue(
amountMilliSats: Long, amountMilliSats: Long,
@@ -170,88 +216,76 @@ class ZapPaymentHandler(
} }
class ZapRequestReady( class ZapRequestReady(
val inputSetup: BaseZapSplitSetup, val inputSetup: MyZapSplitSetup,
val zapRequestJson: String?, val zapRequest: LnZapRequestEvent?,
val user: User? = null,
) )
fun receivingRelaySet(userHex: HexKey): Set<NormalizedRelayUrl>? =
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(userHex),
)?.event as? AdvertisedRelayListEvent
)?.readRelaysNorm()
?.toSet()
suspend fun signAllZapRequests( suspend fun signAllZapRequests(
note: Note, note: Note,
pollOption: Int?, pollOption: Int?,
message: String, message: String,
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
zapsToSend: List<BaseZapSplitSetup>, zapsToSend: List<MyZapSplitSetup>,
onAllDone: suspend (List<ZapRequestReady>) -> Unit, ): List<ZapRequestReady> =
) { mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
collectSuccessfulOperations<BaseZapSplitSetup, ZapRequestReady>( // makes sure the author receives the zap event
items = zapsToSend, val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
runRequestFor = { next: BaseZapSplitSetup, onReady ->
if (next is ZapSplitSetupLnAddress) {
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(ZapRequestReady(next, zapRequestJson))
}
}
} else if (next is ZapSplitSetup) {
val authorRelayList = note.author?.let { receivingRelaySet(it.pubkeyHex) } ?: emptySet()
val userRelayList = receivingRelaySet(next.pubKeyHex) ?: emptySet()
val user = LocalCache.getOrCreateUser(next.pubKeyHex) // makes sure the zap split user receives the zap event
val userRelayList = next.user?.inboxRelays()?.toSet() ?: emptySet()
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList + authorRelayList) { zapRequestJson -> val zapRequest = prepareZapRequestIfNeeded(note, pollOption, message, zapType, next.user, userRelayList + authorRelayList)
onReady(ZapRequestReady(next, zapRequestJson, user))
} ZapRequestReady(next, zapRequest)
}
},
onReady = onAllDone,
)
} }
suspend fun assembleAllInvoices( suspend fun assembleAllInvoices(
requests: List<ZapRequestReady>, requests: List<ZapRequestReady>,
totalAmountMilliSats: Long, totalAmountMilliSats: Long,
message: String, message: String,
showErrorIfNoLnAddress: Boolean,
okHttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit, onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
context: Context, context: Context,
onAllDone: suspend (List<Payable>) -> Unit, ): List<Payable> {
) {
var progressAllPayments = 0.00f var progressAllPayments = 0.00f
val totalWeight = requests.sumOf { it.inputSetup.weight } val totalWeight = requests.sumOf { it.inputSetup.weight }
collectSuccessfulOperations<ZapRequestReady, Payable>( return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady ->
items = requests, try {
runRequestFor = { splitZapRequestPair: ZapRequestReady, onReady ->
assembleInvoice( assembleInvoice(
lud16 = splitZapRequestPair.inputSetup.lnAddress,
splitSetup = splitZapRequestPair.inputSetup, splitSetup = splitZapRequestPair.inputSetup,
nostrZapRequest = splitZapRequestPair.zapRequestJson, nostrZapRequest = splitZapRequestPair.zapRequest,
toUser = splitZapRequestPair.user,
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight), zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight),
message = message, message = message,
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
okHttpClient = okHttpClient, okHttpClient = okHttpClient,
onError = onError,
onProgressStep = { percentStepForThisPayment -> onProgressStep = { percentStepForThisPayment ->
progressAllPayments += percentStepForThisPayment / requests.size progressAllPayments += percentStepForThisPayment / requests.size
onProgress(progressAllPayments) onProgress(progressAllPayments)
}, },
context = context, context = context,
onReady = onReady,
) )
}, } catch (e: LightningAddressResolver.LightningAddressError) {
onReady = onAllDone, onError(e.title, e.msg, splitZapRequestPair.inputSetup.user)
null
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(
context,
R.string.error_unable_to_fetch_invoice,
),
stringRes(
context,
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
e.message,
),
null,
) )
null
}
}
} }
class Paid( class Paid(
@@ -265,11 +299,10 @@ class ZapPaymentHandler(
onError: (String, String, User?) -> Unit, onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
context: Context, context: Context,
onAllDone: suspend (List<Paid>) -> Unit, ): List<Paid> {
) {
var progressAllPayments = 0.00f var progressAllPayments = 0.00f
collectSuccessfulOperations<Payable, Paid>( return collectSuccessfulOperationsReturning(
items = payables, items = payables,
runRequestFor = { payable: Payable, onReady -> runRequestFor = { payable: Payable, onReady ->
account.sendZapPaymentRequestFor( account.sendZapPaymentRequestFor(
@@ -292,7 +325,7 @@ class ZapPaymentHandler(
response.error?.message response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message", ?: response.error?.code?.toString() ?: "Error parsing error message",
), ),
payable.user, payable.info.user,
) )
} else { } else {
progressAllPayments += 0.5f / payables.size progressAllPayments += 0.5f / payables.size
@@ -301,94 +334,60 @@ class ZapPaymentHandler(
}, },
) )
}, },
onReady = onAllDone,
) )
} }
private fun assembleInvoice( private suspend fun assembleInvoice(
splitSetup: BaseZapSplitSetup, lud16: String,
nostrZapRequest: String?, splitSetup: MyZapSplitSetup,
toUser: User?, nostrZapRequest: LnZapRequestEvent?,
zapValue: Long, zapValue: Long,
message: String, message: String,
showErrorIfNoLnAddress: Boolean = true,
okHttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit,
onProgressStep: (percent: Float) -> Unit, onProgressStep: (percent: Float) -> Unit,
context: Context, context: Context,
onReady: (Payable) -> Unit, ): Payable {
) {
var progressThisPayment = 0.00f var progressThisPayment = 0.00f
val lud16 = val invoice =
if (splitSetup is ZapSplitSetupLnAddress) { LightningAddressResolver().lnAddressInvoice(
splitSetup.lnAddress lnAddress = lud16,
} else {
toUser?.info?.lnAddress()
}
if (lud16 != null) {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
milliSats = zapValue, milliSats = zapValue,
message = message, message = message,
nostrRequest = nostrZapRequest, nostrRequest = nostrZapRequest,
okHttpClient = okHttpClient, okHttpClient = okHttpClient,
onError = { title, msg ->
onError(title, msg, toUser)
},
onProgress = { onProgress = {
val step = it - progressThisPayment val step = it - progressThisPayment
progressThisPayment = it progressThisPayment = it
onProgressStep(step) onProgressStep(step)
}, },
context = context, context = context,
onSuccess = { )
onProgressStep(1 - progressThisPayment) onProgressStep(1 - progressThisPayment)
onReady(
Payable( return Payable(
info = splitSetup, info = splitSetup,
user = toUser,
amountMilliSats = zapValue, amountMilliSats = zapValue,
invoice = it, invoice = invoice,
),
) )
},
)
} else {
if (showErrorIfNoLnAddress) {
onError(
stringRes(
context,
R.string.missing_lud16,
),
stringRes(
context,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
toUser?.toBestDisplayName() ?: splitSetup.mainId(),
),
null,
)
}
}
} }
private fun prepareZapRequestIfNeeded( private suspend fun prepareZapRequestIfNeeded(
note: Note, note: Note,
pollOption: Int?, pollOption: Int?,
message: String, message: String,
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
overrideUser: User? = null, overrideUser: User? = null,
additionalRelays: Set<NormalizedRelayUrl>? = null, additionalRelays: Set<NormalizedRelayUrl>? = null,
onReady: (String?) -> Unit, ): LnZapRequestEvent? =
) {
if (zapType != LnZapEvent.ZapType.NONZAP) { if (zapType != LnZapEvent.ZapType.NONZAP) {
tryAndWait { continuation ->
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest -> account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest ->
onReady(zapRequest.toJson()) continuation.resume(zapRequest)
}
} }
} else { } else {
onReady(null) null
}
} }
} }
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu
import android.util.LruCache
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
object CachedCashuParser {
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
val newCachuData = CashuParser().parse(token)
cashuCache.put(token, newCachuData)
}
return cashuCache[token]
}
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu
import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser
import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
class CashuParser {
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
if (cashuToken.startsWith("cashuA")) {
return V3Parser.parseCashuA(cashuToken)
}
if (cashuToken.startsWith("cashuB")) {
return V4Parser.parseCashuB(cashuToken)
}
return GenericLoadable.Error("Could not parse this cashu token")
}
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: List<Proof>,
)
@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)
@@ -0,0 +1,230 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.melt
import android.content.Context
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import kotlin.coroutines.cancellation.CancellationException
class MeltProcessor {
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
): MeltResult {
val baseInvoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
val fees =
feeCalculator(
mintAddress = token.mint,
invoice = baseInvoice,
okHttpClient = okHttpClient,
context = context,
)
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
meltInvoice(token, invoice, okHttpClient, context)
return MeltResult(
token = token,
invoice = invoice,
fees = fees,
)
}
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
}
suspend fun feeCalculator(
mintAddress: String,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
): Int =
try {
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost == null) {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
feeCost
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
private suspend fun meltInvoice(
token: CashuToken,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
) {
try {
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (!successful) {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
}
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.melt
import com.vitorpamplona.amethyst.service.cashu.CashuToken
class MeltResult(
val token: CashuToken,
val invoice: String,
val fees: Int,
)
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v3
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
class V3Parser {
companion object Companion {
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
val converted =
cashu.token.map { token ->
val proofs = token.proofs
val mint = token.mint
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
}
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v3
import com.vitorpamplona.amethyst.service.cashu.Proof
import kotlinx.serialization.Serializable
@Serializable
class V3Token(
val unit: String?,
val memo: String?,
val token: List<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v4
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@Serializable
class V4Token(
// mint
val m: String,
// unit
val u: String,
// memo
val d: String? = null,
val t: Array<V4T>?,
)
@Serializable
class V4T(
// identifier
@ByteString
val i: ByteArray,
val p: Array<V4Proof>,
)
@Serializable
class V4Proof(
// amount
val a: Int,
// secret
val s: String,
// signature
@ByteString
val c: ByteArray,
// no idea what this is
val d: V4DleqProof? = null,
// witness
val w: String? = null,
)
@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v4
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.Proof
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
class V4Parser {
companion object {
@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
val converted =
v4proofs.map { id ->
val proofs =
id.p.map {
Proof(
it.a,
id.i.toHexKey(),
it.s,
it.c.toHexKey(),
)
}
val mint = v4Token.m
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
}
}
@@ -23,145 +23,140 @@ package com.vitorpamplona.amethyst.service.lnurl
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.coroutines.executeAsync
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.net.URLEncoder import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
class LightningAddressResolver { class LightningAddressResolver {
fun assembleUrl(lnaddress: String): String? { fun assembleUrl(lnAddress: String): String? {
val parts = lnaddress.split("@") val parts = lnAddress.split("@")
if (parts.size == 2) { if (parts.size == 2) {
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
} }
if (lnaddress.lowercase().startsWith("lnurl")) { if (lnAddress.lowercase().startsWith("lnurl")) {
return Lud06().toLnUrlp(lnaddress) return Lud06().toLnUrlp(lnAddress)
} }
return null return null
} }
private fun fetchLightningAddressJson( class LightningAddressError(
lnaddress: String, val title: String,
okttpClient: (String) -> OkHttpClient, val msg: String,
onSuccess: (String) -> Unit, ) : Exception(msg)
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
val url = assembleUrl(lnaddress) private suspend fun fetchLightningAddressJson(
lnAddress: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
): String {
val url = assembleUrl(lnAddress)
if (url == null) { if (url == null) {
onError( throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(context, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress),
context,
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
lnaddress,
),
) )
return
} }
val client = okttpClient(url) val client = okHttpClient(url)
try { return try {
val request: Request = val request: Request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url) .url(url)
.build() .build()
client.newCall(request).execute().use { client.newCall(request).executeAsync().use { response ->
if (it.isSuccessful) { withContext(Dispatchers.IO) {
onSuccess(it.body.string()) if (response.isSuccessful) {
response.body.string()
} else { } else {
onError( throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(
context, context,
R.string R.string
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url, url,
lnaddress, lnAddress,
errorMessage(it, context), errorMessage(response, context),
), ),
) )
} }
} }
}
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
e.printStackTrace() throw LightningAddressError(
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(
context, context,
R.string R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception, .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url, url,
lnaddress, lnAddress,
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message, e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
), ),
) )
} }
} }
fun fetchLightningInvoice( suspend fun fetchLightningInvoice(
lnCallback: String, lnCallback: String,
milliSats: Long, milliSats: Long,
message: String, message: String,
nostrRequest: String? = null, nostrRequest: LnZapRequestEvent? = null,
okttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context, context: Context,
) { ): String {
checkNotInMainThread()
val encodedMessage = URLEncoder.encode(message, "utf-8") val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?" val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) { if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") val encodedNostrRequest = URLEncoder.encode(nostrRequest.toJson(), "utf-8")
url += "&nostr=$encodedNostrRequest" url += "&nostr=$encodedNostrRequest"
} }
val client = okttpClient(url) val client = okHttpClient(url)
val request: Request = val request: Request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url) .url(url)
.build() .build()
client.newCall(request).execute().use { return client.newCall(request).executeAsync().use { response ->
if (it.isSuccessful) { withContext(Dispatchers.IO) {
onSuccess(it.body.string()) if (response.isSuccessful) {
response.body.string()
} else { } else {
onError( throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(it, context)), stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
) )
} }
} }
} }
}
fun errorMessage( fun errorMessage(
response: Response, response: Response,
@@ -178,7 +173,7 @@ class LightningAddressResolver {
val statusNode = tree.get("status") val statusNode = tree.get("status")
if (tree.get("error").isBoolean && messageNode != null) { if (tree.get("error").isBoolean && messageNode != null) {
if (errorNode.asBoolean() == true) { if (errorNode.asBoolean()) {
return messageNode.asText() return messageNode.asText()
} }
} }
@@ -203,23 +198,24 @@ class LightningAddressResolver {
?: response.code.toString() ?: response.code.toString()
} }
fun lnAddressInvoice( suspend fun lnAddressInvoice(
lnaddress: String, lnAddress: String,
milliSats: Long, milliSats: Long,
message: String, message: String,
nostrRequest: String? = null, nostrRequest: LnZapRequestEvent? = null,
okHttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
context: Context, context: Context,
) { ): String {
val mapper = jacksonObjectMapper() val mapper = jacksonObjectMapper()
val lnAddressJson =
fetchLightningAddressJson( fetchLightningAddressJson(
lnaddress, lnAddress,
okHttpClient, okHttpClient,
onSuccess = { lnAddressJson -> context,
)
onProgress(0.4f) onProgress(0.4f)
val lnurlp = val lnurlp =
@@ -227,122 +223,111 @@ class LightningAddressResolver {
mapper.readTree(lnAddressJson) mapper.readTree(lnAddressJson)
} catch (t: Throwable) { } catch (t: Throwable) {
if (t is CancellationException) throw t if (t is CancellationException) throw t
onError( throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(
context, context,
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user, R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
lnaddress, lnAddress,
), ),
) )
null
} }
val callback = lnurlp?.get("callback")?.asText()?.ifBlank { null } val callbackUrl = lnurlp?.get("callback")?.asText()?.ifBlank { null }
if (callback == null) { if (callbackUrl == null) {
onError( throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(
context, context,
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user, R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
lnaddress, lnAddress,
), ),
) )
} }
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false
callback?.let { cb -> val invoice =
fetchLightningInvoice( fetchLightningInvoice(
cb, lnCallback = callbackUrl,
milliSats, milliSats = milliSats,
message, message = message,
if (allowsNostr) nostrRequest else null, nostrRequest = if (allowsNostr) nostrRequest else null,
okHttpClient, okHttpClient = okHttpClient,
onSuccess = { context = context,
)
onProgress(0.6f) onProgress(0.6f)
val lnInvoice = val lnInvoice =
try { try {
mapper.readTree(it) mapper.readTree(invoice)
} catch (t: Throwable) { } catch (t: Throwable) {
if (t is CancellationException) throw t if (t is CancellationException) throw t
onError( throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(
context, context,
R.string R.string
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user, .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
lnaddress, lnAddress,
), ),
) )
null
} }
lnInvoice val pr = lnInvoice?.get("pr")?.asText()?.ifBlank { null }
?.get("pr")
?.asText() if (pr == null) {
?.ifBlank { null }
?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats =
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.toLong() == expectedAmountInSats) {
onProgress(0.7f)
onSuccess(pr)
} else {
onProgress(0.0f) onProgress(0.0f)
onError( val reason = lnInvoice?.get("reason")?.asText()?.ifBlank { null }
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( if (reason != null) {
context, throw LightningAddressError(
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnaddress,
expectedAmountInSats.toString(),
),
)
}
}
?: lnInvoice
?.get("reason")
?.asText()
?.ifBlank { null }
?.let { reason ->
onProgress(0.0f)
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(
context, context,
R.string R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user, .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
lnaddress, lnAddress,
reason, reason,
), ),
) )
} } else {
?: run { throw LightningAddressError(
onProgress(0.0f)
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes( stringRes(
context, context,
R.string R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user, .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
lnaddress, lnAddress,
), ),
) )
} }
}, }
onError = onError,
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats =
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.toLong() != expectedAmountInSats) {
onProgress(0.0f)
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context, context,
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnAddress,
expectedAmountInSats.toString(),
),
) )
} }
},
onError = onError, onProgress(0.7f)
context,
) return pr
} }
} }
@@ -25,11 +25,12 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.utils.launchAndWaitAll import com.vitorpamplona.quartz.utils.mapNotNullAsync
import com.vitorpamplona.quartz.utils.tryAndWait import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -37,12 +38,14 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import kotlin.coroutines.resume import kotlin.coroutines.resume
class RegisterAccounts( class RegisterAccounts(
private val accounts: List<AccountInfo>, private val accounts: List<AccountInfo>,
private val client: (String) -> OkHttpClient, private val client: (String) -> OkHttpClient,
) { ) {
@Suppress("SENSELESS_COMPARISON")
val tag = val tag =
if (BuildConfig.FLAVOR == "play") { if (BuildConfig.FLAVOR == "play") {
"RegisterAccounts FirebaseMsgService" "RegisterAccounts FirebaseMsgService"
@@ -52,19 +55,15 @@ class RegisterAccounts(
private suspend fun signAllAuths( private suspend fun signAllAuths(
notificationToken: String, notificationToken: String,
remainingTos: List<Pair<AccountSettings, List<NormalizedRelayUrl>>>, remainingTos: List<Registration>,
output: MutableList<RelayAuthEvent>, ): List<RelayAuthEvent> {
onReady: (List<RelayAuthEvent>) -> Unit,
) {
if (remainingTos.isEmpty()) { if (remainingTos.isEmpty()) {
onReady(output) return emptyList()
return
} }
launchAndWaitAll(remainingTos) { accountRelayPair -> return mapNotNullAsync(remainingTos) { info ->
val result =
tryAndWait { continuation -> tryAndWait { continuation ->
val signer = accountRelayPair.first.createSigner() val signer = info.accountSettings.createSigner()
// TODO: Modify the external launcher to launch as different users. // TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature // Right now it only registers if Amber has already approved this signature
if (signer is NostrSignerExternal) { if (signer is NostrSignerExternal) {
@@ -74,45 +73,46 @@ class RegisterAccounts(
) )
} }
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result -> RelayAuthEvent.create(info.relays, notificationToken, signer) { result ->
continuation.resume(result) continuation.resume(result)
} }
} }
if (result != null) {
output.add(result)
} }
} }
onReady(output) class Registration(
} val accountSettings: AccountSettings,
val relays: List<NormalizedRelayUrl>,
)
// creates proof that it controls all accounts // creates proof that it controls all accounts
private suspend fun signEventsToProveControlOfAccounts( private suspend fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>, accounts: List<AccountInfo>,
notificationToken: String, notificationToken: String,
onReady: (List<RelayAuthEvent>) -> Unit, ): List<RelayAuthEvent> {
) {
val readyToSend = val readyToSend =
accounts accounts
.mapNotNull { .mapNotNull { account ->
if (it.hasPrivKey || it.loggedInWithExternalSigner) { if (account.hasPrivKey || account.loggedInWithExternalSigner) {
Log.d(tag, "Register Account ${it.npub}") Log.d(tag, "Register Account ${account.npub}")
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(account.npub)
if (acc != null && acc.isWriteable()) { if (acc != null && acc.isWriteable()) {
val nip65Read = acc.backupNIP65RelayList?.readRelaysNorm() ?: emptyList() val nip65Read = acc.backupNIP65RelayList?.readRelaysNorm() ?: emptyList()
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ") { it.url } }")
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList()
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ") { it.url } }") if (isDebug) {
val readRelays = nip65Read.joinToString(", ") { it.url }
Log.d(tag, "Register Account ${account.npub} NIP65 Reads $readRelays")
val dmRelays = nip17Read.joinToString(", ") { it.url }
Log.d(tag, "Register Account ${account.npub} NIP17 Reads $dmRelays")
}
val relays = (nip65Read + nip17Read) val relays = (nip65Read + nip17Read)
if (relays.isNotEmpty()) { if (relays.isNotEmpty()) {
Pair(acc, relays) Registration(acc, relays)
} else { } else {
null null
} }
@@ -124,16 +124,10 @@ class RegisterAccounts(
} }
} }
val listOfAuthEvents = mutableListOf<RelayAuthEvent>() return signAllAuths(notificationToken, readyToSend)
signAllAuths(
notificationToken,
readyToSend,
listOfAuthEvents,
onReady,
)
} }
fun postRegistrationEvent(events: List<RelayAuthEvent>) { suspend fun postRegistrationEvent(events: List<RelayAuthEvent>) {
val jsonObject = val jsonObject =
"""{ """{
"events": [ ${events.joinToString(", ") { it.toJson() }} ] "events": [ ${events.joinToString(", ") { it.toJson() }} ]
@@ -147,19 +141,21 @@ class RegisterAccounts(
val request = val request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url) .url(url)
.post(body) .post(body)
.build() .build()
val isSucess = client(url).newCall(request).execute().use { it.isSuccessful } val client = client(url)
Log.i(tag, "Server registration $isSucess")
client.newCall(request).executeAsync().use { response ->
Log.i(tag, "Server registration ${response.isSuccessful}")
}
} }
suspend fun go(notificationToken: String) { suspend fun go(notificationToken: String) {
if (notificationToken.isNotEmpty()) { if (notificationToken.isNotEmpty()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } postRegistrationEvent(signEventsToProveControlOfAccounts(accounts, notificationToken))
} }
} }
} }
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.ots
import android.util.Log import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.quartz.nip03Timestamp.ots.BitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.BitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockHeader import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockHeader
import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException
@@ -51,7 +50,6 @@ class OkHttpBitcoinExplorer(
val request = val request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json") .header("Accept", "application/json")
.url(url) .url(url)
.get() .get()
@@ -95,7 +93,6 @@ class OkHttpBitcoinExplorer(
val request = val request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url) .url(url)
.get() .get()
.build() .build()
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.service.ots package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar
import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp
@@ -60,7 +59,6 @@ class OkHttpCalendar(
val request = val request =
okhttp3.Request okhttp3.Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1") .header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.url(url) .url(url)
@@ -112,7 +110,6 @@ class OkHttpCalendar(
val request = val request =
okhttp3.Request okhttp3.Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1") .header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.url(url) .url(url)
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.service.ots package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit
import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp
@@ -45,7 +44,6 @@ class OkHttpCalendarAsyncSubmit(
val request = val request =
okhttp3.Request okhttp3.Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1") .header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.url(url) .url(url)
@@ -20,19 +20,16 @@
*/ */
package com.vitorpamplona.amethyst.service.playback.composable.controls package com.vitorpamplona.amethyst.service.playback.composable.controls
import android.content.Context
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.components.ShareImageAction import com.vitorpamplona.amethyst.ui.components.ShareImageAction
import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -70,7 +67,7 @@ fun RenderControlButtons(
if (!isLiveStreaming(mediaData.videoUri)) { if (!isLiveStreaming(mediaData.videoUri)) {
AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context -> AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context ->
saveMediaToGalleryInner(mediaData.videoUri, mediaData.mimeType, context, accountViewModel) accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
} }
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle -> AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle ->
@@ -82,23 +79,3 @@ fun RenderControlButtons(
} }
} }
} }
private fun saveMediaToGalleryInner(
videoUri: String?,
mimeType: String?,
localContext: Context,
accountViewModel: AccountViewModel,
) {
MediaSaverToDisk.saveDownloadingIfNeeded(
videoUri = videoUri,
okHttpClient = accountViewModel::okHttpClientForVideo,
mimeType = mimeType,
localContext = localContext,
onSuccess = {
accountViewModel.toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
},
onError = {
accountViewModel.toastManager.toast(R.string.failed_to_save_the_video, null, it)
},
)
}
@@ -20,13 +20,13 @@
*/ */
package com.vitorpamplona.amethyst.service.previews package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync
class UrlPreview { class UrlPreview {
suspend fun fetch( suspend fun fetch(
@@ -52,14 +52,18 @@ class UrlPreview {
.url(url) .url(url)
.get() .get()
.build() .build()
okHttpClient(url).newCall(request).execute().use {
checkNotInMainThread() val client = okHttpClient(url)
if (it.isSuccessful) {
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
val mimeType = val mimeType =
it.headers["Content-Type"]?.toMediaType() response.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}") ?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}")
if (mimeType.type == "text" && mimeType.subtype == "html") { if (mimeType.type == "text" && mimeType.subtype == "html") {
val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType)) val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType)
val data = OpenGraphParser().extractUrlInfo(metaTags)
UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString())
} else if (mimeType.type == "image") { } else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType.toString()) UrlInfoItem(url, image = url, mimeType = mimeType.toString())
@@ -69,7 +73,8 @@ class UrlPreview {
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
} }
} else { } else {
throw IllegalArgumentException("Website returned: " + it.code) throw IllegalArgumentException("Website returned: " + response.code)
}
} }
} }
} }
@@ -27,7 +27,6 @@ import android.provider.OpenableColumns
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -38,10 +37,13 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.coroutines.executeAsync
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import java.io.File import java.io.File
@@ -157,18 +159,16 @@ class BlossomUploader {
requestBuilder requestBuilder
.addHeader("Content-Length", length.toString()) .addHeader("Content-Length", length.toString())
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(apiUrl) .url(apiUrl)
.put(requestBody) .put(requestBody)
val request = requestBuilder.build() val request = requestBuilder.build()
client.newCall(request).execute().use { response -> return client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) { if (response.isSuccessful) {
response.body.use { body -> response.body.use { body ->
val str = body.string() parseResults(body.string())
val result = parseResults(str)
return result
} }
} else { } else {
val errorMessage = response.headers.get("X-Reason") val errorMessage = response.headers.get("X-Reason")
@@ -184,6 +184,7 @@ class BlossomUploader {
} }
} }
} }
}
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
@@ -208,14 +209,14 @@ class BlossomUploader {
val request = val request =
requestBuilder requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(apiUrl.removeSuffix("/") + "/$hash.$extension") .url(apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete() .delete()
.build() .build()
okHttpClient(apiUrl).newCall(request).execute().use { response -> return okHttpClient(apiUrl).newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) { if (response.isSuccessful) {
return true true
} else { } else {
val explanation = HttpStatusMessages.resourceIdFor(response.code) val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) { if (explanation != null) {
@@ -226,6 +227,7 @@ class BlossomUploader {
} }
} }
} }
}
private fun parseResults(body: String): MediaUploadResult { private fun parseResults(body: String): MediaUploadResult {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
@@ -27,7 +27,6 @@ import android.provider.OpenableColumns
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.net.toFile import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -41,12 +40,15 @@ import com.vitorpamplona.quartz.nip96FileStorage.actions.UploadResult
import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.coroutines.executeAsync
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import java.io.InputStream import java.io.InputStream
@@ -168,22 +170,22 @@ class Nip96Uploader {
httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", it.toAuthToken()) } httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", it.toAuthToken()) }
requestBuilder requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl) .url(server.apiUrl)
.post(requestBody) .post(requestBody)
val request = requestBuilder.build() val request = requestBuilder.build()
client.newCall(request).execute().use { response -> return client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) { if (response.isSuccessful) {
response.body.use { body -> response.body.use { body ->
val result = UploadResult.parse(body.string()) val result = UploadResult.parse(body.string())
if (!result.processingUrl.isNullOrBlank()) { if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, okHttpClient, onProgress) waitProcessing(result, server, okHttpClient, onProgress)
} else if (result.status == "success") { } else if (result.status == "success") {
val event = result.nip94Event val event = result.nip94Event
if (event != null) { if (event != null) {
return convertToMediaResult(event) convertToMediaResult(event)
} else { } else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
} }
@@ -219,6 +221,7 @@ class Nip96Uploader {
} }
} }
} }
}
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
@@ -275,17 +278,15 @@ class Nip96Uploader {
val request = val request =
requestBuilder requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete() .delete()
.build() .build()
client.newCall(request).execute().use { response -> return client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) { if (response.isSuccessful) {
response.body.use { body -> val result = DeleteResult.parse(response.body.string())
val result = DeleteResult.parse(body.string()) result.status == "success"
return result.status == "success"
}
} else { } else {
val explanation = HttpStatusMessages.resourceIdFor(response.code) val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) { if (explanation != null) {
@@ -296,6 +297,7 @@ class Nip96Uploader {
} }
} }
} }
}
private suspend fun waitProcessing( private suspend fun waitProcessing(
result: UploadResult, result: UploadResult,
@@ -313,14 +315,15 @@ class Nip96Uploader {
val request: Request = val request: Request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(procUrl) .url(procUrl)
.build() .build()
val client = okHttpClient(procUrl) val client = okHttpClient(procUrl)
client.newCall(request).execute().use { client.newCall(request).executeAsync().use { response ->
if (it.isSuccessful) { withContext(Dispatchers.IO) {
it.body.use { currentResult = UploadResult.parse(it.string()) } if (response.isSuccessful) {
currentResult = UploadResult.parse(response.body.string())
}
} }
} }
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.service.uploads.nip96 package com.vitorpamplona.amethyst.service.uploads.nip96
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo
import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync
class ServerInfoRetriever { class ServerInfoRetriever {
val parser = ServerInfoParser() val parser = ServerInfoParser()
@@ -42,24 +44,25 @@ class ServerInfoRetriever {
.url(parser.assembleUrl(baseUrl)) .url(parser.assembleUrl(baseUrl))
.build() .build()
okHttpClient(baseUrl).newCall(request).execute().use { response -> val client = okHttpClient(baseUrl)
checkNotInMainThread()
response.use { return try {
val body = it.body.string() client.newCall(request).executeAsync().use { response ->
try { withContext(Dispatchers.IO) {
if (it.isSuccessful) { if (response.isSuccessful) {
return parser.parse(baseUrl, body) val body = response.body.string()
parser.parse(baseUrl, body)
} else { } else {
throw RuntimeException( throw RuntimeException(
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
) )
} }
}
}
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) Log.e("RelayInfoFail", "Resulting Message from $baseUrl", e)
throw e throw e
} }
} }
} }
}
}
@@ -54,11 +54,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
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.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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -92,7 +90,6 @@ import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.buttons.PostButton
import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -315,11 +312,11 @@ fun EditPostView(
) { ) {
InvoiceRequest( InvoiceRequest(
lud16, lud16,
user.pubkeyHex, user,
accountViewModel, accountViewModel,
stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice), stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = { onNewInvoice = {
postViewModel.message = postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it) TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false postViewModel.wantsInvoice = false
@@ -27,20 +27,21 @@ import android.media.MediaScannerConnection
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.vitorpamplona.amethyst.BuildConfig import coil3.util.MimeTypeMap.getMimeTypeFromExtension
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.PICTURES_SUBDIRECTORY import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.PICTURES_SUBDIRECTORY
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener.onError
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okhttp3.Call import kotlinx.coroutines.Dispatchers
import okhttp3.Callback import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.coroutines.executeAsync
import okio.BufferedSource import okio.BufferedSource
import okio.IOException
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import okio.source
@@ -48,7 +49,7 @@ import java.io.File
import java.util.UUID import java.util.UUID
object MediaSaverToDisk { object MediaSaverToDisk {
fun saveDownloadingIfNeeded( suspend fun saveDownloadingIfNeeded(
videoUri: String?, videoUri: String?,
okHttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
mimeType: String?, mimeType: String?,
@@ -83,7 +84,7 @@ object MediaSaverToDisk {
* *
* @see PICTURES_SUBDIRECTORY * @see PICTURES_SUBDIRECTORY
*/ */
fun downloadAndSave( suspend fun downloadAndSave(
url: String, url: String,
mimeType: String?, mimeType: String?,
okHttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
@@ -92,32 +93,16 @@ object MediaSaverToDisk {
onError: (Throwable) -> Any?, onError: (Throwable) -> Any?,
) { ) {
val client = okHttpClient(url) val client = okHttpClient(url)
val request = val request =
Request Request
.Builder() .Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.get() .get()
.url(url) .url(url)
.build() .build()
client
.newCall(request)
.enqueue(
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
e.printStackTrace()
onError(e)
}
override fun onResponse(
call: Call,
response: Response,
) {
try { try {
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
check(response.isSuccessful) check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -145,15 +130,14 @@ object MediaSaverToDisk {
) )
} }
onSuccess() onSuccess()
}
}
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
e.printStackTrace() Log.e("MediaSaverToDisk", "Error parsing response", e)
onError(e) onError(e)
} }
} }
},
)
}
private fun getMimeTypeFromExtension(fileName: String): String = private fun getMimeTypeFromExtension(fileName: String): String =
fileName.substringAfterLast('.', "").lowercase().let { fileName.substringAfterLast('.', "").lowercase().let {
@@ -56,8 +56,8 @@ import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.service.CachedCashuProcessor import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser
import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
@@ -82,10 +82,10 @@ fun CashuPreview(
) { ) {
@Suppress("ProduceStateDoesNotAssignValue") @Suppress("ProduceStateDoesNotAssignValue")
val cashuData by produceState( val cashuData by produceState(
initialValue = CachedCashuProcessor.cached(cashutoken), initialValue = CachedCashuParser.cached(cashutoken),
key1 = cashutoken, key1 = cashutoken,
) { ) {
val newToken = withContext(Dispatchers.Default) { CachedCashuProcessor.parse(cashutoken) } val newToken = withContext(Dispatchers.Default) { CachedCashuParser.parse(cashutoken) }
if (value != newToken) { if (value != newToken) {
value = newToken value = newToken
} }
@@ -265,7 +265,7 @@ private fun DialogContent(
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted writeStoragePermissionState.status.isGranted
) { ) {
scope.launch { scope.launch(Dispatchers.IO) {
saveMediaToGallery(myContent, localContext, accountViewModel) saveMediaToGallery(myContent, localContext, accountViewModel)
} }
scope.launch { scope.launch {
@@ -295,7 +295,7 @@ private fun DialogContent(
} }
} }
private fun saveMediaToGallery( private suspend fun saveMediaToGallery(
content: BaseMediaContent, content: BaseMediaContent,
localContext: Context, localContext: Context,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -84,7 +84,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
class ZapOptionstViewModel : ViewModel() { class ZapOptionViewModel : ViewModel() {
private var account: Account? = null private var account: Account? = null
var customAmount by mutableStateOf(TextFieldValue("21")) var customAmount by mutableStateOf(TextFieldValue("21"))
@@ -111,7 +111,7 @@ fun ZapCustomDialog(
baseNote: Note, baseNote: Note,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val postViewModel: ZapOptionstViewModel = viewModel() val postViewModel: ZapOptionViewModel = viewModel()
LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) }
@@ -214,11 +214,7 @@ fun ZapCustomDialog(
TextSpinner( TextSpinner(
label = stringRes(id = R.string.zap_type), label = stringRes(id = R.string.zap_type),
placeholder = placeholder = zapTypes.first { it.first == accountViewModel.defaultZapType() }.second,
zapTypes
.filter { it.first == accountViewModel.defaultZapType() }
.first()
.second,
options = zapOptions, options = zapOptions,
onSelect = { selectedZapType = zapTypes[it].first }, onSelect = { selectedZapType = zapTypes[it].first },
modifier = Modifier.weight(1f).padding(end = 5.dp), modifier = Modifier.weight(1f).padding(end = 5.dp),
@@ -232,16 +228,17 @@ fun ZapCustomDialog(
OutlinedTextField( OutlinedTextField(
// stringRes(R.string.new_amount_in_sats // stringRes(R.string.new_amount_in_sats
label = { label = {
if ( when (selectedZapType) {
selectedZapType == LnZapEvent.ZapType.PUBLIC || LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> {
selectedZapType == LnZapEvent.ZapType.ANONYMOUS
) {
Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) Text(text = stringRes(id = R.string.custom_zaps_add_a_message))
} else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { }
LnZapEvent.ZapType.PRIVATE -> {
Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private))
} else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { }
LnZapEvent.ZapType.NONZAP -> {
Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap))
} }
}
}, },
value = postViewModel.customMessage, value = postViewModel.customMessage,
onValueChange = { postViewModel.customMessage = it }, onValueChange = { postViewModel.customMessage = it },
@@ -295,7 +292,7 @@ fun PayViaIntentDialog(
if (payingInvoices.size == 1) { if (payingInvoices.size == 1) {
val payable = payingInvoices.first() val payable = payingInvoices.first()
payViaIntent(payable.invoice, context, onClose) { payViaIntent(payable.invoice, context, onClose) {
onError(UserBasedErrorMessage(it, payable.user)) onError(UserBasedErrorMessage(it, payable.info.user))
} }
} else { } else {
Dialog( Dialog(
@@ -326,8 +323,8 @@ fun PayViaIntentDialog(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size10dp), modifier = Modifier.padding(vertical = Size10dp),
) { ) {
if (payable.user != null) { if (payable.info.user != null) {
BaseUserPicture(payable.user, Size55dp, accountViewModel = accountViewModel) BaseUserPicture(payable.info.user, Size55dp, accountViewModel = accountViewModel)
} else { } else {
DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel) DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel)
} }
@@ -335,8 +332,8 @@ fun PayViaIntentDialog(
Spacer(modifier = DoubleHorzSpacer) Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
if (payable.user != null) { if (payable.info.user != null) {
UsernameDisplay(payable.user, accountViewModel = accountViewModel) UsernameDisplay(payable.info.user, accountViewModel = accountViewModel)
} else { } else {
Text( Text(
text = stringRes(id = R.string.wallet_number, index + 1), text = stringRes(id = R.string.wallet_number, index + 1),
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Lightning import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder
@Composable @Composable
fun InvoiceRequestCard( fun InvoiceRequestCard(
lud16: String, lud16: String,
toUserPubKeyHex: String, user: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
titleText: String? = null, titleText: String? = null,
buttonText: String? = null, buttonText: String? = null,
@@ -81,7 +82,7 @@ fun InvoiceRequestCard(
) { ) {
InvoiceRequest( InvoiceRequest(
lud16, lud16,
toUserPubKeyHex, user,
accountViewModel, accountViewModel,
titleText, titleText,
buttonText, buttonText,
@@ -94,11 +95,11 @@ fun InvoiceRequestCard(
@Composable @Composable
fun InvoiceRequest( fun InvoiceRequest(
lud16: String, lud16: String,
toUserPubKeyHex: String, user: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
titleText: String? = null, titleText: String? = null,
buttonText: String? = null, buttonText: String? = null,
onSuccess: (String) -> Unit, onNewInvoice: (String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -176,11 +177,11 @@ fun InvoiceRequest(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = { onClick = {
accountViewModel.sendSats( accountViewModel.sendSats(
lnaddress = lud16, lnAddress = lud16,
user = user,
milliSats = amount * 1000, milliSats = amount * 1000,
message = message, message = message,
toUserPubKeyHex = toUserPubKeyHex, onNewInvoice = onNewInvoice,
onSuccess = onSuccess,
onError = onError, onError = onError,
onProgress = {}, onProgress = {},
context = context, context = context,
@@ -30,15 +30,21 @@ fun NewPostInvoiceRequest(
onSuccess: (String) -> Unit, onSuccess: (String) -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
accountViewModel.account.userProfile().info?.lnAddress()?.let { lud16 -> val lnAddress =
accountViewModel.account
.userProfile()
.info
?.lnAddress()
if (lnAddress != null) {
InvoiceRequest( InvoiceRequest(
lud16, lud16 = lnAddress,
accountViewModel.account.userProfile().pubkeyHex, user = accountViewModel.account.userProfile(),
accountViewModel, accountViewModel = accountViewModel,
stringRes(id = R.string.lightning_invoice), titleText = stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice), buttonText = stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = onSuccess, onNewInvoice = onSuccess,
onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, onError = accountViewModel.toastManager::toast,
) )
} }
} }
@@ -321,11 +321,11 @@ private fun GenericCommentPostBody(
postViewModel.lnAddress()?.let { lud16 -> postViewModel.lnAddress()?.let { lud16 ->
InvoiceRequest( InvoiceRequest(
lud16, lud16,
accountViewModel.account.userProfile().pubkeyHex, accountViewModel.account.userProfile(),
accountViewModel, accountViewModel,
stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice), stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = { onNewInvoice = {
postViewModel.insertAtCursor(it) postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false postViewModel.wantsInvoice = false
}, },
@@ -233,7 +233,7 @@ class AccountStateViewModel : ViewModel() {
} else if (EMAIL_PATTERN.matcher(key).matches()) { } else if (EMAIL_PATTERN.matcher(key).matches()) {
Nip05NostrAddressVerifier().verifyNip05( Nip05NostrAddressVerifier().verifyNip05(
key, key,
okttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) },
onSuccess = { publicKey -> onSuccess = { publicKey ->
loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError) loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
}, },
@@ -56,16 +56,17 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.WarningType
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.FeedState
@@ -125,7 +126,8 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.collectSuccessfulOperations import com.vitorpamplona.quartz.utils.mapNotNullAsync
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@@ -148,6 +150,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import kotlin.coroutines.resume
@Stable @Stable
class AccountViewModel( class AccountViewModel(
@@ -480,24 +483,23 @@ class AccountViewModel(
) )
}.toMutableMap() }.toMutableMap()
collectSuccessfulOperations<CombinedZap, DecryptedInfo>( val results =
items = zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, mapNotNullAsync<CombinedZap, DecryptedInfo>(
runRequestFor = { next, onReady -> zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true },
checkNotInMainThread() ) { next ->
val info = innerDecryptAmountMessage(next.request, next.response)
innerDecryptAmountMessage(next.request, next.response) { if (info != null) {
onReady(DecryptedInfo(next.request, next.response, it)) DecryptedInfo(next.request, next.response, info)
} else {
null
}
} }
},
) {
checkNotInMainThread()
it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info }
onNewState(initialResults.values.toImmutableList()) onNewState(initialResults.values.toImmutableList())
} }
} }
}
fun cachedDecryptAmountMessageInGroup(zapNotes: List<CombinedZap>): ImmutableList<ZapAmountCommentNotification> = fun cachedDecryptAmountMessageInGroup(zapNotes: List<CombinedZap>): ImmutableList<ZapAmountCommentNotification> =
zapNotes zapNotes
@@ -586,20 +588,21 @@ class AccountViewModel(
) )
}.toMutableMap() }.toMutableMap()
collectSuccessfulOperations<Pair<Note, Note?>, DecryptedInfo>( val decryptedInfo =
items = myList, mapNotNullAsync(myList) { next ->
runRequestFor = { next, onReady -> val info = innerDecryptAmountMessage(next.first, next.second)
innerDecryptAmountMessage(next.first, next.second) { if (info != null) {
onReady(DecryptedInfo(next.first, next.second, it)) DecryptedInfo(next.first, next.second, info)
} else {
null
} }
}, }
) {
it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info }
onNewState(initialResults.values.toImmutableList()) onNewState(initialResults.values.toImmutableList())
} }
} }
}
fun decryptAmountMessage( fun decryptAmountMessage(
zapRequest: Note, zapRequest: Note,
@@ -607,44 +610,39 @@ class AccountViewModel(
onNewState: (ZapAmountCommentNotification?) -> Unit, onNewState: (ZapAmountCommentNotification?) -> Unit,
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) onNewState(innerDecryptAmountMessage(zapRequest, zapEvent))
} }
} }
private fun innerDecryptAmountMessage( private suspend fun innerDecryptAmountMessage(
zapRequest: Note, zapRequest: Note,
zapEvent: Note?, zapEvent: Note?,
onReady: (ZapAmountCommentNotification) -> Unit, ): ZapAmountCommentNotification? =
) {
checkNotInMainThread()
(zapRequest.event as? LnZapRequestEvent)?.let { (zapRequest.event as? LnZapRequestEvent)?.let {
val amount = showAmountInteger((zapEvent?.event as? LnZapEvent)?.amount)
if (it.isPrivateZap()) { if (it.isPrivateZap()) {
decryptZap(zapRequest) { decryptedContent -> val decryptedContent = account.decryptZapOrNull(it)
val amount = (zapEvent?.event as? LnZapEvent)?.amount if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
onReady(
ZapAmountCommentNotification( ZapAmountCommentNotification(
newAuthor, LocalCache.checkGetOrCreateUser(decryptedContent.pubKey),
decryptedContent.content.ifBlank { null }, decryptedContent.content.ifBlank { null },
showAmountInteger(amount), amount,
), )
} else {
ZapAmountCommentNotification(
zapRequest.author,
null,
amount,
) )
} }
} else { } else {
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (!zapRequest.event?.content.isNullOrBlank() || amount != null) {
onReady(
ZapAmountCommentNotification( ZapAmountCommentNotification(
zapRequest.author, zapRequest.author,
zapRequest.event?.content?.ifBlank { null }, zapRequest.event?.content?.ifBlank { null },
showAmountInteger(amount), amount,
),
) )
} }
} }
}
}
fun zap( fun zap(
note: Note, note: Note,
@@ -795,13 +793,6 @@ class AccountViewModel(
viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) }
} }
fun decryptZap(
note: Note,
onReady: (Event) -> Unit,
) {
account.decryptZapContentAuthor(note, onReady)
}
fun follow(channel: PublicChatChannel) { fun follow(channel: PublicChatChannel) {
viewModelScope.launch(Dispatchers.IO) { account.follow(channel) } viewModelScope.launch(Dispatchers.IO) { account.follow(channel) }
} }
@@ -980,7 +971,7 @@ class AccountViewModel(
Nip05NostrAddressVerifier() Nip05NostrAddressVerifier()
.verifyNip05( .verifyNip05(
nip05, nip05,
okttpClient = { okHttpClient = {
app.okHttpClients.getHttpClient(account.shouldUseTorForNIP05(it)) app.okHttpClients.getHttpClient(account.shouldUseTorForNIP05(it))
}, },
onSuccess = { onSuccess = {
@@ -1377,15 +1368,26 @@ class AccountViewModel(
val lud16 = account.userProfile().info?.lud16 val lud16 = account.userProfile().info?.lud16
if (lud16 != null) { if (lud16 != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
CashuProcessor() try {
.melt( val meltResult = MeltProcessor().melt(token, lud16, ::okHttpClientForMoney, context)
token, onDone(
lud16, stringRes(context, R.string.cashu_successful_redemption),
okHttpClient = ::okHttpClientForMoney, stringRes(
onSuccess = { title, message -> onDone(title, message) },
onError = { title, message -> onDone(title, message) },
context, context,
R.string.cashu_successful_redemption_explainer,
token.totalAmount.toString(),
meltResult.fees.toString(),
),
) )
} catch (e: LightningAddressResolver.LightningAddressError) {
onDone(e.title, e.msg)
} catch (e: Exception) {
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
onDone(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
} }
} else { } else {
onDone( onDone(
@@ -1606,48 +1608,75 @@ class AccountViewModel(
} }
fun sendSats( fun sendSats(
lnaddress: String, lnAddress: String,
user: User,
milliSats: Long, milliSats: Long,
message: String, message: String,
toUserPubKeyHex: HexKey, onNewInvoice: (String) -> Unit,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
context: Context, context: Context,
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
if (defaultZapType() == LnZapEvent.ZapType.NONZAP) { try {
LightningAddressResolver() val zapRequest = prepareZapRequestIfNeeded(user, message, defaultZapType())
.lnAddressInvoice(
lnaddress, val invoice =
milliSats, LightningAddressResolver().lnAddressInvoice(
message, lnAddress = lnAddress,
null, milliSats = milliSats,
message = message,
nostrRequest = zapRequest,
okHttpClient = ::okHttpClientForMoney, okHttpClient = ::okHttpClientForMoney,
onSuccess = onSuccess,
onError = onError,
onProgress = onProgress, onProgress = onProgress,
context = context, context = context,
) )
onNewInvoice(invoice)
} catch (e: LightningAddressResolver.LightningAddressError) {
onError(e.title, e.msg)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Error", e.message ?: "Unknown error")
}
}
}
private suspend fun prepareZapRequestIfNeeded(
user: User,
message: String,
zapType: LnZapEvent.ZapType,
): LnZapRequestEvent? =
if (zapType != LnZapEvent.ZapType.NONZAP) {
tryAndWait { continuation ->
account.createZapRequestFor(user, message, zapType) { zapRequest ->
continuation.resume(zapRequest)
}
}
} else { } else {
account.createZapRequestFor(toUserPubKeyHex, message, defaultZapType()) { zapRequest -> null
LocalCache.justConsumeMyOwnEvent(zapRequest) }
LightningAddressResolver()
.lnAddressInvoice( fun saveMediaToGallery(
lnaddress, videoUri: String?,
milliSats, mimeType: String?,
message, localContext: Context,
zapRequest.toJson(), ) {
okHttpClient = ::okHttpClientForMoney, viewModelScope.launch {
onSuccess = onSuccess, MediaSaverToDisk.saveDownloadingIfNeeded(
onError = onError, videoUri = videoUri,
onProgress = onProgress, okHttpClient = ::okHttpClientForVideo,
context = context, mimeType = mimeType,
localContext = localContext,
onSuccess = {
toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
},
onError = {
toastManager.toast(R.string.failed_to_save_the_video, null, it)
},
) )
} }
} }
}
}
suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account) suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account)
@@ -43,8 +43,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
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.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -294,11 +292,11 @@ private fun NewProductBody(
postViewModel.lnAddress()?.let { lud16 -> postViewModel.lnAddress()?.let { lud16 ->
InvoiceRequest( InvoiceRequest(
lud16, lud16,
accountViewModel.account.userProfile().pubkeyHex, accountViewModel.account.userProfile(),
accountViewModel, accountViewModel,
stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice), stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = { onNewInvoice = {
postViewModel.insertAtCursor(it) postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false postViewModel.wantsInvoice = false
}, },
@@ -364,11 +364,11 @@ private fun NewPostScreenBody(
postViewModel.lnAddress()?.let { lud16 -> postViewModel.lnAddress()?.let { lud16 ->
InvoiceRequest( InvoiceRequest(
lud16, lud16,
accountViewModel.account.userProfile().pubkeyHex, accountViewModel.account.userProfile(),
accountViewModel, accountViewModel,
stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice), stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = { onNewInvoice = {
postViewModel.insertAtCursor(it) postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false postViewModel.wantsInvoice = false
}, },
@@ -26,13 +26,13 @@ import androidx.compose.runtime.Composable
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
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -51,12 +51,11 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
@Composable @Composable
fun DisplayLNAddress( fun DisplayLNAddress(
lud16: String?, lud16: String?,
userHex: String, user: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
var zapExpanded by remember { mutableStateOf(false) } var zapExpanded by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) } var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
@@ -67,7 +66,7 @@ fun DisplayLNAddress(
textContent = showErrorMessageDialog ?: "", textContent = showErrorMessageDialog ?: "",
onClickStartMessage = { onClickStartMessage = {
nav.nav { nav.nav {
routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel) routeToMessage(user, showErrorMessageDialog, accountViewModel = accountViewModel)
} }
}, },
onDismiss = { showErrorMessageDialog = null }, onDismiss = { showErrorMessageDialog = null },
@@ -105,7 +104,7 @@ fun DisplayLNAddress(
) { ) {
InvoiceRequestCard( InvoiceRequestCard(
lud16, lud16,
userHex, user,
accountViewModel, accountViewModel,
onSuccess = { onSuccess = {
zapExpanded = false zapExpanded = false
@@ -186,9 +186,19 @@ fun DrawAdditionalInfo(
} }
} }
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } val lud16 =
val pubkeyHex = remember { baseUser.pubkeyHex } remember(userState) {
DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) userState
?.user
?.info
?.lud16
?.trim() ?: userState
?.user
?.info
?.lud06
?.trim()
}
DisplayLNAddress(lud16, baseUser, accountViewModel, nav)
val identities = user.latestMetadata?.identityClaims() val identities = user.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) { if (!identities.isNullOrEmpty()) {
+2 -1
View File
@@ -36,7 +36,7 @@ media3 = "1.7.1"
mockk = "1.14.4" mockk = "1.14.4"
kotlinx-coroutines-test = "1.10.2" kotlinx-coroutines-test = "1.10.2"
navigationCompose = "2.9.1" navigationCompose = "2.9.1"
okhttp = "5.0.0" okhttp = "5.1.0"
runner = "1.6.2" runner = "1.6.2"
rfc3986 = "0.1.2" rfc3986 = "0.1.2"
secp256k1KmpJniAndroid = "0.18.0" secp256k1KmpJniAndroid = "0.18.0"
@@ -118,6 +118,7 @@ markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", n
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
@@ -100,7 +100,7 @@ class LnZapRequestEvent(
fun decryptPrivateZap( fun decryptPrivateZap(
signer: NostrSigner, signer: NostrSigner,
onReady: (Event) -> Unit, onReady: (LnZapPrivateEvent) -> Unit,
) { ) {
privateZapEvent?.let { privateZapEvent?.let {
onReady(it) onReady(it)
@@ -20,7 +20,9 @@
*/ */
package com.vitorpamplona.quartz.utils package com.vitorpamplona.quartz.utils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -48,6 +50,12 @@ suspend fun <T> launchAndWaitAll(
withTimeoutOrNull(15000) { withTimeoutOrNull(15000) {
jobs.joinAll() jobs.joinAll()
} }
async {
jobs.forEach {
it.cancel("Timeout")
}
}
} }
} }
@@ -69,15 +77,14 @@ suspend inline fun <T> tryAndWait(
* runs the request for that item, * runs the request for that item,
* and gathers all the results in the output map. * and gathers all the results in the output map.
*/ */
suspend fun <T, K> collectSuccessfulOperations( suspend fun <T, K> collectSuccessfulOperationsReturning(
items: List<T>, items: List<T>,
runRequestFor: (T, (K) -> Unit) -> Unit, runRequestFor: (T, (K) -> Unit) -> Unit,
output: MutableList<K> = mutableListOf(), ): List<K> {
onReady: suspend (List<K>) -> Unit, val output: MutableList<K> = mutableListOf()
) {
if (items.isEmpty()) { if (items.isEmpty()) {
onReady(output) return output
return
} }
launchAndWaitAll(items) { launchAndWaitAll(items) {
@@ -91,5 +98,58 @@ suspend fun <T, K> collectSuccessfulOperations(
} }
} }
onReady(output) return output
}
/**
* Executes multiple suspending functions concurrently using `async` and attempts to wait for all of them
* to complete within a default 15-second timeout.
*
* If the timeout is reached, it returns the results of all tasks that successfully completed by then
* and cancels any tasks that are still running. Tasks that completed with an exception are not
* included in the returned list.
*
* @param tasks A list of suspending functions, each returning a value of type [T].
* @return A list containing the results of tasks that successfully completed within the timeout.
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun <T, K> mapNotNullAsync(
items: List<T>,
timeoutMillis: Long = 30000,
runRequestFor: suspend (T) -> K?,
): List<K> {
if (items.isEmpty()) {
return emptyList()
}
return coroutineScope {
// Launch all tasks asynchronously and get their Deferred handles.
val jobs =
items.map { item ->
async {
runRequestFor(item)
}
}
// Use withTimeout to impose a 15-second limit on waiting for all deferreds.
// If all tasks complete within 15 seconds, awaitAll() will return their results,
// and this block will return those results.
withTimeoutOrNull(timeoutMillis) {
jobs.joinAll()
}
async {
jobs.forEach {
it.cancel("Timeout")
}
}
jobs.mapNotNull {
if (it.isCompleted) {
it.getCompleted()
} else {
null
}
}
}
} }