diff --git a/amethyst/build.gradle b/amethyst/build.gradle index ceddd3178..6978368d7 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinAndroid) alias(libs.plugins.googleServices) alias(libs.plugins.jetbrainsComposeCompiler) + alias(libs.plugins.serialization) } android { @@ -282,6 +283,9 @@ dependencies { // Image compression lib implementation libs.zelory.video.compressor + // Cbor for cashuB format + implementation libs.kotlinx.serialization.cbor + testImplementation libs.junit testImplementation libs.mockk diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt new file mode 100644 index 000000000..5fb552f09 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt @@ -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>).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>).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>).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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index 9d603e50f..0b3d465e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -23,14 +23,20 @@ package com.vitorpamplona.amethyst.service import android.content.Context import android.util.LruCache import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.databind.JsonNode 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.ammolite.service.HttpClientManager +import com.vitorpamplona.quartz.encoders.toHexKey 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.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -42,16 +48,26 @@ data class CashuToken( val token: String, val mint: String, val totalAmount: Long, - val proofs: JsonNode, + val proofs: List, +) + +@Serializable +@Immutable +class Proof( + val amount: Int, + val id: String, + val secret: String, + val C: String, ) object CachedCashuProcessor { - val cashuCache = LruCache>(20) + val cashuCache = LruCache>>(20) - fun cached(token: String): GenericLoadable = cashuCache[token] ?: GenericLoadable.Loading() + fun cached(token: String): GenericLoadable> = cashuCache[token] ?: GenericLoadable.Loading() - fun parse(token: String): GenericLoadable { + fun parse(token: String): GenericLoadable> { if (cashuCache[token] !is GenericLoadable.Loaded) { + checkNotInMainThread() val newCachuData = CashuProcessor().parse(token) cashuCache.put(token, newCachuData) @@ -62,25 +78,138 @@ object CachedCashuProcessor { } class CashuProcessor { - fun parse(cashuToken: String): GenericLoadable { + @Serializable + class V3Token( + val unit: String?, // unit + val memo: String?, // memo + val token: List?, + ) + + @Serializable + class V3T( + val mint: String, + val proofs: List, + ) + + fun parse(cashuToken: String): GenericLoadable> { + checkNotInMainThread() + + if (cashuToken.startsWith("cashuA")) { + return parseCashuA(cashuToken) + } + + if (cashuToken.startsWith("cashuB")) { + return parseCashuB(cashuToken) + } + + return GenericLoadable.Error("Could not parse this cashu token") + } + + fun parseCashuA(cashuToken: String): GenericLoadable> { checkNotInMainThread() try { val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token))) - val token = cashu.get("token").get(0) - val proofs = token.get("proofs") - val mint = token.get("mint").asText() + val cashu = jacksonObjectMapper().readValue(String(Base64.getDecoder().decode(base64token))) - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.get("amount").asLong() + if (cashu.token == null) { + return GenericLoadable.Error("No token found") } - 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) { if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") + return GenericLoadable.Error>("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?, + ) + + @Serializable + class V4T( + @ByteString + val i: ByteArray, // identifier + val p: Array, + ) + + @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> { + checkNotInMainThread() + + try { + val base64token = cashuToken.replace("cashuB", "") + + val parser = Cbor { ignoreUnknownKeys = true } + + val v4Token = parser.decodeFromByteArray(Base64.getUrlDecoder().decode(base64token)) + + val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found") + + val converted = + v4proofs.map { id -> + val proofs = + id.p.map { + Proof( + it.a, + id.i.toHexKey(), + it.s, + it.c.toHexKey(), + ) + } + val mint = v4Token.m + + var totalAmount = 0L + for (proof in proofs) { + totalAmount += proof.amount + } + + CashuToken(cashuToken, mint, totalAmount, proofs) + } + + return GenericLoadable.Loaded(converted) + } 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 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) val mediaType = "application/json; charset=utf-8".toMediaType() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index abc8f976d..ba03e2147 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent import android.net.Uri -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.padding 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.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle 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.LocalContext import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity import androidx.lifecycle.viewmodel.compose.viewModel -import com.fasterxml.jackson.databind.node.TextNode import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu 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.ui.actions.CrossfadeIfEnabled 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.OpenInNewIcon 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.stringRes 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.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.SmallishBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.subtleBorder import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -108,8 +98,8 @@ fun CashuPreview( CrossfadeIfEnabled(targetState = cashuData, label = "CashuPreview", accountViewModel = accountViewModel) { when (it) { - is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel) - is GenericLoadable.Error -> + is GenericLoadable.Loaded> -> CashuPreview(it.loaded, accountViewModel) + is GenericLoadable.Error> -> Text( text = "$cashutoken ", style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), @@ -121,10 +111,12 @@ fun CashuPreview( @Composable fun CashuPreview( - token: CashuToken, + tokens: List, accountViewModel: AccountViewModel, ) { - CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast) + tokens.forEach { + CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast) + } } @Composable @@ -137,14 +129,8 @@ fun CashuPreviewPreview() { AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) { Column { - CashuPreview( - token = CashuToken("token", "mint", 32400, TextNode("")), - melt = { token, context, onDone -> }, - toast = { title, message -> }, - ) - CashuPreviewNew( - token = CashuToken("token", "mint", 32400, TextNode("")), + token = CashuToken("token", "mint", 32400, listOf()), melt = { token, context, onDone -> }, 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 fun CashuPreviewNew( token: CashuToken, diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 663d1bc6c..0914babe4 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -235,7 +235,7 @@ class RichTextParser { 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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8b75eceb..c2bf6aad4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,8 @@ jna = "5.14.0" junit = "4.13.2" kotlin = "2.0.0" kotlinxCollectionsImmutable = "0.3.7" +kotlinxSerialization = "1.7.1" +kotlinxSerializationPlugin = "2.0.0" languageId = "17.0.5" lazysodiumAndroid = "5.1.0" 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" } junit = { group = "junit", name = "junit", version.ref = "junit" } 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" } 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" } @@ -126,4 +129,5 @@ diffplugSpotless = { id = "com.diffplug.spotless", version.ref = "spotless" } googleServices = { id = "com.google.gms.google-services", version.ref = "gms" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file +jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' } \ No newline at end of file