From 267d4c505a8e16e8d0920ea28dcfdef60bce61d1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 9 Jul 2025 15:14:58 -0400 Subject: [PATCH] Moves OkHttp to the coroutines version and turns all their usage into suspend functions --- amethyst/build.gradle | 1 + .../com/vitorpamplona/amethyst/CashuBTest.kt | 10 +- .../amethyst/DMFileDecryptionTest.kt | 1 - .../com/vitorpamplona/amethyst/DebugUtils.kt | 5 +- .../vitorpamplona/amethyst/model/Account.kt | 36 +- .../amethyst/model/LocalCache.kt | 5 + .../amethyst/service/CashuProcessor.kt | 426 ------------------ .../service/Nip05NostrAddressVerifier.kt | 36 +- .../service/Nip11RelayInfoRetriever.kt | 65 +-- .../amethyst/service/OnlineCheck.kt | 23 +- .../amethyst/service/ZapPaymentHandler.kt | 377 ++++++++-------- .../service/cashu/CachedCashuParser.kt | 40 ++ .../amethyst/service/cashu/CashuParser.kt | 43 ++ .../amethyst/service/cashu/CashuToken.kt | 41 ++ .../service/cashu/melt/MeltProcessor.kt | 230 ++++++++++ .../amethyst/service/cashu/melt/MeltResult.kt | 29 ++ .../amethyst/service/cashu/v3/V3Parser.kt | 63 +++ .../amethyst/service/cashu/v3/V3Token.kt | 37 ++ .../amethyst/service/cashu/v4/V4Models.kt | 68 +++ .../amethyst/service/cashu/v4/V4Parser.kt | 74 +++ .../service/lnurl/LightningAddressResolver.kt | 373 ++++++++------- .../service/notifications/RegisterAccounts.kt | 96 ++-- .../service/ots/OkHttpBitcoinExplorer.kt | 3 - .../amethyst/service/ots/OkHttpCalendar.kt | 3 - .../service/ots/OkHttpCalendarAsyncSubmit.kt | 2 - .../controls/RenderControlButtons.kt | 25 +- .../amethyst/service/previews/UrlPreview.kt | 39 +- .../uploads/blossom/BlossomUploader.kt | 56 +-- .../service/uploads/nip96/Nip96Uploader.kt | 105 ++--- .../uploads/nip96/ServerInfoRetriever.kt | 27 +- .../amethyst/ui/actions/EditPostView.kt | 7 +- .../amethyst/ui/actions/MediaSaverToDisk.kt | 96 ++-- .../amethyst/ui/components/CashuRedeem.kt | 8 +- .../ui/components/ZoomableContentDialog.kt | 4 +- .../amethyst/ui/note/ZapCustomDialog.kt | 39 +- .../note/creators/invoice/InvoiceRequest.kt | 15 +- .../creators/invoice/NewPostInvoiceRequest.kt | 22 +- .../nip22Comments/GenericCommentPostScreen.kt | 4 +- .../ui/screen/AccountStateViewModel.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 219 +++++---- .../nip99Classifieds/NewProductScreen.kt | 6 +- .../loggedIn/home/ShortNotePostScreen.kt | 4 +- .../profile/header/DisplayLNAddress.kt | 9 +- .../profile/header/DrawAdditionalInfo.kt | 16 +- gradle/libs.versions.toml | 3 +- .../quartz/nip57Zaps/LnZapRequestEvent.kt | 2 +- .../quartz/utils/ParallelUtils.kt | 74 ++- 47 files changed, 1552 insertions(+), 1317 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 38147f0ca..99f896e12 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -222,6 +222,7 @@ dependencies { // Websockets API implementation libs.okhttp + implementation libs.okhttpCoroutines // Encrypted Key Storage implementation libs.androidx.security.crypto.ktx diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt index ff697d6c8..200c896d7 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.service.CashuProcessor -import com.vitorpamplona.amethyst.service.CashuToken +import com.vitorpamplona.amethyst.service.cashu.CashuParser +import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.ui.components.GenericLoadable import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking @@ -38,7 +38,7 @@ class CashuBTest { @Test() fun parseCashuA() { runBlocking { - val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded>).loaded[0] + val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded>).loaded[0] assertEquals(cashuTokenA, parsed.token) assertEquals("https://8333.space:3338", parsed.mint) @@ -59,7 +59,7 @@ class CashuBTest { @Test() fun parseCashuB() = runBlocking { - val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded>).loaded + val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded>).loaded assertEquals(cashuTokenB1, parsed[0].token) assertEquals("http://localhost:3338", parsed[0].mint) @@ -84,7 +84,7 @@ class CashuBTest { @Test() fun parseCashuB2() = runBlocking { - val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded>).loaded + val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded>).loaded assertEquals(cashuTokenB2, parsed[0].token) assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt index d00972ce2..7e3e34a6e 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt @@ -58,7 +58,6 @@ class DMFileDecryptionTest { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index ca70015c8..6fd11fd16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import kotlin.time.DurationUnit import kotlin.time.measureTimedValue +@Suppress("SENSELESS_COMPARISON") val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark" fun debugState(context: Context) { @@ -142,10 +143,10 @@ fun debugState(context: Context) { .sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L }) 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) -> - 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 ") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 698198ed9..0e388f09c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -167,6 +167,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits @@ -625,19 +626,23 @@ class Account( } fun createZapRequestFor( - userPubKeyHex: String, + user: User, message: String = "", zapType: LnZapEvent.ZapType, onReady: (LnZapRequestEvent) -> Unit, ) { + val relays = nip65RelayList.inboxFlow.value + user.inboxRelays() + LnZapRequestEvent.create( - userPubKeyHex, - nip65RelayList.inboxFlow.value.toSet(), - signer, - message, - zapType, - onReady = onReady, - ) + userHex = user.pubkeyHex, + relays = relays, + signer = signer, + message = message, + zapType = zapType, + ) { zapRequest -> + LocalCache.justConsumeMyOwnEvent(zapRequest) + onReady(zapRequest) + } } 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): Boolean = users.all { isHidden(it) } fun isHidden(user: User) = isHidden(user.pubkeyHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 550c86fc8..596f66923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -628,6 +628,7 @@ object LocalCache : ILocalCache { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + @Suppress("DEPRECATION") fun consume( event: TorrentCommentEvent, relay: NormalizedRelayUrl?, @@ -682,6 +683,7 @@ object LocalCache : ILocalCache { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + @Suppress("DEPRECATION") fun consume( event: GitReplyEvent, relay: NormalizedRelayUrl?, @@ -778,6 +780,7 @@ object LocalCache : ILocalCache { return false } + @Suppress("DEPRECATION") fun computeReplyTo(event: Event): List = when (event) { is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } @@ -1318,6 +1321,7 @@ object LocalCache : ILocalCache { return false } + @Suppress("DEPRECATION") private fun deleteNote(deleteNote: Note) { val deletedEvent = deleteNote.event @@ -3057,6 +3061,7 @@ object LocalCache : ILocalCache { } } + @Suppress("DEPRECATION") private fun justConsumeInnerInner( event: Event, relay: NormalizedRelayUrl?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt deleted file mode 100644 index 08d8fdf5e..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ /dev/null @@ -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, -) - -@Serializable -@Immutable -class Proof( - val amount: Int, - val id: String, - val secret: String, - val C: String, -) - -object CachedCashuProcessor { - val cashuCache = LruCache>>(20) - - fun cached(token: String): GenericLoadable> = cashuCache[token] ?: GenericLoadable.Loading() - - fun parse(token: String): GenericLoadable> { - 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?, - ) - - @Serializable - class V3T( - val mint: String, - val proofs: List, - ) - - fun parse(cashuToken: String): GenericLoadable> { - 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> { - checkNotInMainThread() - - try { - val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readValue(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?, - ) - - @Serializable - class V4T( - // identifier - @ByteString - val i: ByteArray, - val p: Array, - ) - - @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> { - checkNotInMainThread() - - try { - val base64token = cashuToken.replace("cashuB", "") - - val parser = Cbor { ignoreUnknownKeys = true } - - val v4Token = parser.decodeFromByteArray(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), - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index c3b0497a8..35fb7b90d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -20,18 +20,18 @@ */ package com.vitorpamplona.amethyst.service -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05 import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class Nip05NostrAddressVerifier { suspend fun fetchNip05Json( nip05: String, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) = withContext(Dispatchers.IO) { @@ -45,30 +45,22 @@ class Nip05NostrAddressVerifier { } try { - val request = - 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() + val request = Request.Builder().url(url).build() - if (it.isSuccessful) { - onSuccess(it.body.string()) + // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint. + 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 { 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) { if (e is CancellationException) throw e onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}") @@ -77,7 +69,7 @@ class Nip05NostrAddressVerifier { suspend fun verifyNip05( nip05: String, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) { @@ -86,7 +78,7 @@ class Nip05NostrAddressVerifier { fetchNip05Json( nip05, - okttpClient, + okHttpClient, onSuccess = { checkNotInMainThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index 5a23ad813..3c3364093 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -22,18 +22,18 @@ package com.vitorpamplona.amethyst.service import android.util.Log 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.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException -import okhttp3.Call -import okhttp3.Callback +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response -import java.io.IOException +import okhttp3.coroutines.executeAsync object Nip11CachedRetriever { sealed class RetrieveResult( @@ -160,7 +160,6 @@ class Nip11Retriever { onInfo: (Nip11RelayInformation) -> Unit, onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit, ) { - checkNotInMainThread() val url = relay.toHttp() try { val request: Request = @@ -170,44 +169,28 @@ class Nip11Retriever { .url(url) .build() - okHttpClient(url) - .newCall(request) - .enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - onInfo(Nip11RelayInformation.fromJson(body)) - } else { - onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e( - "RelayInfoFail", - "Resulting Message from Relay ${relay.url} in not parseable: $body", - e, - ) - onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) - } - } - } + val client = okHttpClient(url) - override fun onFailure( - call: Call, - e: IOException, - ) { - Log.e("RelayInfoFail", "${relay.url} unavailable", e) - onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message) + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + val body = response.body.string() + try { + if (response.isSuccessful) { + onInfo(Nip11RelayInformation.fromJson(body)) + } else { + onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString()) } - }, - ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e( + "RelayInfoFail", + "Resulting Message from Relay ${relay.url} in not parseable: $body", + e, + ) + onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) + } + } + } } catch (e: Exception) { if (e is CancellationException) throw e Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 5136c0c2f..04f21d2fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.utils.RandomInstance import okhttp3.EventListener import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request +import okhttp3.coroutines.executeAsync import okio.ByteString.Companion.toByteString import kotlin.coroutines.cancellation.CancellationException @@ -49,12 +49,10 @@ object OnlineChecker { return false } - fun isOnline( + suspend fun isOnline( url: String?, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, ): Boolean { - checkNotInMainThread() - if (url.isNullOrBlank()) return false if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { return checkOnlineCache.get(url).online @@ -66,7 +64,6 @@ object OnlineChecker { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url.replace("wss+livekit://", "wss://")) .header("Upgrade", "websocket") .header("Connection", "Upgrade") @@ -76,29 +73,23 @@ object OnlineChecker { .build() val client = - okttpClient(url) + okHttpClient(url) .newBuilder() .eventListener(EventListener.NONE) .protocols(listOf(Protocol.HTTP_1_1)) .build() - client.newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful - } + client.newCall(request).executeAsync().use { it.isSuccessful } } else { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() + val client = okHttpClient(url) - okttpClient(url).newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful - } + client.newCall(request).executeAsync().use { it.isSuccessful } } checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 505fb75f9..c33486135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -29,23 +29,25 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver 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.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent 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.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent 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.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import kotlin.coroutines.resume import kotlin.math.round class ZapPaymentHandler( @@ -53,12 +55,25 @@ class ZapPaymentHandler( ) { @Immutable data class Payable( - val info: BaseZapSplitSetup, - val user: User?, + val info: MyZapSplitSetup, val amountMilliSats: Long, 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( note: Note, amountMilliSats: Long, @@ -75,87 +90,118 @@ class ZapPaymentHandler( val noteEvent = note.event val zapSplitSetup = noteEvent?.zapSplitSetup() - val zapsToSend = + val unverifiedZapsToSend = 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()) { - 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) { val appLud16 = noteEvent.appMetaData()?.lnAddress() if (appLud16 != null) { - listOf(ZapSplitSetupLnAddress(appLud16, weight = 1.0)) + listOf(UnverifiedZapSplitSetup(appLud16)) } else { val lud16 = note.author?.info?.lnAddress() - - 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)) + listOf(UnverifiedZapSplitSetup(lud16)) } } else { - val lud16 = note.author?.info?.lnAddress() + listOf(UnverifiedZapSplitSetup(note.author?.info?.lnAddress())) + } - 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, + if (showErrorIfNoLnAddress) { + val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() } + errors.forEach { + val message = + if (it.user != null) { + stringRes( + context, + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + it.user.toBestDisplayName(), ) + } else { + stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats) } - return@withContext - } - 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) - signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs -> - if (splitZapRequestPairs.isEmpty()) { - onProgress(0.00f) - return@signAllZapRequests - } else { - onProgress(0.05f) - } - assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, okHttpClient, onError, onProgress = { - onProgress(it * 0.7f + 0.05f) // keeps within range. - }, context) { payables -> - if (payables.isEmpty()) { - onProgress(0.00f) - return@assembleAllInvoices - } else { - onProgress(0.75f) - } + val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend) - if (account.hasWalletConnectSetup()) { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) { - // onProgress(1f) - } - } else { - onPayViaIntent( - payables.toImmutableList(), - ) + if (splitZapRequests.isEmpty()) { + onProgress(0.00f) + return@withContext + } else { + onProgress(0.05f) + } - onProgress(0f) - } - } + val payables = + assembleAllInvoices( + requests = splitZapRequests, + totalAmountMilliSats = amountMilliSats, + message = message, + okHttpClient = okHttpClient, + onError = onError, + onProgress = { onProgress(it * 0.7f + 0.05f) }, + context = context, + ) + + if (payables.isEmpty()) { + onProgress(0.00f) + return@withContext + } else { + onProgress(0.75f) + } + + if (account.hasWalletConnectSetup()) { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + // onProgress(1f) + } else { + onPayViaIntent(payables.toImmutableList()) + onProgress(0f) } } @@ -170,88 +216,76 @@ class ZapPaymentHandler( } class ZapRequestReady( - val inputSetup: BaseZapSplitSetup, - val zapRequestJson: String?, - val user: User? = null, + val inputSetup: MyZapSplitSetup, + val zapRequest: LnZapRequestEvent?, ) - fun receivingRelaySet(userHex: HexKey): Set? = - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(userHex), - )?.event as? AdvertisedRelayListEvent - )?.readRelaysNorm() - ?.toSet() - suspend fun signAllZapRequests( note: Note, pollOption: Int?, message: String, zapType: LnZapEvent.ZapType, - zapsToSend: List, - onAllDone: suspend (List) -> Unit, - ) { - collectSuccessfulOperations( - items = zapsToSend, - 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() + zapsToSend: List, + ): List = + mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> + // makes sure the author receives the zap event + val authorRelayList = note.author?.inboxRelays()?.toSet() ?: 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 -> - onReady(ZapRequestReady(next, zapRequestJson, user)) - } - } - }, - onReady = onAllDone, - ) - } + val zapRequest = prepareZapRequestIfNeeded(note, pollOption, message, zapType, next.user, userRelayList + authorRelayList) + + ZapRequestReady(next, zapRequest) + } suspend fun assembleAllInvoices( requests: List, totalAmountMilliSats: Long, message: String, - showErrorIfNoLnAddress: Boolean, okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (List) -> Unit, - ) { + ): List { var progressAllPayments = 0.00f val totalWeight = requests.sumOf { it.inputSetup.weight } - collectSuccessfulOperations( - items = requests, - runRequestFor = { splitZapRequestPair: ZapRequestReady, onReady -> + return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady -> + try { assembleInvoice( + lud16 = splitZapRequestPair.inputSetup.lnAddress, splitSetup = splitZapRequestPair.inputSetup, - nostrZapRequest = splitZapRequestPair.zapRequestJson, - toUser = splitZapRequestPair.user, + nostrZapRequest = splitZapRequestPair.zapRequest, zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight), message = message, - showErrorIfNoLnAddress = showErrorIfNoLnAddress, okHttpClient = okHttpClient, - onError = onError, onProgressStep = { percentStepForThisPayment -> progressAllPayments += percentStepForThisPayment / requests.size onProgress(progressAllPayments) }, context = context, - onReady = onReady, ) - }, - onReady = onAllDone, - ) + } catch (e: LightningAddressResolver.LightningAddressError) { + 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( @@ -265,11 +299,10 @@ class ZapPaymentHandler( onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (List) -> Unit, - ) { + ): List { var progressAllPayments = 0.00f - collectSuccessfulOperations( + return collectSuccessfulOperationsReturning( items = payables, runRequestFor = { payable: Payable, onReady -> account.sendZapPaymentRequestFor( @@ -292,7 +325,7 @@ class ZapPaymentHandler( response.error?.message ?: response.error?.code?.toString() ?: "Error parsing error message", ), - payable.user, + payable.info.user, ) } else { progressAllPayments += 0.5f / payables.size @@ -301,94 +334,60 @@ class ZapPaymentHandler( }, ) }, - onReady = onAllDone, ) } - private fun assembleInvoice( - splitSetup: BaseZapSplitSetup, - nostrZapRequest: String?, - toUser: User?, + private suspend fun assembleInvoice( + lud16: String, + splitSetup: MyZapSplitSetup, + nostrZapRequest: LnZapRequestEvent?, zapValue: Long, message: String, - showErrorIfNoLnAddress: Boolean = true, okHttpClient: (String) -> OkHttpClient, - onError: (String, String, User?) -> Unit, onProgressStep: (percent: Float) -> Unit, context: Context, - onReady: (Payable) -> Unit, - ) { + ): Payable { var progressThisPayment = 0.00f - val lud16 = - if (splitSetup is ZapSplitSetupLnAddress) { - splitSetup.lnAddress - } else { - toUser?.info?.lnAddress() - } + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + milliSats = zapValue, + message = message, + nostrRequest = nostrZapRequest, + okHttpClient = okHttpClient, + onProgress = { + val step = it - progressThisPayment + progressThisPayment = it + onProgressStep(step) + }, + context = context, + ) - if (lud16 != null) { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - milliSats = zapValue, - message = message, - nostrRequest = nostrZapRequest, - okHttpClient = okHttpClient, - onError = { title, msg -> - onError(title, msg, toUser) - }, - onProgress = { - val step = it - progressThisPayment - progressThisPayment = it - onProgressStep(step) - }, - context = context, - onSuccess = { - onProgressStep(1 - progressThisPayment) - onReady( - Payable( - info = splitSetup, - user = toUser, - amountMilliSats = zapValue, - invoice = it, - ), - ) - }, - ) - } 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, - ) - } - } + onProgressStep(1 - progressThisPayment) + + return Payable( + info = splitSetup, + amountMilliSats = zapValue, + invoice = invoice, + ) } - private fun prepareZapRequestIfNeeded( + private suspend fun prepareZapRequestIfNeeded( note: Note, pollOption: Int?, message: String, zapType: LnZapEvent.ZapType, overrideUser: User? = null, additionalRelays: Set? = null, - onReady: (String?) -> Unit, - ) { + ): LnZapRequestEvent? = if (zapType != LnZapEvent.ZapType.NONZAP) { - account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest -> - onReady(zapRequest.toJson()) + tryAndWait { continuation -> + account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest -> + continuation.resume(zapRequest) + } } } else { - onReady(null) + null } - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt new file mode 100644 index 000000000..c5a48dcc7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt @@ -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>>(20) + + fun cached(token: String): GenericLoadable> = cashuCache[token] ?: GenericLoadable.Loading() + + fun parse(token: String): GenericLoadable> { + if (cashuCache[token] !is GenericLoadable.Loaded) { + val newCachuData = CashuParser().parse(token) + cashuCache.put(token, newCachuData) + } + + return cashuCache[token] + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt new file mode 100644 index 000000000..49101b4b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt @@ -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> { + 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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt new file mode 100644 index 000000000..6c0eeb6d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt @@ -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, +) + +@Serializable +@Immutable +class Proof( + val amount: Int, + val id: String, + val secret: String, + val C: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt new file mode 100644 index 000000000..c08ec6308 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt new file mode 100644 index 000000000..11ad4711a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt @@ -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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt new file mode 100644 index 000000000..9650f2358 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt @@ -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> { + try { + val base64token = cashuToken.replace("cashuA", "") + val cashu = jacksonObjectMapper().readValue(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") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt new file mode 100644 index 000000000..be244b2ed --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt @@ -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?, +) + +@Serializable +class V3T( + val mint: String, + val proofs: List, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt new file mode 100644 index 000000000..071b4c7d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt @@ -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?, +) + +@Serializable +class V4T( + // identifier + @ByteString + val i: ByteArray, + val p: Array, +) + +@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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt new file mode 100644 index 000000000..ddd22fb5e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt @@ -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> { + try { + val base64token = cashuToken.replace("cashuB", "") + val parser = Cbor { ignoreUnknownKeys = true } + val v4Token = parser.decodeFromByteArray(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") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index c21c79134..42cc045fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -23,142 +23,137 @@ package com.vitorpamplona.amethyst.service.lnurl import android.content.Context import android.util.Log import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil 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.Request import okhttp3.Response +import okhttp3.coroutines.executeAsync import java.math.BigDecimal import java.math.RoundingMode import java.net.URLEncoder import kotlin.coroutines.cancellation.CancellationException class LightningAddressResolver { - fun assembleUrl(lnaddress: String): String? { - val parts = lnaddress.split("@") + fun assembleUrl(lnAddress: String): String? { + val parts = lnAddress.split("@") if (parts.size == 2) { return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" } - if (lnaddress.lowercase().startsWith("lnurl")) { - return Lud06().toLnUrlp(lnaddress) + if (lnAddress.lowercase().startsWith("lnurl")) { + return Lud06().toLnUrlp(lnAddress) } return null } - private fun fetchLightningAddressJson( - lnaddress: String, - okttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() + class LightningAddressError( + val title: String, + val msg: String, + ) : Exception(msg) - val url = assembleUrl(lnaddress) + private suspend fun fetchLightningAddressJson( + lnAddress: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ): String { + val url = assembleUrl(lnAddress) if (url == null) { - onError( + throw LightningAddressError( stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, - lnaddress, - ), + stringRes(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 = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .build() - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - 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, - url, - lnaddress, - errorMessage(it, context), - ), - ) + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.string() + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + 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, + url, + lnAddress, + errorMessage(response, context), + ), + ) + } } } } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() - onError( + throw LightningAddressError( stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes( context, R.string .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception, url, - lnaddress, + lnAddress, e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message, ), ) } } - fun fetchLightningInvoice( + suspend fun fetchLightningInvoice( lnCallback: String, milliSats: Long, message: String, - nostrRequest: String? = null, - okttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, + nostrRequest: LnZapRequestEvent? = null, + okHttpClient: (String) -> OkHttpClient, context: Context, - ) { - checkNotInMainThread() - + ): String { val encodedMessage = URLEncoder.encode(message, "utf-8") val urlBinder = if (lnCallback.contains("?")) "&" else "?" var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" if (nostrRequest != null) { - val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") + val encodedNostrRequest = URLEncoder.encode(nostrRequest.toJson(), "utf-8") url += "&nostr=$encodedNostrRequest" } - val client = okttpClient(url) + val client = okHttpClient(url) val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .build() - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(it, context)), - ) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.string() + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)), + ) + } } } } @@ -178,7 +173,7 @@ class LightningAddressResolver { val statusNode = tree.get("status") if (tree.get("error").isBoolean && messageNode != null) { - if (errorNode.asBoolean() == true) { + if (errorNode.asBoolean()) { return messageNode.asText() } } @@ -203,146 +198,136 @@ class LightningAddressResolver { ?: response.code.toString() } - fun lnAddressInvoice( - lnaddress: String, + suspend fun lnAddressInvoice( + lnAddress: String, milliSats: Long, message: String, - nostrRequest: String? = null, + nostrRequest: LnZapRequestEvent? = null, okHttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - ) { + ): String { val mapper = jacksonObjectMapper() - fetchLightningAddressJson( - lnaddress, - okHttpClient, - onSuccess = { lnAddressJson -> - onProgress(0.4f) + val lnAddressJson = + fetchLightningAddressJson( + lnAddress, + okHttpClient, + context, + ) - val lnurlp = - try { - mapper.readTree(lnAddressJson) - } catch (t: Throwable) { - if (t is CancellationException) throw t - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user, - lnaddress, - ), - ) - null - } + onProgress(0.4f) - val callback = lnurlp?.get("callback")?.asText()?.ifBlank { null } - - if (callback == null) { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user, - lnaddress, - ), - ) - } - - val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false - - callback?.let { cb -> - fetchLightningInvoice( - cb, - milliSats, - message, - if (allowsNostr) nostrRequest else null, - okHttpClient, - onSuccess = { - onProgress(0.6f) - - val lnInvoice = - try { - mapper.readTree(it) - } catch (t: Throwable) { - if (t is CancellationException) throw t - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user, - lnaddress, - ), - ) - null - } - - lnInvoice - ?.get("pr") - ?.asText() - ?.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) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - 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 - .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user, - lnaddress, - reason, - ), - ) - } - ?: run { - onProgress(0.0f) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user, - lnaddress, - ), - ) - } - }, - onError = onError, + val lnurlp = + try { + mapper.readTree(lnAddressJson) + } catch (t: Throwable) { + if (t is CancellationException) throw t + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( context, - ) - } - }, - onError = onError, - context, - ) + R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user, + lnAddress, + ), + ) + } + + val callbackUrl = lnurlp?.get("callback")?.asText()?.ifBlank { null } + + if (callbackUrl == null) { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user, + lnAddress, + ), + ) + } + + val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false + + val invoice = + fetchLightningInvoice( + lnCallback = callbackUrl, + milliSats = milliSats, + message = message, + nostrRequest = if (allowsNostr) nostrRequest else null, + okHttpClient = okHttpClient, + context = context, + ) + + onProgress(0.6f) + + val lnInvoice = + try { + mapper.readTree(invoice) + } catch (t: Throwable) { + if (t is CancellationException) throw t + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user, + lnAddress, + ), + ) + } + + val pr = lnInvoice?.get("pr")?.asText()?.ifBlank { null } + + if (pr == null) { + onProgress(0.0f) + val reason = lnInvoice?.get("reason")?.asText()?.ifBlank { null } + + if (reason != null) { + throw LightningAddressError( + 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_with_user, + lnAddress, + reason, + ), + ) + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user, + lnAddress, + ), + ) + } + } + + // 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, + R.string.incorrect_invoice_amount_sats_from_it_should_have_been, + invoiceAmount.toLong().toString(), + lnAddress, + expectedAmountInSats.toString(), + ), + ) + } + + onProgress(0.7f) + + return pr } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 9a43fb398..14001c6a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -25,11 +25,12 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -37,12 +38,14 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync import kotlin.coroutines.resume class RegisterAccounts( private val accounts: List, private val client: (String) -> OkHttpClient, ) { + @Suppress("SENSELESS_COMPARISON") val tag = if (BuildConfig.FLAVOR == "play") { "RegisterAccounts FirebaseMsgService" @@ -52,67 +55,64 @@ class RegisterAccounts( private suspend fun signAllAuths( notificationToken: String, - remainingTos: List>>, - output: MutableList, - onReady: (List) -> Unit, - ) { + remainingTos: List, + ): List { if (remainingTos.isEmpty()) { - onReady(output) - return + return emptyList() } - launchAndWaitAll(remainingTos) { accountRelayPair -> - val result = - tryAndWait { continuation -> - val signer = accountRelayPair.first.createSigner() - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } - - RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result -> - continuation.resume(result) - } + return mapNotNullAsync(remainingTos) { info -> + tryAndWait { continuation -> + val signer = info.accountSettings.createSigner() + // TODO: Modify the external launcher to launch as different users. + // Right now it only registers if Amber has already approved this signature + if (signer is NostrSignerExternal) { + signer.launcher.registerLauncher( + launcher = { }, + contentResolver = Amethyst.instance::contentResolverFn, + ) } - if (result != null) { - output.add(result) + RelayAuthEvent.create(info.relays, notificationToken, signer) { result -> + continuation.resume(result) + } } } - - onReady(output) } + class Registration( + val accountSettings: AccountSettings, + val relays: List, + ) + // creates proof that it controls all accounts private suspend fun signEventsToProveControlOfAccounts( accounts: List, notificationToken: String, - onReady: (List) -> Unit, - ) { + ): List { val readyToSend = accounts - .mapNotNull { - if (it.hasPrivKey || it.loggedInWithExternalSigner) { - Log.d(tag, "Register Account ${it.npub}") + .mapNotNull { account -> + if (account.hasPrivKey || account.loggedInWithExternalSigner) { + Log.d(tag, "Register Account ${account.npub}") - val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) + val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(account.npub) if (acc != null && acc.isWriteable()) { 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() - 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) if (relays.isNotEmpty()) { - Pair(acc, relays) + Registration(acc, relays) } else { null } @@ -124,16 +124,10 @@ class RegisterAccounts( } } - val listOfAuthEvents = mutableListOf() - signAllAuths( - notificationToken, - readyToSend, - listOfAuthEvents, - onReady, - ) + return signAllAuths(notificationToken, readyToSend) } - fun postRegistrationEvent(events: List) { + suspend fun postRegistrationEvent(events: List) { val jsonObject = """{ "events": [ ${events.joinToString(", ") { it.toJson() }} ] @@ -147,19 +141,21 @@ class RegisterAccounts( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .post(body) .build() - val isSucess = client(url).newCall(request).execute().use { it.isSuccessful } - Log.i(tag, "Server registration $isSucess") + val client = client(url) + + client.newCall(request).executeAsync().use { response -> + Log.i(tag, "Server registration ${response.isSuccessful}") + } } suspend fun go(notificationToken: String) { if (notificationToken.isNotEmpty()) { withContext(Dispatchers.IO) { - signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } + postRegistrationEvent(signEventsToProveControlOfAccounts(accounts, notificationToken)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt index fec1f9e15..7ec04b0e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.ots import android.util.Log 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.BlockHeader import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException @@ -51,7 +50,6 @@ class OkHttpBitcoinExplorer( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/json") .url(url) .get() @@ -95,7 +93,6 @@ class OkHttpBitcoinExplorer( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt index caf0f73b8..b8af7b7ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.ots -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp @@ -60,7 +59,6 @@ class OkHttpCalendar( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) @@ -112,7 +110,6 @@ class OkHttpCalendar( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt index 0feae9e87..c412fc0c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.ots -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp @@ -45,7 +44,6 @@ class OkHttpCalendarAsyncSubmit( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt index 2749a52e1..e2f95fadb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt @@ -20,19 +20,16 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls -import android.content.Context import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier 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.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming 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.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -70,7 +67,7 @@ fun RenderControlButtons( if (!isLiveStreaming(mediaData.videoUri)) { 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 -> @@ -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) - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 4508340ff..3d25f3886 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.service.previews -import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class UrlPreview { suspend fun fetch( @@ -52,24 +52,29 @@ class UrlPreview { .url(url) .get() .build() - okHttpClient(url).newCall(request).execute().use { - checkNotInMainThread() - if (it.isSuccessful) { - val mimeType = - it.headers["Content-Type"]?.toMediaType() - ?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}") - if (mimeType.type == "text" && mimeType.subtype == "html") { - val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType)) - UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) - } else if (mimeType.type == "image") { - UrlInfoItem(url, image = url, mimeType = mimeType.toString()) - } else if (mimeType.type == "video") { - UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + + val client = okHttpClient(url) + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val mimeType = + response.headers["Content-Type"]?.toMediaType() + ?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}") + if (mimeType.type == "text" && mimeType.subtype == "html") { + val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType) + val data = OpenGraphParser().extractUrlInfo(metaTags) + UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) + } else if (mimeType.type == "image") { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } else if (mimeType.type == "video") { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } else { + throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") + } } else { - throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") + throw IllegalArgumentException("Website returned: " + response.code) } - } else { - throw IllegalArgumentException("Website returned: " + it.code) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 6e9ab4cec..7979093cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -27,7 +27,6 @@ import android.provider.OpenableColumns import android.webkit.MimeTypeMap import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages 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.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.coroutines.executeAsync import okio.BufferedSink import okio.source import java.io.File @@ -157,29 +159,28 @@ class BlossomUploader { requestBuilder .addHeader("Content-Length", length.toString()) - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(apiUrl) .put(requestBody) val request = requestBuilder.build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val str = body.string() - val result = parseResults(str) - return result - } - } else { - val errorMessage = response.headers.get("X-Reason") - - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) - } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.use { body -> + parseResults(body.string()) + } } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) + val errorMessage = response.headers.get("X-Reason") + + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (errorMessage != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) + } else if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) + } } } } @@ -208,20 +209,21 @@ class BlossomUploader { val request = requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(apiUrl.removeSuffix("/") + "/$hash.$extension") .delete() .build() - okHttpClient(apiUrl).newCall(request).execute().use { response -> - if (response.isSuccessful) { - return true - } else { - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + return okHttpClient(apiUrl).newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + true } else { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index a6dca1bda..b6acdfde8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -27,7 +27,6 @@ import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.core.net.toFile import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages 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.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.coroutines.executeAsync import okio.BufferedSink import okio.source import java.io.InputStream @@ -168,53 +170,54 @@ class Nip96Uploader { httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", it.toAuthToken()) } requestBuilder - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(server.apiUrl) .post(requestBody) val request = requestBuilder.build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val result = UploadResult.parse(body.string()) - if (!result.processingUrl.isNullOrBlank()) { - return waitProcessing(result, server, okHttpClient, onProgress) - } else if (result.status == "success") { - val event = result.nip94Event - if (event != null) { - return convertToMediaResult(event) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.use { body -> + val result = UploadResult.parse(body.string()) + if (!result.processingUrl.isNullOrBlank()) { + waitProcessing(result, server, okHttpClient, onProgress) + } else if (result.status == "success") { + val event = result.nip94Event + if (event != null) { + convertToMediaResult(event) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) + } } else { throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } - } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } - } - } else { - val msg = response.body.string() + } else { + val msg = response.body.string() - val errorMessage = - try { - val tree = jacksonObjectMapper().readTree(msg) - val status = tree.get("status")?.asText() - val message = tree.get("message")?.asText() - if (status == "error" && message != null) { - message - } else { + val errorMessage = + try { + val tree = jacksonObjectMapper().readTree(msg) + val status = tree.get("status")?.asText() + val message = tree.get("message")?.asText() + if (status == "error" && message != null) { + message + } else { + null + } + } catch (e: Exception) { null } - } catch (e: Exception) { - null - } - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) - } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) - } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (errorMessage != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) + } else if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) + } } } } @@ -275,23 +278,22 @@ class Nip96Uploader { val request = requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") .delete() .build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val result = DeleteResult.parse(body.string()) - return result.status == "success" - } - } else { - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val result = DeleteResult.parse(response.body.string()) + result.status == "success" } else { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } @@ -313,14 +315,15 @@ class Nip96Uploader { val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(procUrl) .build() val client = okHttpClient(procUrl) - client.newCall(request).execute().use { - if (it.isSuccessful) { - it.body.use { currentResult = UploadResult.parse(it.string()) } + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + currentResult = UploadResult.parse(response.body.string()) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index f3abddafc..e473a9424 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.amethyst.service.uploads.nip96 import android.util.Log -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class ServerInfoRetriever { val parser = ServerInfoParser() @@ -42,24 +44,25 @@ class ServerInfoRetriever { .url(parser.assembleUrl(baseUrl)) .build() - okHttpClient(baseUrl).newCall(request).execute().use { response -> - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - return parser.parse(baseUrl, body) + val client = okHttpClient(baseUrl) + + return try { + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val body = response.body.string() + parser.parse(baseUrl, body) } else { throw RuntimeException( "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", ) } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) - throw e } } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayInfoFail", "Resulting Message from $baseUrl", e) + throw e } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 6a7781ece..67a6a9f68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -54,11 +54,9 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically 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.creators.invoice.AddLnInvoiceButton 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.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -315,11 +312,11 @@ fun EditPostView( ) { InvoiceRequest( lud16, - user.pubkeyHex, + user, accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it) postViewModel.wantsInvoice = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index d3e3e3b8b..42773706c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -27,20 +27,21 @@ import android.media.MediaScannerConnection import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import androidx.core.net.toFile 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.quartz.nip01Core.relay.client.listeners.EmptyClientListener.onError import kotlinx.coroutines.CancellationException -import okhttp3.Call -import okhttp3.Callback +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response +import okhttp3.coroutines.executeAsync import okio.BufferedSource -import okio.IOException import okio.buffer import okio.sink import okio.source @@ -48,7 +49,7 @@ import java.io.File import java.util.UUID object MediaSaverToDisk { - fun saveDownloadingIfNeeded( + suspend fun saveDownloadingIfNeeded( videoUri: String?, okHttpClient: (String) -> OkHttpClient, mimeType: String?, @@ -83,7 +84,7 @@ object MediaSaverToDisk { * * @see PICTURES_SUBDIRECTORY */ - fun downloadAndSave( + suspend fun downloadAndSave( url: String, mimeType: String?, okHttpClient: (String) -> OkHttpClient, @@ -92,67 +93,50 @@ object MediaSaverToDisk { onError: (Throwable) -> Any?, ) { val client = okHttpClient(url) - val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .get() .url(url) .build() - client - .newCall(request) - .enqueue( - object : Callback { - override fun onFailure( - call: Call, - e: IOException, - ) { - e.printStackTrace() - onError(e) - } + try { + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + check(response.isSuccessful) - override fun onResponse( - call: Call, - response: Response, - ) { - try { - check(response.isSuccessful) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url)) + check(contentType.isNotBlank()) { "Can't find out the content type" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url)) - check(contentType.isNotBlank()) { "Can't find out the content type" } - - val realType = - if (contentType == "application/octet-stream") { - mimeType ?: getMimeTypeFromExtension(url) - } else { - contentType - } - - saveContentQ( - displayName = File(trimInlineMetaData(url)).nameWithoutExtension, - contentType = realType, - contentSource = response.body.source(), - contentResolver = context.contentResolver, - ) + val realType = + if (contentType == "application/octet-stream") { + mimeType ?: getMimeTypeFromExtension(url) } else { - saveContentDefault( - fileName = File(trimInlineMetaData(url)).name, - contentSource = response.body.source(), - context = context, - ) + contentType } - onSuccess() - } catch (e: Exception) { - if (e is CancellationException) throw e - e.printStackTrace() - onError(e) - } + + saveContentQ( + displayName = File(trimInlineMetaData(url)).nameWithoutExtension, + contentType = realType, + contentSource = response.body.source(), + contentResolver = context.contentResolver, + ) + } else { + saveContentDefault( + fileName = File(trimInlineMetaData(url)).name, + contentSource = response.body.source(), + context = context, + ) } - }, - ) + onSuccess() + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("MediaSaverToDisk", "Error parsing response", e) + onError(e) + } } private fun getMimeTypeFromExtension(fileName: String): String = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 183cafa84..55ccb3871 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -56,8 +56,8 @@ import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.service.CachedCashuProcessor -import com.vitorpamplona.amethyst.service.CashuToken +import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser +import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon @@ -82,10 +82,10 @@ fun CashuPreview( ) { @Suppress("ProduceStateDoesNotAssignValue") val cashuData by produceState( - initialValue = CachedCashuProcessor.cached(cashutoken), + initialValue = CachedCashuParser.cached(cashutoken), key1 = cashutoken, ) { - val newToken = withContext(Dispatchers.Default) { CachedCashuProcessor.parse(cashutoken) } + val newToken = withContext(Dispatchers.Default) { CachedCashuParser.parse(cashutoken) } if (value != newToken) { value = newToken } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 087ec6b50..bfc5b8a36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -265,7 +265,7 @@ private fun DialogContent( Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted ) { - scope.launch { + scope.launch(Dispatchers.IO) { saveMediaToGallery(myContent, localContext, accountViewModel) } scope.launch { @@ -295,7 +295,7 @@ private fun DialogContent( } } -private fun saveMediaToGallery( +private suspend fun saveMediaToGallery( content: BaseMediaContent, localContext: Context, accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index b6459f1c3..0ebb39184 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -84,7 +84,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException -class ZapOptionstViewModel : ViewModel() { +class ZapOptionViewModel : ViewModel() { private var account: Account? = null var customAmount by mutableStateOf(TextFieldValue("21")) @@ -111,7 +111,7 @@ fun ZapCustomDialog( baseNote: Note, ) { val context = LocalContext.current - val postViewModel: ZapOptionstViewModel = viewModel() + val postViewModel: ZapOptionViewModel = viewModel() LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } @@ -214,11 +214,7 @@ fun ZapCustomDialog( TextSpinner( label = stringRes(id = R.string.zap_type), - placeholder = - zapTypes - .filter { it.first == accountViewModel.defaultZapType() } - .first() - .second, + placeholder = zapTypes.first { it.first == accountViewModel.defaultZapType() }.second, options = zapOptions, onSelect = { selectedZapType = zapTypes[it].first }, modifier = Modifier.weight(1f).padding(end = 5.dp), @@ -232,15 +228,16 @@ fun ZapCustomDialog( OutlinedTextField( // stringRes(R.string.new_amount_in_sats label = { - if ( - selectedZapType == LnZapEvent.ZapType.PUBLIC || - selectedZapType == LnZapEvent.ZapType.ANONYMOUS - ) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) - } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) - } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) + when (selectedZapType) { + LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) + } + LnZapEvent.ZapType.PRIVATE -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) + } + LnZapEvent.ZapType.NONZAP -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) + } } }, value = postViewModel.customMessage, @@ -295,7 +292,7 @@ fun PayViaIntentDialog( if (payingInvoices.size == 1) { val payable = payingInvoices.first() payViaIntent(payable.invoice, context, onClose) { - onError(UserBasedErrorMessage(it, payable.user)) + onError(UserBasedErrorMessage(it, payable.info.user)) } } else { Dialog( @@ -326,8 +323,8 @@ fun PayViaIntentDialog( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp), ) { - if (payable.user != null) { - BaseUserPicture(payable.user, Size55dp, accountViewModel = accountViewModel) + if (payable.info.user != null) { + BaseUserPicture(payable.info.user, Size55dp, accountViewModel = accountViewModel) } else { DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel) } @@ -335,8 +332,8 @@ fun PayViaIntentDialog( Spacer(modifier = DoubleHorzSpacer) Column(modifier = Modifier.weight(1f)) { - if (payable.user != null) { - UsernameDisplay(payable.user, accountViewModel = accountViewModel) + if (payable.info.user != null) { + UsernameDisplay(payable.info.user, accountViewModel = accountViewModel) } else { Text( text = stringRes(id = R.string.wallet_number, index + 1), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt index 13080fc99..59baf599e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons 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.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder @Composable fun InvoiceRequestCard( lud16: String, - toUserPubKeyHex: String, + user: User, accountViewModel: AccountViewModel, titleText: String? = null, buttonText: String? = null, @@ -81,7 +82,7 @@ fun InvoiceRequestCard( ) { InvoiceRequest( lud16, - toUserPubKeyHex, + user, accountViewModel, titleText, buttonText, @@ -94,11 +95,11 @@ fun InvoiceRequestCard( @Composable fun InvoiceRequest( lud16: String, - toUserPubKeyHex: String, + user: User, accountViewModel: AccountViewModel, titleText: String? = null, buttonText: String? = null, - onSuccess: (String) -> Unit, + onNewInvoice: (String) -> Unit, onError: (String, String) -> Unit, ) { val context = LocalContext.current @@ -176,11 +177,11 @@ fun InvoiceRequest( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), onClick = { accountViewModel.sendSats( - lnaddress = lud16, + lnAddress = lud16, + user = user, milliSats = amount * 1000, message = message, - toUserPubKeyHex = toUserPubKeyHex, - onSuccess = onSuccess, + onNewInvoice = onNewInvoice, onError = onError, onProgress = {}, context = context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt index 5ee80d2f1..a2e04533e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt @@ -30,15 +30,21 @@ fun NewPostInvoiceRequest( onSuccess: (String) -> Unit, accountViewModel: AccountViewModel, ) { - accountViewModel.account.userProfile().info?.lnAddress()?.let { lud16 -> + val lnAddress = + accountViewModel.account + .userProfile() + .info + ?.lnAddress() + + if (lnAddress != null) { InvoiceRequest( - lud16, - accountViewModel.account.userProfile().pubkeyHex, - accountViewModel, - stringRes(id = R.string.lightning_invoice), - stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = onSuccess, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + lud16 = lnAddress, + user = accountViewModel.account.userProfile(), + accountViewModel = accountViewModel, + titleText = stringRes(id = R.string.lightning_invoice), + buttonText = stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = onSuccess, + onError = accountViewModel.toastManager::toast, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index f91fb778c..2990dcd04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -321,11 +321,11 @@ private fun GenericCommentPostBody( postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( lud16, - accountViewModel.account.userProfile().pubkeyHex, + accountViewModel.account.userProfile(), accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.insertAtCursor(it) postViewModel.wantsInvoice = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 101800e4d..1faadedf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -233,7 +233,7 @@ class AccountStateViewModel : ViewModel() { } else if (EMAIL_PATTERN.matcher(key).matches()) { Nip05NostrAddressVerifier().verifyNip05( key, - okttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, onSuccess = { publicKey -> loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9919b0f6f..d42ff041a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -56,16 +56,17 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState 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.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.OnlineChecker 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.lnurl.LightningAddressResolver 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.toasts.ToastManager 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.utils.Hex 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.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -148,6 +150,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import kotlin.coroutines.resume @Stable class AccountViewModel( @@ -480,22 +483,21 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulOperations( - items = zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, - runRequestFor = { next, onReady -> - checkNotInMainThread() - - innerDecryptAmountMessage(next.request, next.response) { - onReady(DecryptedInfo(next.request, next.response, it)) + val results = + mapNotNullAsync( + zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, + ) { next -> + val info = innerDecryptAmountMessage(next.request, next.response) + if (info != null) { + 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()) } } @@ -586,18 +588,19 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulOperations, DecryptedInfo>( - items = myList, - runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.first, next.second) { - onReady(DecryptedInfo(next.first, next.second, it)) + val decryptedInfo = + mapNotNullAsync(myList) { next -> + val info = innerDecryptAmountMessage(next.first, next.second) + if (info != null) { + DecryptedInfo(next.first, next.second, info) + } else { + null } - }, - ) { - it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + } - onNewState(initialResults.values.toImmutableList()) - } + decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + + onNewState(initialResults.values.toImmutableList()) } } @@ -607,44 +610,39 @@ class AccountViewModel( onNewState: (ZapAmountCommentNotification?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) + onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)) } } - private fun innerDecryptAmountMessage( + private suspend fun innerDecryptAmountMessage( zapRequest: Note, zapEvent: Note?, - onReady: (ZapAmountCommentNotification) -> Unit, - ) { - checkNotInMainThread() - + ): ZapAmountCommentNotification? = (zapRequest.event as? LnZapRequestEvent)?.let { + val amount = showAmountInteger((zapEvent?.event as? LnZapEvent)?.amount) if (it.isPrivateZap()) { - decryptZap(zapRequest) { decryptedContent -> - val amount = (zapEvent?.event as? LnZapEvent)?.amount - val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) - onReady( - ZapAmountCommentNotification( - newAuthor, - decryptedContent.content.ifBlank { null }, - showAmountInteger(amount), - ), + val decryptedContent = account.decryptZapOrNull(it) + if (decryptedContent != null) { + ZapAmountCommentNotification( + LocalCache.checkGetOrCreateUser(decryptedContent.pubKey), + decryptedContent.content.ifBlank { null }, + amount, + ) + } else { + ZapAmountCommentNotification( + zapRequest.author, + null, + amount, ) } } else { - val amount = (zapEvent?.event as? LnZapEvent)?.amount - if (!zapRequest.event?.content.isNullOrBlank() || amount != null) { - onReady( - ZapAmountCommentNotification( - zapRequest.author, - zapRequest.event?.content?.ifBlank { null }, - showAmountInteger(amount), - ), - ) - } + ZapAmountCommentNotification( + zapRequest.author, + zapRequest.event?.content?.ifBlank { null }, + amount, + ) } } - } fun zap( note: Note, @@ -795,13 +793,6 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } } - fun decryptZap( - note: Note, - onReady: (Event) -> Unit, - ) { - account.decryptZapContentAuthor(note, onReady) - } - fun follow(channel: PublicChatChannel) { viewModelScope.launch(Dispatchers.IO) { account.follow(channel) } } @@ -980,7 +971,7 @@ class AccountViewModel( Nip05NostrAddressVerifier() .verifyNip05( nip05, - okttpClient = { + okHttpClient = { app.okHttpClients.getHttpClient(account.shouldUseTorForNIP05(it)) }, onSuccess = { @@ -1377,15 +1368,26 @@ class AccountViewModel( val lud16 = account.userProfile().info?.lud16 if (lud16 != null) { viewModelScope.launch(Dispatchers.IO) { - CashuProcessor() - .melt( - token, - lud16, - okHttpClient = ::okHttpClientForMoney, - onSuccess = { title, message -> onDone(title, message) }, - onError = { title, message -> onDone(title, message) }, - context, + try { + val meltResult = MeltProcessor().melt(token, lud16, ::okHttpClientForMoney, context) + onDone( + stringRes(context, R.string.cashu_successful_redemption), + stringRes( + 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 { onDone( @@ -1606,46 +1608,73 @@ class AccountViewModel( } fun sendSats( - lnaddress: String, + lnAddress: String, + user: User, milliSats: Long, message: String, - toUserPubKeyHex: HexKey, - onSuccess: (String) -> Unit, + onNewInvoice: (String) -> Unit, onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, ) { viewModelScope.launch(Dispatchers.IO) { - if (defaultZapType() == LnZapEvent.ZapType.NONZAP) { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress, - milliSats, - message, - null, + try { + val zapRequest = prepareZapRequestIfNeeded(user, message, defaultZapType()) + + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lnAddress, + milliSats = milliSats, + message = message, + nostrRequest = zapRequest, okHttpClient = ::okHttpClientForMoney, - onSuccess = onSuccess, - onError = onError, onProgress = onProgress, context = context, ) - } else { - account.createZapRequestFor(toUserPubKeyHex, message, defaultZapType()) { zapRequest -> - LocalCache.justConsumeMyOwnEvent(zapRequest) - LightningAddressResolver() - .lnAddressInvoice( - lnaddress, - milliSats, - message, - zapRequest.toJson(), - okHttpClient = ::okHttpClientForMoney, - onSuccess = onSuccess, - onError = onError, - onProgress = onProgress, - 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 { + null + } + + fun saveMediaToGallery( + videoUri: String?, + mimeType: String?, + localContext: Context, + ) { + viewModelScope.launch { + MediaSaverToDisk.saveDownloadingIfNeeded( + videoUri = videoUri, + okHttpClient = ::okHttpClientForVideo, + 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) + }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 94cfbce29..fe301d25c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -43,8 +43,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.Modifier import androidx.compose.ui.platform.LocalContext @@ -294,11 +292,11 @@ private fun NewProductBody( postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( lud16, - accountViewModel.account.userProfile().pubkeyHex, + accountViewModel.account.userProfile(), accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.insertAtCursor(it) postViewModel.wantsInvoice = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index c3203f6dd..2ebc00210 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -364,11 +364,11 @@ private fun NewPostScreenBody( postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( lud16, - accountViewModel.account.userProfile().pubkeyHex, + accountViewModel.account.userProfile(), accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.insertAtCursor(it) postViewModel.wantsInvoice = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index e7731decf..07a893015 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -26,13 +26,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.navigation.INav @@ -51,12 +51,11 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse @Composable fun DisplayLNAddress( lud16: String?, - userHex: String, + user: User, accountViewModel: AccountViewModel, nav: INav, ) { val context = LocalContext.current - val scope = rememberCoroutineScope() var zapExpanded by remember { mutableStateOf(false) } var showErrorMessageDialog by remember { mutableStateOf(null) } @@ -67,7 +66,7 @@ fun DisplayLNAddress( textContent = showErrorMessageDialog ?: "", onClickStartMessage = { nav.nav { - routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel) + routeToMessage(user, showErrorMessageDialog, accountViewModel = accountViewModel) } }, onDismiss = { showErrorMessageDialog = null }, @@ -105,7 +104,7 @@ fun DisplayLNAddress( ) { InvoiceRequestCard( lud16, - userHex, + user, accountViewModel, onSuccess = { zapExpanded = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 9b5d3f74b..b1b3b81d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -186,9 +186,19 @@ fun DrawAdditionalInfo( } } - val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } - val pubkeyHex = remember { baseUser.pubkeyHex } - DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) + val lud16 = + remember(userState) { + userState + ?.user + ?.info + ?.lud16 + ?.trim() ?: userState + ?.user + ?.info + ?.lud06 + ?.trim() + } + DisplayLNAddress(lud16, baseUser, accountViewModel, nav) val identities = user.latestMetadata?.identityClaims() if (!identities.isNullOrEmpty()) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05bf20797..c227c8ff1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ media3 = "1.7.1" mockk = "1.14.4" kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.9.1" -okhttp = "5.0.0" +okhttp = "5.1.0" runner = "1.6.2" rfc3986 = "0.1.2" 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" } 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" } +okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } 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-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index 04a7d450d..851869d76 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -100,7 +100,7 @@ class LnZapRequestEvent( fun decryptPrivateZap( signer: NostrSigner, - onReady: (Event) -> Unit, + onReady: (LnZapPrivateEvent) -> Unit, ) { privateZapEvent?.let { onReady(it) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt index bf46ca8af..1d4158487 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.utils +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.suspendCancellableCoroutine @@ -48,6 +50,12 @@ suspend fun launchAndWaitAll( withTimeoutOrNull(15000) { jobs.joinAll() } + + async { + jobs.forEach { + it.cancel("Timeout") + } + } } } @@ -69,15 +77,14 @@ suspend inline fun tryAndWait( * runs the request for that item, * and gathers all the results in the output map. */ -suspend fun collectSuccessfulOperations( +suspend fun collectSuccessfulOperationsReturning( items: List, runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableList = mutableListOf(), - onReady: suspend (List) -> Unit, -) { +): List { + val output: MutableList = mutableListOf() + if (items.isEmpty()) { - onReady(output) - return + return output } launchAndWaitAll(items) { @@ -91,5 +98,58 @@ suspend fun 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 mapNotNullAsync( + items: List, + timeoutMillis: Long = 30000, + runRequestFor: suspend (T) -> K?, +): List { + 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 + } + } + } }