Moves OkHttp to the coroutines version and turns all their usage into suspend functions
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-22
@@ -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()
|
||||
|
||||
|
||||
+11
-28
@@ -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,
|
||||
)
|
||||
+230
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
-122
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+35
-39
@@ -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)
|
||||
|
||||
-2
@@ -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)
|
||||
|
||||
+1
-24
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -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)
|
||||
|
||||
+18
-15
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-11
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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),
|
||||
|
||||
+8
-7
@@ -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,
|
||||
|
||||
+14
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
},
|
||||
|
||||
+1
-1
@@ -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)
|
||||
},
|
||||
|
||||
+115
-86
@@ -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)
|
||||
|
||||
|
||||
+2
-4
@@ -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
|
||||
},
|
||||
|
||||
+2
-2
@@ -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
|
||||
},
|
||||
|
||||
+4
-5
@@ -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
|
||||
|
||||
+13
-3
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user