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

This commit is contained in:
Vitor Pamplona
2025-07-09 15:14:58 -04:00
parent b502ab7de8
commit 267d4c505a
47 changed files with 1552 additions and 1317 deletions
+1
View File
@@ -222,6 +222,7 @@ dependencies {
// Websockets API
implementation libs.okhttp
implementation libs.okhttpCoroutines
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx
@@ -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<List<CashuToken>>).loaded[0]
val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).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<List<CashuToken>>).loaded
val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).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<List<CashuToken>>).loaded
val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
@@ -58,7 +58,6 @@ class DMFileDecryptionTest {
val request =
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
@@ -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 ")
}
}
@@ -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<HexKey>): Boolean = users.all { isHidden(it) }
fun isHidden(user: User) = isHidden(user.pubkeyHex)
@@ -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<Note> =
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?,
@@ -1,426 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: List<Proof>,
)
@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)
object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
checkNotInMainThread()
val newCachuData = CashuProcessor().parse(token)
cashuCache.put(token, newCachuData)
}
return cashuCache[token]
}
}
class CashuProcessor {
@Serializable
class V3Token(
val unit: String?,
val memo: String?,
val token: List<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
if (cashuToken.startsWith("cashuA")) {
return parseCashuA(cashuToken)
}
if (cashuToken.startsWith("cashuB")) {
return parseCashuB(cashuToken)
}
return GenericLoadable.Error("Could not parse this cashu token")
}
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
val converted =
cashu.token.map { token ->
val proofs = token.proofs
val mint = token.mint
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
@Serializable
class V4Token(
// mint
val m: String,
// unit
val u: String,
// memo
val d: String? = null,
val t: Array<V4T>?,
)
@Serializable
class V4T(
// identifier
@ByteString
val i: ByteArray,
val p: Array<V4Proof>,
)
@Serializable
class V4Proof(
// amount
val a: Int,
// secret
val s: String,
// signature
@ByteString
val c: ByteArray,
// no idea what this is
val d: V4DleqProof? = null,
// witness
val w: String? = null,
)
@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)
@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
val converted =
v4proofs.map { id ->
val proofs =
id.p.map {
Proof(
it.a,
id.i.toHexKey(),
it.s,
it.c.toHexKey(),
)
}
val mint = v4Token.m
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
runCatching {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
okHttpClient = okHttpClient,
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
okHttpClient = okHttpClient,
onSuccess = { fees ->
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
okHttpClient = okHttpClient,
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, okHttpClient, onSuccess, onError, context)
},
onProgress = {},
onError = onError,
context = context,
)
},
onError = onError,
context,
)
},
onProgress = {},
onError = onError,
context = context,
)
}
}
fun feeCalculator(
mintAddress: String,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
try {
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost,
)
} else {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
private fun meltInvoice(
token: CashuToken,
invoice: String,
fees: Int,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
try {
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(
context,
R.string.cashu_successful_redemption_explainer,
token.totalAmount.toString(),
fees.toString(),
),
)
} else {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
}
@@ -20,18 +20,18 @@
*/
package com.vitorpamplona.amethyst.service
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()
@@ -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,22 +169,16 @@ 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()
val client = okHttpClient(url)
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
try {
if (it.isSuccessful) {
if (response.isSuccessful) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -198,16 +191,6 @@ class Nip11Retriever {
}
}
}
override fun onFailure(
call: Call,
e: IOException,
) {
Log.e("RelayInfoFail", "${relay.url} unavailable", e)
onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
},
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.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))
@@ -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,69 +90,106 @@ 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),
val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() }
errors.forEach {
val message =
if (it.user != null) {
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
note.author,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
it.user.toBestDisplayName(),
)
}
return@withContext
} else {
stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
}
listOf(ZapSplitSetupLnAddress(lud16, weight = 1.0))
onError(
stringRes(context, R.string.missing_lud16),
message,
it.user,
)
}
}
val zapsToSend =
unverifiedZapsToSend.mapNotNull {
if (it.lnAddress != null) {
MyZapSplitSetup(
it.lnAddress,
it.weight,
it.relay,
it.user,
)
} else {
null
}
}
onProgress(0.02f)
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
if (splitZapRequestPairs.isEmpty()) {
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend)
if (splitZapRequests.isEmpty()) {
onProgress(0.00f)
return@signAllZapRequests
return@withContext
} else {
onProgress(0.05f)
}
assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, okHttpClient, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) { payables ->
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@assembleAllInvoices
return@withContext
} else {
onProgress(0.75f)
}
@@ -145,19 +197,13 @@ class ZapPaymentHandler(
if (account.hasWalletConnectSetup()) {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context) {
}, context)
// onProgress(1f)
}
} else {
onPayViaIntent(
payables.toImmutableList(),
)
onPayViaIntent(payables.toImmutableList())
onProgress(0f)
}
}
}
}
private fun calculateZapValue(
amountMilliSats: Long,
@@ -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<NormalizedRelayUrl>? =
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(userHex),
)?.event as? AdvertisedRelayListEvent
)?.readRelaysNorm()
?.toSet()
suspend fun signAllZapRequests(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
zapsToSend: List<BaseZapSplitSetup>,
onAllDone: suspend (List<ZapRequestReady>) -> Unit,
) {
collectSuccessfulOperations<BaseZapSplitSetup, ZapRequestReady>(
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<MyZapSplitSetup>,
): List<ZapRequestReady> =
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<ZapRequestReady>,
totalAmountMilliSats: Long,
message: String,
showErrorIfNoLnAddress: Boolean,
okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (List<Payable>) -> Unit,
) {
): List<Payable> {
var progressAllPayments = 0.00f
val totalWeight = requests.sumOf { it.inputSetup.weight }
collectSuccessfulOperations<ZapRequestReady, Payable>(
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<Paid>) -> Unit,
) {
): List<Paid> {
var progressAllPayments = 0.00f
collectSuccessfulOperations<Payable, Paid>(
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()
}
if (lud16 != null) {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
val invoice =
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(
return Payable(
info = splitSetup,
user = toUser,
amountMilliSats = zapValue,
invoice = it,
),
invoice = invoice,
)
},
)
} else {
if (showErrorIfNoLnAddress) {
onError(
stringRes(
context,
R.string.missing_lud16,
),
stringRes(
context,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
toUser?.toBestDisplayName() ?: splitSetup.mainId(),
),
null,
)
}
}
}
private fun prepareZapRequestIfNeeded(
private suspend fun prepareZapRequestIfNeeded(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
additionalRelays: Set<NormalizedRelayUrl>? = null,
onReady: (String?) -> Unit,
) {
): LnZapRequestEvent? =
if (zapType != LnZapEvent.ZapType.NONZAP) {
tryAndWait { continuation ->
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest ->
onReady(zapRequest.toJson())
continuation.resume(zapRequest)
}
}
} else {
onReady(null)
}
null
}
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu
import android.util.LruCache
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
object CachedCashuParser {
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
val newCachuData = CashuParser().parse(token)
cashuCache.put(token, newCachuData)
}
return cashuCache[token]
}
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu
import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser
import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
class CashuParser {
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
if (cashuToken.startsWith("cashuA")) {
return V3Parser.parseCashuA(cashuToken)
}
if (cashuToken.startsWith("cashuB")) {
return V4Parser.parseCashuB(cashuToken)
}
return GenericLoadable.Error("Could not parse this cashu token")
}
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: List<Proof>,
)
@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)
@@ -0,0 +1,230 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.melt
import android.content.Context
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import kotlin.coroutines.cancellation.CancellationException
class MeltProcessor {
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
): MeltResult {
val baseInvoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
val fees =
feeCalculator(
mintAddress = token.mint,
invoice = baseInvoice,
okHttpClient = okHttpClient,
context = context,
)
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
meltInvoice(token, invoice, okHttpClient, context)
return MeltResult(
token = token,
invoice = invoice,
fees = fees,
)
}
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
}
suspend fun feeCalculator(
mintAddress: String,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
): Int =
try {
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost == null) {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
feeCost
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
private suspend fun meltInvoice(
token: CashuToken,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
) {
try {
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (!successful) {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
}
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.melt
import com.vitorpamplona.amethyst.service.cashu.CashuToken
class MeltResult(
val token: CashuToken,
val invoice: String,
val fees: Int,
)
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v3
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
class V3Parser {
companion object Companion {
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
val converted =
cashu.token.map { token ->
val proofs = token.proofs
val mint = token.mint
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
}
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v3
import com.vitorpamplona.amethyst.service.cashu.Proof
import kotlinx.serialization.Serializable
@Serializable
class V3Token(
val unit: String?,
val memo: String?,
val token: List<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v4
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@Serializable
class V4Token(
// mint
val m: String,
// unit
val u: String,
// memo
val d: String? = null,
val t: Array<V4T>?,
)
@Serializable
class V4T(
// identifier
@ByteString
val i: ByteArray,
val p: Array<V4Proof>,
)
@Serializable
class V4Proof(
// amount
val a: Int,
// secret
val s: String,
// signature
@ByteString
val c: ByteArray,
// no idea what this is
val d: V4DleqProof? = null,
// witness
val w: String? = null,
)
@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu.v4
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.Proof
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
class V4Parser {
companion object {
@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
val converted =
v4proofs.map { id ->
val proofs =
id.p.map {
Proof(
it.a,
id.i.toHexKey(),
it.s,
it.c.toHexKey(),
)
}
val mint = v4Token.m
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
}
}
@@ -23,145 +23,140 @@ package com.vitorpamplona.amethyst.service.lnurl
import android.content.Context
import android.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())
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
response.body.string()
} else {
onError(
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(it, context),
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())
return client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
response.body.string()
} else {
onError(
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(it, context)),
stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
)
}
}
}
}
fun errorMessage(
response: Response,
@@ -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,23 +198,24 @@ 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()
val lnAddressJson =
fetchLightningAddressJson(
lnaddress,
lnAddress,
okHttpClient,
onSuccess = { lnAddressJson ->
context,
)
onProgress(0.4f)
val lnurlp =
@@ -227,122 +223,111 @@ class LightningAddressResolver {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
throw LightningAddressError(
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,
lnAddress,
),
)
null
}
val callback = lnurlp?.get("callback")?.asText()?.ifBlank { null }
val callbackUrl = lnurlp?.get("callback")?.asText()?.ifBlank { null }
if (callback == null) {
onError(
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,
lnAddress,
),
)
}
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false
callback?.let { cb ->
val invoice =
fetchLightningInvoice(
cb,
milliSats,
message,
if (allowsNostr) nostrRequest else null,
okHttpClient,
onSuccess = {
lnCallback = callbackUrl,
milliSats = milliSats,
message = message,
nostrRequest = if (allowsNostr) nostrRequest else null,
okHttpClient = okHttpClient,
context = context,
)
onProgress(0.6f)
val lnInvoice =
try {
mapper.readTree(it)
mapper.readTree(invoice)
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
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,
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 {
val pr = lnInvoice?.get("pr")?.asText()?.ifBlank { null }
if (pr == null) {
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(
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,
lnAddress,
reason,
),
)
}
?: run {
onProgress(0.0f)
onError(
} 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,
lnAddress,
),
)
}
},
onError = onError,
}
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats =
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.toLong() != expectedAmountInSats) {
onProgress(0.0f)
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnAddress,
expectedAmountInSats.toString(),
),
)
}
},
onError = onError,
context,
)
onProgress(0.7f)
return pr
}
}
@@ -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<AccountInfo>,
private val client: (String) -> OkHttpClient,
) {
@Suppress("SENSELESS_COMPARISON")
val tag =
if (BuildConfig.FLAVOR == "play") {
"RegisterAccounts FirebaseMsgService"
@@ -52,19 +55,15 @@ class RegisterAccounts(
private suspend fun signAllAuths(
notificationToken: String,
remainingTos: List<Pair<AccountSettings, List<NormalizedRelayUrl>>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
remainingTos: List<Registration>,
): List<RelayAuthEvent> {
if (remainingTos.isEmpty()) {
onReady(output)
return
return emptyList()
}
launchAndWaitAll(remainingTos) { accountRelayPair ->
val result =
return mapNotNullAsync(remainingTos) { info ->
tryAndWait { continuation ->
val signer = accountRelayPair.first.createSigner()
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) {
@@ -74,45 +73,46 @@ class RegisterAccounts(
)
}
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
RelayAuthEvent.create(info.relays, notificationToken, signer) { result ->
continuation.resume(result)
}
}
if (result != null) {
output.add(result)
}
}
onReady(output)
}
class Registration(
val accountSettings: AccountSettings,
val relays: List<NormalizedRelayUrl>,
)
// creates proof that it controls all accounts
private suspend fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
): List<RelayAuthEvent> {
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<RelayAuthEvent>()
signAllAuths(
notificationToken,
readyToSend,
listOfAuthEvents,
onReady,
)
return signAllAuths(notificationToken, readyToSend)
}
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
suspend fun postRegistrationEvent(events: List<RelayAuthEvent>) {
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))
}
}
}
@@ -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()
@@ -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)
@@ -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)
@@ -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)
},
)
}
@@ -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,14 +52,18 @@ class UrlPreview {
.url(url)
.get()
.build()
okHttpClient(url).newCall(request).execute().use {
checkNotInMainThread()
if (it.isSuccessful) {
val client = okHttpClient(url)
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
val mimeType =
it.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}")
response.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}")
if (mimeType.type == "text" && mimeType.subtype == "html") {
val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType))
val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType)
val data = OpenGraphParser().extractUrlInfo(metaTags)
UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString())
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
@@ -69,7 +73,8 @@ class UrlPreview {
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
}
} else {
throw IllegalArgumentException("Website returned: " + it.code)
throw IllegalArgumentException("Website returned: " + response.code)
}
}
}
}
@@ -27,7 +27,6 @@ import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import 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,18 +159,16 @@ 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 ->
return client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseResults(str)
return result
parseResults(body.string())
}
} else {
val errorMessage = response.headers.get("X-Reason")
@@ -184,6 +184,7 @@ class BlossomUploader {
}
}
}
}
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
@@ -208,14 +209,14 @@ 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 ->
return okHttpClient(apiUrl).newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
return true
true
} else {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
@@ -226,6 +227,7 @@ class BlossomUploader {
}
}
}
}
private fun parseResults(body: String): MediaUploadResult {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
@@ -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,22 +170,22 @@ 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 ->
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()) {
return waitProcessing(result, server, okHttpClient, onProgress)
waitProcessing(result, server, okHttpClient, onProgress)
} else if (result.status == "success") {
val event = result.nip94Event
if (event != null) {
return convertToMediaResult(event)
convertToMediaResult(event)
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
}
@@ -219,6 +221,7 @@ class Nip96Uploader {
}
}
}
}
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
@@ -275,17 +278,15 @@ 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 ->
return client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
response.body.use { body ->
val result = DeleteResult.parse(body.string())
return result.status == "success"
}
val result = DeleteResult.parse(response.body.string())
result.status == "success"
} else {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
@@ -296,6 +297,7 @@ class Nip96Uploader {
}
}
}
}
private suspend fun waitProcessing(
result: UploadResult,
@@ -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())
}
}
}
@@ -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)
Log.e("RelayInfoFail", "Resulting Message from $baseUrl", e)
throw e
}
}
}
}
}
@@ -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
@@ -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,32 +93,16 @@ 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)
}
override fun onResponse(
call: Call,
response: Response,
) {
try {
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -145,15 +130,14 @@ object MediaSaverToDisk {
)
}
onSuccess()
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
Log.e("MediaSaverToDisk", "Error parsing response", e)
onError(e)
}
}
},
)
}
private fun getMimeTypeFromExtension(fileName: String): String =
fileName.substringAfterLast('.', "").lowercase().let {
@@ -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
}
@@ -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,
@@ -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,16 +228,17 @@ fun ZapCustomDialog(
OutlinedTextField(
// stringRes(R.string.new_amount_in_sats
label = {
if (
selectedZapType == LnZapEvent.ZapType.PUBLIC ||
selectedZapType == LnZapEvent.ZapType.ANONYMOUS
) {
when (selectedZapType) {
LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> {
Text(text = stringRes(id = R.string.custom_zaps_add_a_message))
} else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) {
}
LnZapEvent.ZapType.PRIVATE -> {
Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private))
} else if (selectedZapType == LnZapEvent.ZapType.NONZAP) {
}
LnZapEvent.ZapType.NONZAP -> {
Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap))
}
}
},
value = postViewModel.customMessage,
onValueChange = { postViewModel.customMessage = it },
@@ -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),
@@ -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,
@@ -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,
)
}
}
@@ -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
},
@@ -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)
},
@@ -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,24 +483,23 @@ class AccountViewModel(
)
}.toMutableMap()
collectSuccessfulOperations<CombinedZap, DecryptedInfo>(
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<CombinedZap, DecryptedInfo>(
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())
}
}
}
fun cachedDecryptAmountMessageInGroup(zapNotes: List<CombinedZap>): ImmutableList<ZapAmountCommentNotification> =
zapNotes
@@ -586,20 +588,21 @@ class AccountViewModel(
)
}.toMutableMap()
collectSuccessfulOperations<Pair<Note, Note?>, 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 }
}
decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info }
onNewState(initialResults.values.toImmutableList())
}
}
}
fun decryptAmountMessage(
zapRequest: Note,
@@ -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(
val decryptedContent = account.decryptZapOrNull(it)
if (decryptedContent != null) {
ZapAmountCommentNotification(
newAuthor,
LocalCache.checkGetOrCreateUser(decryptedContent.pubKey),
decryptedContent.content.ifBlank { null },
showAmountInteger(amount),
),
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),
),
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) },
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,48 +1608,75 @@ 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,
)
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 {
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,
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)
},
)
}
}
}
}
suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account)
@@ -43,8 +43,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.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
},
@@ -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
},
@@ -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<String?>(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
@@ -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()) {
+2 -1
View File
@@ -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" }
@@ -100,7 +100,7 @@ class LnZapRequestEvent(
fun decryptPrivateZap(
signer: NostrSigner,
onReady: (Event) -> Unit,
onReady: (LnZapPrivateEvent) -> Unit,
) {
privateZapEvent?.let {
onReady(it)
@@ -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 <T> launchAndWaitAll(
withTimeoutOrNull(15000) {
jobs.joinAll()
}
async {
jobs.forEach {
it.cancel("Timeout")
}
}
}
}
@@ -69,15 +77,14 @@ suspend inline fun <T> tryAndWait(
* runs the request for that item,
* and gathers all the results in the output map.
*/
suspend fun <T, K> collectSuccessfulOperations(
suspend fun <T, K> collectSuccessfulOperationsReturning(
items: List<T>,
runRequestFor: (T, (K) -> Unit) -> Unit,
output: MutableList<K> = mutableListOf(),
onReady: suspend (List<K>) -> Unit,
) {
): List<K> {
val output: MutableList<K> = mutableListOf()
if (items.isEmpty()) {
onReady(output)
return
return output
}
launchAndWaitAll(items) {
@@ -91,5 +98,58 @@ suspend fun <T, K> collectSuccessfulOperations(
}
}
onReady(output)
return output
}
/**
* Executes multiple suspending functions concurrently using `async` and attempts to wait for all of them
* to complete within a default 15-second timeout.
*
* If the timeout is reached, it returns the results of all tasks that successfully completed by then
* and cancels any tasks that are still running. Tasks that completed with an exception are not
* included in the returned list.
*
* @param tasks A list of suspending functions, each returning a value of type [T].
* @return A list containing the results of tasks that successfully completed within the timeout.
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun <T, K> mapNotNullAsync(
items: List<T>,
timeoutMillis: Long = 30000,
runRequestFor: suspend (T) -> K?,
): List<K> {
if (items.isEmpty()) {
return emptyList()
}
return coroutineScope {
// Launch all tasks asynchronously and get their Deferred handles.
val jobs =
items.map { item ->
async {
runRequestFor(item)
}
}
// Use withTimeout to impose a 15-second limit on waiting for all deferreds.
// If all tasks complete within 15 seconds, awaitAll() will return their results,
// and this block will return those results.
withTimeoutOrNull(timeoutMillis) {
jobs.joinAll()
}
async {
jobs.forEach {
it.cancel("Timeout")
}
}
jobs.mapNotNull {
if (it.isCompleted) {
it.getCompleted()
} else {
null
}
}
}
}