Adds support for CashuB tokens using CBOR
This commit is contained in:
@@ -5,6 +5,7 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||||
alias(libs.plugins.googleServices)
|
alias(libs.plugins.googleServices)
|
||||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||||
|
alias(libs.plugins.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -282,6 +283,9 @@ dependencies {
|
|||||||
// Image compression lib
|
// Image compression lib
|
||||||
implementation libs.zelory.video.compressor
|
implementation libs.zelory.video.compressor
|
||||||
|
|
||||||
|
// Cbor for cashuB format
|
||||||
|
implementation libs.kotlinx.serialization.cbor
|
||||||
|
|
||||||
testImplementation libs.junit
|
testImplementation libs.junit
|
||||||
testImplementation libs.mockk
|
testImplementation libs.mockk
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024 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
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.vitorpamplona.amethyst.service.CashuProcessor
|
||||||
|
import com.vitorpamplona.amethyst.service.CashuToken
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||||
|
import junit.framework.TestCase.assertEquals
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CashuBTest {
|
||||||
|
val cashuTokenA = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"
|
||||||
|
val cashuTokenB1 = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA"
|
||||||
|
val cashuTokenB2 = "cashuBpGFkb01pbmliaXRzIHJ1bGVzIWFteEpodHRwOi8vbGJ1dGxoNWxmZ2dxNXI3eHBpd2hyYWpkbDdzeHB1cGdhZ2F6eGw2NXc0YzVjZzcyd3RvZmFzYWQub25pb246MzMzOGF0gaJhaUgAm7I9OpEuTmFwg6NhYRhAYWNYIQPfWR0mG80XbGnj6DO8q1NIyjHSGGIEkoWTA6H16HTpx2FzeEA3YThkY2Y5YjNlOGEyNDdjZTMzOWU3MzY5ZTliNGExOWYzMWVhY2I2OWQ4YjBjNjVkYWFlYjcyZDFhY2I5YWQzo2FhGCBhY1ghA55SwCFBc46dwnjbkb87Mzo30T2EE9Ws_nemuFneDegGYXN4QDlkODFjMWEyNjE2ODUzYWQ4MDQ5Y2JjZDFjN2MyNDdhZGQ4M2IzNzM4Mjg2MjBiYWMyZmQ3ZjNlNWE1OGFjZWKjYWEEYWNYIQM-yAQQTR2t6pIAmfmGM8Wxy7ajKVLOaUg7TrV8o-EdVWFzeEBmNmViMTI4ZmJlMDM3MTEzZTkzZjM3NjllYTYwMTk1NmY1N2NkZWNhNTYwOGY0NWUzMDhhZDU0ZmQ4YTQxNWVhYXVjc2F0"
|
||||||
|
|
||||||
|
@Test()
|
||||||
|
fun parseCashuA() {
|
||||||
|
runBlocking {
|
||||||
|
val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
|
||||||
|
|
||||||
|
assertEquals(cashuTokenA, parsed.token)
|
||||||
|
assertEquals("https://8333.space:3338", parsed.mint)
|
||||||
|
assertEquals(10, parsed.totalAmount)
|
||||||
|
|
||||||
|
assertEquals(2, parsed.proofs[0].amount)
|
||||||
|
assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret)
|
||||||
|
assertEquals("009a1f293253e41e", parsed.proofs[0].id)
|
||||||
|
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C)
|
||||||
|
|
||||||
|
assertEquals(8, parsed.proofs[1].amount)
|
||||||
|
assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret)
|
||||||
|
assertEquals("009a1f293253e41e", parsed.proofs[1].id)
|
||||||
|
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test()
|
||||||
|
fun parseCashuB() =
|
||||||
|
runBlocking {
|
||||||
|
val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
|
||||||
|
|
||||||
|
assertEquals(cashuTokenB1, parsed[0].token)
|
||||||
|
assertEquals("http://localhost:3338", parsed[0].mint)
|
||||||
|
assertEquals(1, parsed[0].totalAmount)
|
||||||
|
assertEquals(1, parsed[0].proofs[0].amount)
|
||||||
|
assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret)
|
||||||
|
assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id)
|
||||||
|
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C)
|
||||||
|
|
||||||
|
assertEquals(3, parsed[1].totalAmount)
|
||||||
|
assertEquals(2, parsed[1].proofs[0].amount)
|
||||||
|
assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret)
|
||||||
|
assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id)
|
||||||
|
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C)
|
||||||
|
|
||||||
|
assertEquals(1, parsed[1].proofs[1].amount)
|
||||||
|
assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret)
|
||||||
|
assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id)
|
||||||
|
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test()
|
||||||
|
fun parseCashuB2() =
|
||||||
|
runBlocking {
|
||||||
|
val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
|
||||||
|
|
||||||
|
assertEquals(cashuTokenB2, parsed[0].token)
|
||||||
|
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
|
||||||
|
assertEquals(100, parsed[0].totalAmount)
|
||||||
|
assertEquals(64, parsed[0].proofs[0].amount)
|
||||||
|
assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret)
|
||||||
|
assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id)
|
||||||
|
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C)
|
||||||
|
|
||||||
|
assertEquals(32, parsed[0].proofs[1].amount)
|
||||||
|
assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret)
|
||||||
|
assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id)
|
||||||
|
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,20 @@ package com.vitorpamplona.amethyst.service
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||||
|
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||||
import com.vitorpamplona.quartz.events.Event
|
import com.vitorpamplona.quartz.events.Event
|
||||||
|
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.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -42,16 +48,26 @@ data class CashuToken(
|
|||||||
val token: String,
|
val token: String,
|
||||||
val mint: String,
|
val mint: String,
|
||||||
val totalAmount: Long,
|
val totalAmount: Long,
|
||||||
val proofs: JsonNode,
|
val proofs: List<Proof>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@Immutable
|
||||||
|
class Proof(
|
||||||
|
val amount: Int,
|
||||||
|
val id: String,
|
||||||
|
val secret: String,
|
||||||
|
val C: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
object CachedCashuProcessor {
|
object CachedCashuProcessor {
|
||||||
val cashuCache = LruCache<String, GenericLoadable<CashuToken>>(20)
|
val cashuCache = LruCache<String, GenericLoadable<List<CashuToken>>>(20)
|
||||||
|
|
||||||
fun cached(token: String): GenericLoadable<CashuToken> = cashuCache[token] ?: GenericLoadable.Loading()
|
fun cached(token: String): GenericLoadable<List<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
|
||||||
|
|
||||||
fun parse(token: String): GenericLoadable<CashuToken> {
|
fun parse(token: String): GenericLoadable<List<CashuToken>> {
|
||||||
if (cashuCache[token] !is GenericLoadable.Loaded) {
|
if (cashuCache[token] !is GenericLoadable.Loaded) {
|
||||||
|
checkNotInMainThread()
|
||||||
val newCachuData = CashuProcessor().parse(token)
|
val newCachuData = CashuProcessor().parse(token)
|
||||||
|
|
||||||
cashuCache.put(token, newCachuData)
|
cashuCache.put(token, newCachuData)
|
||||||
@@ -62,25 +78,138 @@ object CachedCashuProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CashuProcessor {
|
class CashuProcessor {
|
||||||
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
|
@Serializable
|
||||||
|
class V3Token(
|
||||||
|
val unit: String?, // unit
|
||||||
|
val memo: String?, // memo
|
||||||
|
val token: List<V3T>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class V3T(
|
||||||
|
val mint: String,
|
||||||
|
val proofs: List<Proof>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parse(cashuToken: String): GenericLoadable<List<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<List<CashuToken>> {
|
||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val base64token = cashuToken.replace("cashuA", "")
|
val base64token = cashuToken.replace("cashuA", "")
|
||||||
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
|
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
|
||||||
val token = cashu.get("token").get(0)
|
|
||||||
val proofs = token.get("proofs")
|
|
||||||
val mint = token.get("mint").asText()
|
|
||||||
|
|
||||||
var totalAmount = 0L
|
if (cashu.token == null) {
|
||||||
for (proof in proofs) {
|
return GenericLoadable.Error("No token found")
|
||||||
totalAmount += proof.get("amount").asLong()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
|
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)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
return GenericLoadable.Error<List<CashuToken>>("Could not parse this cashu token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class V4Token(
|
||||||
|
val m: String, // mint
|
||||||
|
val u: String, // unit
|
||||||
|
val d: String? = null, // memo
|
||||||
|
val t: Array<V4T>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class V4T(
|
||||||
|
@ByteString
|
||||||
|
val i: ByteArray, // identifier
|
||||||
|
val p: Array<V4Proof>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class V4Proof(
|
||||||
|
val a: Int, // amount
|
||||||
|
val s: String, // secret
|
||||||
|
@ByteString
|
||||||
|
val c: ByteArray, // signature
|
||||||
|
val d: V4DleqProof? = null, // no idea what this is
|
||||||
|
val w: String? = null, // witness
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class V4DleqProof(
|
||||||
|
@ByteString
|
||||||
|
val e: ByteArray,
|
||||||
|
@ByteString
|
||||||
|
val s: ByteArray,
|
||||||
|
@ByteString
|
||||||
|
val r: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
fun parseCashuB(cashuToken: String): GenericLoadable<List<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)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
return GenericLoadable.Error("Could not parse this cashu token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +338,21 @@ class CashuProcessor {
|
|||||||
val factory = Event.mapper.nodeFactory
|
val factory = Event.mapper.nodeFactory
|
||||||
|
|
||||||
val jsonObject = factory.objectNode()
|
val jsonObject = factory.objectNode()
|
||||||
jsonObject.put("proofs", token.proofs)
|
|
||||||
|
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)
|
jsonObject.put("pr", invoice)
|
||||||
|
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -31,11 +30,8 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -53,14 +49,12 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDirection
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat.startActivity
|
import androidx.core.content.ContextCompat.startActivity
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.fasterxml.jackson.databind.node.TextNode
|
|
||||||
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
|
||||||
@@ -69,7 +63,6 @@ import com.vitorpamplona.amethyst.service.CachedCashuProcessor
|
|||||||
import com.vitorpamplona.amethyst.service.CashuToken
|
import com.vitorpamplona.amethyst.service.CashuToken
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||||
import com.vitorpamplona.amethyst.ui.note.CashuIcon
|
|
||||||
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
|
||||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||||
@@ -77,14 +70,11 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
|||||||
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.AmethystTheme
|
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
|
||||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -108,8 +98,8 @@ fun CashuPreview(
|
|||||||
|
|
||||||
CrossfadeIfEnabled(targetState = cashuData, label = "CashuPreview", accountViewModel = accountViewModel) {
|
CrossfadeIfEnabled(targetState = cashuData, label = "CashuPreview", accountViewModel = accountViewModel) {
|
||||||
when (it) {
|
when (it) {
|
||||||
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
|
is GenericLoadable.Loaded<List<CashuToken>> -> CashuPreview(it.loaded, accountViewModel)
|
||||||
is GenericLoadable.Error<CashuToken> ->
|
is GenericLoadable.Error<List<CashuToken>> ->
|
||||||
Text(
|
Text(
|
||||||
text = "$cashutoken ",
|
text = "$cashutoken ",
|
||||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||||
@@ -121,10 +111,12 @@ fun CashuPreview(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CashuPreview(
|
fun CashuPreview(
|
||||||
token: CashuToken,
|
tokens: List<CashuToken>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast)
|
tokens.forEach {
|
||||||
|
CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -137,14 +129,8 @@ fun CashuPreviewPreview() {
|
|||||||
|
|
||||||
AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) {
|
AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) {
|
||||||
Column {
|
Column {
|
||||||
CashuPreview(
|
|
||||||
token = CashuToken("token", "mint", 32400, TextNode("")),
|
|
||||||
melt = { token, context, onDone -> },
|
|
||||||
toast = { title, message -> },
|
|
||||||
)
|
|
||||||
|
|
||||||
CashuPreviewNew(
|
CashuPreviewNew(
|
||||||
token = CashuToken("token", "mint", 32400, TextNode("")),
|
token = CashuToken("token", "mint", 32400, listOf()),
|
||||||
melt = { token, context, onDone -> },
|
melt = { token, context, onDone -> },
|
||||||
toast = { title, message -> },
|
toast = { title, message -> },
|
||||||
)
|
)
|
||||||
@@ -152,148 +138,6 @@ fun CashuPreviewPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CashuPreview(
|
|
||||||
token: CashuToken,
|
|
||||||
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
|
|
||||||
toast: (String, String) -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
|
|
||||||
.clip(shape = QuoteBorder)
|
|
||||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 10.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = CustomHashTagIcons.Cashu,
|
|
||||||
null,
|
|
||||||
modifier = Size20Modifier,
|
|
||||||
tint = Color.Unspecified,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.cashu),
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.W500,
|
|
||||||
modifier = Modifier.padding(start = 10.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(thickness = DividerThickness)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${token.totalAmount} ${stringRes(id = R.string.sats)}",
|
|
||||||
fontSize = 25.sp,
|
|
||||||
fontWeight = FontWeight.W500,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 10.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(top = 5.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
var isRedeeming by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
isRedeeming = true
|
|
||||||
melt(token, context) { title, message ->
|
|
||||||
toast(title, message)
|
|
||||||
isRedeeming = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = QuoteBorder,
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
if (isRedeeming) {
|
|
||||||
LoadingAnimation()
|
|
||||||
} else {
|
|
||||||
ZapIcon(Size20Modifier, tint = Color.White)
|
|
||||||
}
|
|
||||||
Spacer(DoubleHorzSpacer)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringRes(id = R.string.cashu_redeem_to_zap),
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = StdHorzSpacer)
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
try {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
||||||
|
|
||||||
startActivity(context, intent, null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e is CancellationException) throw e
|
|
||||||
toast(stringRes(context, R.string.cashu), stringRes(context, R.string.cashu_no_wallet_found))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = QuoteBorder,
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
CashuIcon(Size20Modifier)
|
|
||||||
Spacer(DoubleHorzSpacer)
|
|
||||||
Text(
|
|
||||||
stringRes(id = R.string.cashu_redeem_to_cashu),
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = StdHorzSpacer)
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
// Copying the token to clipboard
|
|
||||||
clipboardManager.setText(AnnotatedString(token.token))
|
|
||||||
},
|
|
||||||
shape = QuoteBorder,
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
CopyIcon(Size20Modifier, Color.White)
|
|
||||||
Spacer(DoubleHorzSpacer)
|
|
||||||
Text(stringRes(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp)
|
|
||||||
}
|
|
||||||
Spacer(modifier = StdHorzSpacer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CashuPreviewNew(
|
fun CashuPreviewNew(
|
||||||
token: CashuToken,
|
token: CashuToken,
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ class RichTextParser {
|
|||||||
|
|
||||||
if (word.startsWith("lnurl", true)) return WithdrawSegment(word)
|
if (word.startsWith("lnurl", true)) return WithdrawSegment(word)
|
||||||
|
|
||||||
if (word.startsWith("cashuA", true)) return CashuSegment(word)
|
if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word)
|
||||||
|
|
||||||
if (word.startsWith("#")) return parseHash(word, tags)
|
if (word.startsWith("#")) return parseHash(word, tags)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ jna = "5.14.0"
|
|||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.0.0"
|
kotlin = "2.0.0"
|
||||||
kotlinxCollectionsImmutable = "0.3.7"
|
kotlinxCollectionsImmutable = "0.3.7"
|
||||||
|
kotlinxSerialization = "1.7.1"
|
||||||
|
kotlinxSerializationPlugin = "2.0.0"
|
||||||
languageId = "17.0.5"
|
languageId = "17.0.5"
|
||||||
lazysodiumAndroid = "5.1.0"
|
lazysodiumAndroid = "5.1.0"
|
||||||
lifecycleRuntimeKtx = "2.8.4"
|
lifecycleRuntimeKtx = "2.8.4"
|
||||||
@@ -98,6 +100,7 @@ jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackso
|
|||||||
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
|
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
|
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
|
||||||
|
kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" }
|
||||||
lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" }
|
lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" }
|
||||||
markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
|
markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
|
||||||
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
|
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
|
||||||
@@ -126,4 +129,5 @@ diffplugSpotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
|||||||
googleServices = { id = "com.google.gms.google-services", version.ref = "gms" }
|
googleServices = { id = "com.google.gms.google-services", version.ref = "gms" }
|
||||||
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' }
|
||||||
Reference in New Issue
Block a user