Merge pull request #776 from a1denvalu3/feat/cashu-chips-android

feat: add Cashu ecash chips and /pay command
This commit is contained in:
callebtc 2026-07-29 20:21:12 +02:00 committed by GitHub
commit 44f62a3c14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 782 additions and 3 deletions

View File

@ -0,0 +1,287 @@
package com.bitchat.android.ui
import com.google.gson.JsonParser
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.math.BigDecimal
import java.util.Base64
import java.util.Currency
import java.util.Locale
/**
* Bounded, display-only Cashu token decoder. Tokens are bearer instruments, so
* this class never contacts a mint or attempts to hold/redeem funds.
*/
object CashuTokenDecoder {
const val MAX_TOKEN_LENGTH = 60_000
private const val MAX_AMOUNT = 2_100_000_000_000_000L
data class TokenInfo(
val version: Char,
val amount: Long?,
val unit: String?,
val mintHost: String?,
val memo: String?
) {
val displayAmount: String?
get() = amount?.let { value ->
val displayUnit = unit ?: "sat"
val minorDigits = minorUnitDigits(displayUnit)
val formatted = if (minorDigits == null || minorDigits == 0) {
value.toString()
} else {
BigDecimal.valueOf(value, minorDigits).setScale(minorDigits).toPlainString()
}
"$formatted $displayUnit"
}
}
fun bareToken(raw: String): String? {
var token = raw.trim()
if ('%' in token) token = percentDecode(token) ?: return null
token = when {
token.startsWith("cashu://", ignoreCase = true) -> token.substring(8)
token.startsWith("cashu:", ignoreCase = true) -> token.substring(6)
else -> token
}
if (token.length !in 12..MAX_TOKEN_LENGTH) return null
if (!token.startsWith("cashuA") && !token.startsWith("cashuB")) return null
if (token.any { !it.isLetterOrDigit() && it !in "-_+/=" }) return null
return token
}
/**
* Permissive decoding is suitable for display: unsupported but plausible
* v4 CBOR still gets a generic chip. Strict decoding is required before
* sending and accepts only a fully parsed token with a positive amount.
*/
fun decode(raw: String, strict: Boolean = false): TokenInfo? {
val token = bareToken(raw) ?: return null
val payload = decodeBase64Url(token.substring(6)) ?: return null
if (payload.isEmpty()) return null
val info = when (token[5]) {
'A' -> decodeV3(payload)
'B' -> decodeV4(payload) ?: if (strict) null else TokenInfo('B', null, null, null, null)
else -> null
} ?: return null
return if (!strict || (info.amount != null && info.amount > 0)) info else null
}
fun extractTokens(text: String, max: Int = 3): List<String> {
if (text.isEmpty() || max <= 0) return emptyList()
val matches = TOKEN_REGEX.findAll(text)
val result = LinkedHashSet<String>()
for (match in matches) {
bareToken(match.value)?.let(result::add)
if (result.size == max) break
}
return result.toList()
}
fun walletUri(token: String): String? = bareToken(token)?.let { "cashu:${encodeUriComponent(it)}" }
fun webRedeemUri(token: String): String? =
bareToken(token)?.let { "https://redeem.cashu.me/?token=${encodeUriComponent(it)}" }
private fun encodeUriComponent(value: String): String =
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
/** Percent-decodes URI input without URLDecoder's form-specific '+' → space conversion. */
private fun percentDecode(value: String): String? {
val output = StringBuilder(value.length)
var index = 0
while (index < value.length) {
if (value[index] != '%') {
output.append(value[index++])
continue
}
val bytes = ArrayList<Byte>()
while (index < value.length && value[index] == '%') {
if (index + 2 >= value.length) return null
val byte = value.substring(index + 1, index + 3).toIntOrNull(16) ?: return null
bytes += byte.toByte()
index += 3
}
output.append(String(bytes.toByteArray(), StandardCharsets.UTF_8))
}
return output.toString()
}
private fun decodeBase64Url(input: String): ByteArray? {
val normalized = input.replace('-', '+').replace('_', '/').trimEnd('=')
if (normalized.length % 4 == 1) return null
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
return runCatching { Base64.getDecoder().decode(padded) }.getOrNull()
}
private fun decodeV3(payload: ByteArray): TokenInfo? = runCatching {
val root = JsonParser.parseString(String(payload, StandardCharsets.UTF_8)).asJsonObject
val entries = root.getAsJsonArray("token")?.takeIf { it.size() > 0 } ?: return null
var total = 0L
var sawAmount = false
var mintHost: String? = null
for (entryElement in entries) {
val entry = entryElement.takeIf { it.isJsonObject }?.asJsonObject ?: continue
if (mintHost == null) mintHost = sanitizeHost(entry.get("mint")?.takeIf { it.isJsonPrimitive }?.asString)
val proofs = entry.getAsJsonArray("proofs") ?: continue
for (proofElement in proofs) {
val amountElement = proofElement.takeIf { it.isJsonObject }?.asJsonObject?.get("amount") ?: continue
if (!amountElement.isJsonPrimitive || !amountElement.asJsonPrimitive.isNumber) continue
val value = runCatching { amountElement.asBigDecimal.longValueExact() }.getOrNull() ?: continue
if (value <= 0 || value > MAX_AMOUNT) continue
if (total > MAX_AMOUNT - value) return null
total += value
sawAmount = true
}
}
TokenInfo(
version = 'A',
amount = total.takeIf { sawAmount },
unit = sanitizeUnit(root.get("unit")?.takeIf { it.isJsonPrimitive }?.asString),
mintHost = mintHost,
memo = sanitizeMemo(root.get("memo")?.takeIf { it.isJsonPrimitive }?.asString)
)
}.getOrNull()
private fun decodeV4(payload: ByteArray): TokenInfo? {
val root = CborReader(payload).parseComplete() as? CborValue.MapValue ?: return null
var total = 0L
var sawAmount = false
var mintHost: String? = null
var unit: String? = null
var memo: String? = null
for ((key, value) in root.pairs) {
when ((key as? CborValue.Text)?.value) {
"m" -> mintHost = sanitizeHost((value as? CborValue.Text)?.value)
"u" -> unit = sanitizeUnit((value as? CborValue.Text)?.value)
"d" -> memo = sanitizeMemo((value as? CborValue.Text)?.value)
"t" -> for (group in (value as? CborValue.ArrayValue)?.values.orEmpty()) {
for ((groupKey, groupValue) in (group as? CborValue.MapValue)?.pairs.orEmpty()) {
if ((groupKey as? CborValue.Text)?.value != "p") continue
for (proof in (groupValue as? CborValue.ArrayValue)?.values.orEmpty()) {
for ((proofKey, proofValue) in (proof as? CborValue.MapValue)?.pairs.orEmpty()) {
if ((proofKey as? CborValue.Text)?.value != "a") continue
val amount = (proofValue as? CborValue.Unsigned)?.value ?: continue
if (amount == 0L || amount > MAX_AMOUNT) continue
if (total > MAX_AMOUNT - amount) return null
total += amount
sawAmount = true
}
}
}
}
}
}
return TokenInfo('B', total.takeIf { sawAmount }, unit, mintHost, memo)
}
private fun sanitizeHost(value: String?): String? = value
?.takeIf { it.length <= 512 }
?.let { runCatching { URI(it).host }.getOrNull() }
?.takeIf { it.isNotEmpty() }
?.lowercase()
?.take(48)
private fun sanitizeUnit(value: String?): String? =
value?.takeIf { it.isNotEmpty() && it.length <= 12 && it.all(Char::isLetterOrDigit) }
private fun sanitizeMemo(value: String?): String? {
if (value == null || value.length > 512) return null
return value.filterNot(Char::isISOControl).trim().take(80).takeIf(String::isNotEmpty)
}
/** ISO-4217 values use their currency's minor unit; custom units stay integer-denominated. */
private fun minorUnitDigits(unit: String): Int? {
return runCatching {
Currency.getInstance(unit.uppercase(Locale.ROOT)).defaultFractionDigits
}.getOrNull()?.takeIf { it >= 0 }
}
private val TOKEN_REGEX = Regex("""(?i:cashu:(?://)?)?cashu[AB][A-Za-z0-9_+/%=-]{6,}""")
}
private sealed interface CborValue {
data class Unsigned(val value: Long) : CborValue
data class Text(val value: String) : CborValue
data class ArrayValue(val values: List<CborValue>) : CborValue
data class MapValue(val pairs: List<Pair<CborValue, CborValue>>) : CborValue
data object Opaque : CborValue
}
private class CborReader(private val bytes: ByteArray) {
private var index = 0
private var itemBudget = 50_000
fun parseComplete(): CborValue? {
val value = parseValue(0) ?: return null
return value.takeIf { index == bytes.size }
}
private fun parseValue(depth: Int): CborValue? {
if (depth >= 16 || itemBudget-- <= 0) return null
val (major, argument) = readHead() ?: return null
return when (major) {
0 -> CborValue.Unsigned(argument.takeIf { it <= Long.MAX_VALUE }?.toLong() ?: return null)
1 -> CborValue.Opaque
2 -> if (readBytes(argument) != null) CborValue.Opaque else null
3 -> readBytes(argument)?.toString(StandardCharsets.UTF_8)?.let(CborValue::Text)
4 -> parseContainer(argument, depth) { CborValue.ArrayValue(it) }
5 -> {
if (argument > 10_000 || argument > itemBudget / 2) return null
val pairs = ArrayList<Pair<CborValue, CborValue>>(argument.coerceAtMost(64).toInt())
repeat(argument.toInt()) {
pairs += (parseValue(depth + 1) ?: return null) to (parseValue(depth + 1) ?: return null)
}
CborValue.MapValue(pairs)
}
6 -> parseValue(depth + 1)
7 -> CborValue.Opaque
else -> null
}
}
private fun parseContainer(
count: Long,
depth: Int,
wrap: (List<CborValue>) -> CborValue
): CborValue? {
if (count > 10_000 || count > itemBudget) return null
val values = ArrayList<CborValue>(count.coerceAtMost(64).toInt())
repeat(count.toInt()) { values += parseValue(depth + 1) ?: return null }
return wrap(values)
}
private fun readHead(): Pair<Int, Long>? {
if (index >= bytes.size) return null
val head = bytes[index++].toInt() and 0xff
val major = head ushr 5
val info = head and 0x1f
val argument = when (info) {
in 0..23 -> info.toLong()
24 -> readUInt(1)
25 -> readUInt(2)
26 -> readUInt(4)
27 -> readUInt(8)
else -> null
} ?: return null
return major to argument
}
private fun readUInt(width: Int): Long? {
if (bytes.size - index < width) return null
var value = 0L
repeat(width) {
val next = bytes[index++].toLong() and 0xff
if (value > (Long.MAX_VALUE - next) ushr 8) return null
value = (value shl 8) or next
}
return value
}
private fun readBytes(count: Long): ByteArray? {
if (count < 0 || count > bytes.size - index) return null
val end = index + count.toInt()
return bytes.copyOfRange(index, end).also { index = end }
}
}

View File

@ -920,6 +920,20 @@ class ChatViewModel(
mesh.myPeerID,
state.getNicknameValue()
)
} else if (channel != null && channelManager.hasChannelKey(channel)) {
channelManager.sendEncryptedChannelMessage(
messageContent,
mentions,
channel,
state.getNicknameValue(),
mesh.myPeerID,
onEncryptedPayload = {
mesh.sendMessage(messageContent, mentions, channel)
},
onFallback = {
mesh.sendMessage(messageContent, mentions, channel)
}
)
} else {
mesh.sendMessage(messageContent, mentions, channel)
}

View File

@ -26,6 +26,7 @@ class CommandProcessor(
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
CommandSuggestion("/pay", emptyList(), "<token> [public]", "send a Cashu ecash token"),
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
CommandSuggestion("/w", emptyList(), null, "see who's online")
@ -41,6 +42,7 @@ class CommandProcessor(
when (cmd) {
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
"/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel)
"/pay" -> handlePayCommand(command, meshService, myPeerID, onSendMessage, viewModel)
"/w" -> handleWhoCommand(meshService, viewModel)
"/clear" -> handleClearCommand()
"/pass" -> handlePassCommand(parts, myPeerID)
@ -363,6 +365,95 @@ class CommandProcessor(
)
messageManager.addMessage(systemMessage)
}
private fun handlePayCommand(
command: String,
meshService: MeshService,
myPeerID: String,
onSendMessage: (String, List<String>, String?) -> Unit,
viewModel: ChatViewModel?
) {
val args = command.trim().split(Regex("\\s+")).drop(1)
if (args.isEmpty()) {
addSystemMessage("usage: /pay <cashu token> [public] — Cashu tokens are bearer instruments")
return
}
val publicConfirmed = args.lastOrNull()?.equals("public", ignoreCase = true) == true
val rawToken = if (publicConfirmed) args.dropLast(1).joinToString(" ") else args.joinToString(" ")
val token = CashuTokenDecoder.bareToken(rawToken)
val info = token?.let { CashuTokenDecoder.decode(it, strict = true) }
if (token == null || info == null) {
addSystemMessage("invalid cashu token — not sending it")
return
}
val selectedPeer = state.getSelectedPrivateChatPeerValue()
if (selectedPeer != null) {
privateChatManager.sendPrivateMessage(
token,
selectedPeer,
getPeerNickname(selectedPeer, meshService),
state.getNicknameValue(),
myPeerID
) { content, peerID, recipientNickname, messageId ->
sendPrivateMessageVia(meshService, content, peerID, recipientNickname, messageId, viewModel)
}
} else {
if (!publicConfirmed) {
addSystemMessage(
"Cashu tokens are bearer instruments. Anyone here can redeem this token. " +
"Confirm with: /pay <token> public"
)
return
}
val isLocationChannel =
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
if (!isLocationChannel) {
val message = BitchatMessage(
sender = state.getNicknameValue() ?: myPeerID,
content = token,
timestamp = Date(),
isRelay = false,
senderPeerID = myPeerID,
channel = state.getCurrentChannelValue()
)
val channel = state.getCurrentChannelValue()
if (channel != null) channelManager.addChannelMessage(channel, message, myPeerID)
else messageManager.addMessage(message)
}
onSendMessage(token, emptyList(), state.getCurrentChannelValue())
}
addSystemMessage(
"sent ${info.displayAmount ?: "Cashu token"} — bearer token; first redeemer wins"
)
}
private fun addSystemMessage(content: String) {
val message = BitchatMessage(
sender = "system",
content = content,
timestamp = Date(),
isRelay = false
)
val selectedPeer = state.getSelectedPrivateChatPeerValue()
val selectedLocationChannel = state.selectedLocationChannel.value
val channel = state.getCurrentChannelValue()
when {
selectedPeer != null -> {
messageManager.addPrivateMessageNoUnread(selectedPeer, message.copy(isPrivate = true))
}
selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location -> {
messageManager.addChannelMessage(
"geo:${selectedLocationChannel.channel.geohash}",
message
)
}
channel != null -> channelManager.addChannelMessage(channel, message, null)
else -> messageManager.addMessage(message)
}
}
private fun handleUnknownCommand(cmd: String) {
val systemMessage = BitchatMessage(
@ -410,7 +501,12 @@ class CommandProcessor(
emptyList()
}
return baseCommands + channelCommands
val isPublicGeohash =
state.getSelectedPrivateChatPeerValue() == null &&
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
return (baseCommands + channelCommands).filterNot {
isPublicGeohash && it.command == "/pay"
}
}
private fun filterCommands(commands: List<CommandSuggestion>, input: String): List<CommandSuggestion> {

View File

@ -327,6 +327,9 @@ fun MessageInput(
var isRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
val cashuToken = remember(value.text) {
CashuTokenDecoder.bareToken(value.text)
}
// Slide-to-cancel: while recording, the mic button streams the finger position (root
// coords) up here; the cancel disc beside it reports its bounds. Approaching the disc
@ -425,16 +428,17 @@ fun MessageInput(
// user is composing rather than reading, and green-on-black is tiring to
// type into.
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.onSurface,
color = if (cashuToken == null) colorScheme.onSurface else Color.Transparent,
fontFamily = BitchatFontFamily
),
cursorBrush = SolidColor(
if (isRecording) Color.Transparent else colorScheme.onSurface
if (isRecording || cashuToken != null) Color.Transparent else colorScheme.onSurface
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend()
}),
singleLine = cashuToken != null,
// Cap the growth so a pasted wall of text cannot swallow the message list.
maxLines = 6,
visualTransformation = remember(
@ -463,6 +467,14 @@ fun MessageInput(
}
)
cashuToken?.let { token ->
CashuPaymentChip(
token = token,
onClick = { focusRequester.requestFocus() },
showActions = false,
)
}
// Placeholder fades rather than blinking, which matters because it reappears
// every time a message is sent.
val placeholderAlpha by animateFloatAsState(

View File

@ -1,5 +1,11 @@
package com.bitchat.android.ui
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.animation.AnimatedContent
@ -15,7 +21,9 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -25,13 +33,17 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -53,6 +65,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@ -599,6 +613,24 @@ fun MessageItem(
return
}
val cashuTokens = remember(message.content) {
CashuTokenDecoder.extractTokens(message.content)
}
if (cashuTokens.isNotEmpty() && message.sender != "system") {
CashuMessageContent(
message = message,
tokens = cashuTokens,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
)
return
}
if (message.sender == "system") {
// Background narration: `// Tor started. Routing all chats…`
val annotatedText = remember(message, colorScheme.onSurface) {
@ -756,6 +788,149 @@ internal fun TextMessageLayout(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CashuMessageContent(
message: BitchatMessage,
tokens: List<String>,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
) {
val remainingText = tokens.fold(message.content) { text, token ->
text.replace("cashu://$token", "", ignoreCase = true)
.replace("cashu:$token", "", ignoreCase = true)
.replace(token, "")
}.trim()
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextMessageLayout(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
bodyContent = remainingText,
)
tokens.forEach { token -> CashuPaymentChip(token) }
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun CashuPaymentChip(
token: String,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
showActions: Boolean = true,
) {
val context = LocalContext.current
val info = remember(token) { CashuTokenDecoder.decode(token) }
val primaryLabel = listOfNotNull(info?.displayAmount, info?.mintHost)
.joinToString(" · ")
.ifEmpty { stringResource(R.string.cashu_pay_via) }
var showMenu by remember { mutableStateOf(false) }
Box {
Row(
modifier = modifier
.border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.25f), RoundedCornerShape(12.dp))
.background(
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.45f),
RoundedCornerShape(12.dp)
)
.combinedClickable(
onClick = onClick ?: { redeemCashu(context, token, preferWallet = true) },
onLongClick = if (showActions) {
{ showMenu = true }
} else {
null
}
)
.semantics {
contentDescription = buildString {
append(context.getString(R.string.cashu_payment_description))
append(": ")
append(primaryLabel)
info?.memo?.let { append(", $it") }
}
}
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("🥜")
Column {
Text(
primaryLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
info?.memo?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
DropdownMenu(
expanded = showActions && showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.cashu_copy_token)) },
onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Cashu token", token))
showMenu = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.cashu_redeem_wallet)) },
onClick = {
showMenu = false
redeemCashu(context, token, preferWallet = true)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.cashu_redeem_web)) },
onClick = {
showMenu = false
redeemCashu(context, token, preferWallet = false)
}
)
}
}
}
private fun redeemCashu(context: Context, token: String, preferWallet: Boolean) {
val wallet = CashuTokenDecoder.walletUri(token)
val web = CashuTokenDecoder.webRedeemUri(token) ?: return
if (preferWallet && wallet != null) {
val walletIntent = Intent(Intent.ACTION_VIEW, Uri.parse(wallet))
try {
context.startActivity(walletIntent)
return
} catch (_: ActivityNotFoundException) {
// No wallet registered for cashu:, so use the explicit web fallback.
}
}
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) }
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme

View File

@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="cashu_pay_via">pay via Cashu</string>
<string name="cashu_payment_description">Cashu payment</string>
<string name="cashu_copy_token">Copy token</string>
<string name="cashu_redeem_wallet">Redeem in wallet</string>
<string name="cashu_redeem_web">Redeem on web</string>
<string name="app_name">bitchat</string>
<string name="permission_bluetooth_rationale">Bluetooth permission is required for peer-to-peer messaging without internet.</string>
<string name="permission_location_rationale">Location permission is required to discover nearby devices via Bluetooth.</string>

View File

@ -0,0 +1,165 @@
package com.bitchat.android.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import java.nio.charset.StandardCharsets
import java.util.Base64
class CashuTokenDecoderTest {
@Test
fun `v3 sums proofs and sanitizes display metadata`() {
val token = v3Token(
"""{"token":[{"mint":"https://MINT.example.com/path","proofs":[{"amount":1},{"amount":4},{"amount":16}]}],"unit":"sat","memo":" lunch\n"}"""
)
val info = CashuTokenDecoder.decode(token, strict = true)
assertEquals(21L, info?.amount)
assertEquals("21 sat", info?.displayAmount)
assertEquals("mint.example.com", info?.mintHost)
assertEquals("lunch", info?.memo)
}
@Test
fun `display amount applies currency minor units without changing proof sum`() {
val usd = CashuTokenDecoder.decode(
v3Token("""{"token":[{"proofs":[{"amount":900},{"amount":1}]}],"unit":"usd"}"""),
strict = true
)
val jpy = CashuTokenDecoder.decode(
v3Token("""{"token":[{"proofs":[{"amount":901}]}],"unit":"jpy"}"""),
strict = true
)
assertEquals(901L, usd?.amount)
assertEquals("9.01 usd", usd?.displayAmount)
assertEquals("901 jpy", jpy?.displayAmount)
}
@Test
fun `display amount does not assume decimals for custom units`() {
val token = CashuTokenDecoder.decode(
v3Token("""{"token":[{"proofs":[{"amount":901}]}],"unit":"usdc"}"""),
strict = true
)
assertEquals("901 usdc", token?.displayAmount)
}
@Test
fun `v4 definite length token decodes strictly`() {
val token = validV4Token()
val info = CashuTokenDecoder.decode(token, strict = true)
assertEquals(21L, info?.amount)
assertEquals("mint.example.com", info?.mintHost)
}
@Test
fun `strict v4 rejects truncation junk and trailing data`() {
val valid = validV4Token()
val truncated = valid.dropLast(12)
val junk = "cashuB" + "Q".repeat(40)
val payloadWithTrailingGarbage = decodePayload(valid) + byteArrayOf(0)
val trailing = "cashuB" + base64Url(payloadWithTrailingGarbage)
assertNull(CashuTokenDecoder.decode(truncated, strict = true))
assertNull(CashuTokenDecoder.decode(junk, strict = true))
assertNull(CashuTokenDecoder.decode(trailing, strict = true))
assertNotNull(CashuTokenDecoder.decode(junk))
}
@Test
fun `strict decoding rejects missing nonpositive fractional and overflowing amounts`() {
val values = listOf(
"""{"token":[{"proofs":[]}]}""",
"""{"token":[{"proofs":[{"amount":0}]}]}""",
"""{"token":[{"proofs":[{"amount":1.5}]}]}""",
"""{"token":[{"proofs":[{"amount":2100000000000000},{"amount":1}]}]}"""
)
values.forEach { assertNull(CashuTokenDecoder.decode(v3Token(it), strict = true)) }
}
@Test
fun `extracts URI forms as bare deduplicated tokens with a cap`() {
val first = v3Token("""{"token":[{"proofs":[{"amount":1}]}]}""")
val second = v3Token("""{"token":[{"proofs":[{"amount":2}]}]}""")
val text = "cashu:$first and cashu://$first then $second"
assertEquals(listOf(first, second), CashuTokenDecoder.extractTokens(text, max = 2))
assertEquals(first, CashuTokenDecoder.bareToken("cashu%3A$first"))
}
@Test
fun `sentence punctuation is not included in extracted token`() {
val token = v3Token("""{"token":[{"proofs":[{"amount":1}]}]}""")
assertEquals(listOf(token), CashuTokenDecoder.extractTokens("Redeem $token."))
assertNull(CashuTokenDecoder.bareToken("$token."))
}
@Test
fun `oversized and deeply nested input fails closed`() {
assertNull(CashuTokenDecoder.decode("cashuA" + "A".repeat(CashuTokenDecoder.MAX_TOKEN_LENGTH)))
var nested = byteArrayOf(0)
repeat(20) { nested = byteArrayOf(0x81.toByte()) + nested }
assertNull(CashuTokenDecoder.decode("cashuB" + base64Url(nested), strict = true))
}
private fun v3Token(json: String): String =
"cashuA" + base64Url(json.toByteArray(StandardCharsets.UTF_8))
private fun validV4Token(): String {
val proofs = listOf(1L, 4L, 16L).map { amount ->
cborMap(
"a" to cborUnsigned(amount),
"s" to cborText("secret"),
"c" to cborBytes(byteArrayOf(2, 0xab.toByte(), 0xcd.toByte()))
)
}
val payload = cborMap(
"m" to cborText("https://mint.example.com"),
"u" to cborText("sat"),
"t" to cborArray(
cborMap(
"i" to cborBytes(byteArrayOf(0, 0xad.toByte(), 0x26, 0x8c.toByte())),
"p" to cborArray(*proofs.toTypedArray())
)
)
)
return "cashuB" + base64Url(payload)
}
private fun cborUnsigned(value: Long) = cborHead(0, value)
private fun cborText(value: String) =
cborHead(3, value.toByteArray().size.toLong()) + value.toByteArray()
private fun cborBytes(value: ByteArray) = cborHead(2, value.size.toLong()) + value
private fun cborArray(vararg values: ByteArray) =
cborHead(4, values.size.toLong()) + values.fold(byteArrayOf(), ByteArray::plus)
private fun cborMap(vararg pairs: Pair<String, ByteArray>) =
cborHead(5, pairs.size.toLong()) + pairs.fold(byteArrayOf()) { bytes, pair ->
bytes + cborText(pair.first) + pair.second
}
private fun cborHead(major: Int, value: Long): ByteArray = when (value) {
in 0..23 -> byteArrayOf(((major shl 5) or value.toInt()).toByte())
in 24..255 -> byteArrayOf(((major shl 5) or 24).toByte(), value.toByte())
else -> byteArrayOf(
((major shl 5) or 25).toByte(),
(value ushr 8).toByte(),
value.toByte()
)
}
private fun decodePayload(token: String): ByteArray {
val encoded = token.substring(6)
return Base64.getUrlDecoder().decode(encoded + "=".repeat((4 - encoded.length % 4) % 4))
}
private fun base64Url(bytes: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}

View File

@ -2,6 +2,9 @@ package com.bitchat.android.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals
@ -136,4 +139,26 @@ class CommandProcessorTest() {
assertTrue(locallyRead.contains(message.id))
}
@Test
fun `pay feedback is added to active geohash channel`() {
val geohash = "u0nd"
chatState.setSelectedLocationChannel(
ChannelID.Location(GeohashChannel(GeohashChannelLevel.PROVINCE, geohash))
)
commandProcessor.processCommand(
command = "/pay invalid",
meshService = meshService,
myPeerID = "peer-id",
onSendMessage = { _, _, _ -> },
viewModel = null
)
assertEquals(
"invalid cashu token — not sending it",
chatState.getChannelMessagesValue()["geo:$geohash"]?.single()?.content
)
assertEquals(0, chatState.getMessagesValue().size)
}
}