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