feat(nostr): decentralized layer Phase A — identity, relays, zaps, NWC wallet

Additive Wavlake-inspired layer; zero existing features touched.

Crypto/protocol (pure-Kotlin core, fr.acinq secp256k1-kmp 0.17.3):
- Bech32 + NIP-19 (npub/nsec/nprofile/nevent/naddr)
- NIP-01 events: canonical serialization, BIP-340 schnorr sign/verify
- NIP-04 ECDH AES-CBC (raw x-coord via pubKeyTweakMul)
- Wavlake music NIP kinds 30440-30443 (Track/Album/Artist/Playlist)
- 4 conformance unit tests vs official spec vectors (all pass)

NostrService: multi-relay OkHttp websocket client, publish/subscribe/
query/getProfile/publishProfile/publishReaction/publishTrack/publishRaw,
best-effort failure posture (relays down = silent, app unaffected).

Identity: NostrKeys — AndroidKeyStore-encrypted private key at rest,
npub-only display. Verified on-device: create → npub chip → survives
force-stop + relaunch.

Payments (PaymentService abstraction, NWC provider):
- NIP-47 Nostr Wallet Connect over the pairing relay, pairing string
  keystore-encrypted, 15s timeout, never hangs playback
- LUD-06/LUD-16 lightning-address invoice resolution
- NIP-57 zaps: ephemeral-key zap request → callback nostr= → pay
- payment history records (no secrets)

UI (design-system tokens, progressive disclosure):
- ⚡ bolt on mini-player → TipSheet (21/100/500/1000/custom, recent
  addresses)
- Appearance → ⚡ Decentralized sheet: identity create/import, NWC
  connect, advanced relays/profile
This commit is contained in:
avi 2026-09-08 11:49:09 -05:00
commit 5ff5318db4
19 changed files with 2255 additions and 4 deletions

View file

@ -104,9 +104,15 @@ dependencies {
// not exist on Maven Central), which made the build fail during // not exist on Maven Central), which made the build fail during
// dependency resolution before Kotlin was ever compiled. // dependency resolution before Kotlin was ever compiled.
implementation("net.jthink:jaudiotagger:2.2.5") implementation("net.jthink:jaudiotagger:2.2.5")
// Nostr/Lightning crypto: BIP-340 schnorr + ECDH via ACINQ's secp256k1
// Kotlin-multiplatform binding (prebuilt .so for all Android ABIs).
implementation("fr.acinq.secp256k1:secp256k1-kmp:0.17.3")
implementation("fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.17.3")
// Testing // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-extract:0.17.3")
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-linux:0.17.3")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation(composeBom) androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")

View file

@ -0,0 +1,24 @@
package com.rada.di
import com.rada.payments.NwcProvider
import com.rada.payments.PaymentService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PaymentsModule {
/**
* Provider priority list. NWC first (user-connected wallet); LNURL and
* Cashu providers land in later phases and just append here — no other
* call site changes.
*/
@Provides
@Singleton
fun providePaymentService(nwc: NwcProvider): PaymentService =
PaymentService(providers = { listOf(nwc) })
}

View file

@ -0,0 +1,156 @@
package com.rada.nostr
/**
* BIP-173 bech32 + NIP-19 TLV entities (npub, nsec, nprofile, nevent, naddr).
* Pure Kotlin, no external deps.
*/
object Bech32 {
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
private fun polymod(values: IntArray): Int {
var chk = 1
for (v in values) {
val b = chk ushr 25
chk = ((chk and 0x1ffffff) shl 5) xor v
for (i in 0..4) if (((b ushr i) and 1) != 0) chk = chk xor GENERATOR[i]
}
return chk
}
private fun hrpExpand(hrp: String): IntArray {
val ret = IntArray(hrp.length * 2 + 1)
for (i in hrp.indices) ret[i] = hrp[i].code ushr 5
// ret[hrp.length] stays 0 (separator)
for (i in hrp.indices) ret[hrp.length + 1 + i] = hrp[i].code and 31
return ret
}
private fun verifyChecksum(hrp: String, data: IntArray): Boolean =
polymod(hrpExpand(hrp) + data) == 1
private fun createChecksum(hrp: String, data: IntArray): IntArray {
val values = hrpExpand(hrp) + data + IntArray(6)
val mod = polymod(values) xor 1
return IntArray(6) { (mod ushr (5 * (5 - it))) and 31 }
}
fun encode(hrp: String, data5bit: IntArray): String {
val combined = data5bit + createChecksum(hrp, data5bit)
return hrp + "1" + combined.joinToString("") { CHARSET[it].toString() }
}
/** Returns (hrp, data5bit) or null on invalid input. */
fun decode(bech: String): Pair<String, IntArray>? {
val lower = bech.lowercase()
val pos = lower.lastIndexOf('1')
if (pos < 1 || pos + 7 > lower.length) return null
val hrp = lower.substring(0, pos)
val data = IntArray(lower.length - pos - 1)
for (i in data.indices) {
val idx = CHARSET.indexOf(lower[pos + 1 + i])
if (idx == -1) return null
data[i] = idx
}
if (!verifyChecksum(hrp, data)) return null
return hrp to data.dropLast(6).toIntArray()
}
fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
var acc = 0
var bits = 0
val out = ArrayList<Int>()
val maxv = (1 shl toBits) - 1
for (b in data) {
val value = b.toInt() and 0xff
acc = (acc shl fromBits) or value
bits += fromBits
while (bits >= toBits) {
bits -= toBits
out.add((acc ushr bits) and maxv)
}
}
if (pad && bits > 0) out.add((acc shl (toBits - bits)) and maxv)
return out.toIntArray()
}
fun convertBitsBack(data5: IntArray, toBits: Int = 8): ByteArray {
var acc = 0
var bits = 0
val out = ArrayList<Byte>()
val maxv = (1 shl toBits) - 1
for (value in data5) {
acc = (acc shl 5) or value
bits += 5
if (bits >= toBits) {
bits -= toBits
out.add(((acc ushr bits) and maxv).toByte())
}
}
return out.toByteArray()
}
}
/** NIP-19 entities. */
object Nip19 {
fun npub(pubkeyHex: String): String =
Bech32.encode("npub", Bech32.convertBits(hexToBytes(pubkeyHex), 8, 5, true))
fun nsec(privkeyHex: String): String =
Bech32.encode("nsec", Bech32.convertBits(hexToBytes(privkeyHex), 8, 5, true))
/** nprofile = TLV {0: pubkey, 1: relay hints} */
fun nprofile(pubkeyHex: String, relays: List<String>): String {
val tlv = ArrayList<Byte>()
fun tag(type: Int, payload: ByteArray) {
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
}
tag(0, hexToBytes(pubkeyHex))
relays.take(3).forEach { tag(1, it.toByteArray()) }
return Bech32.encode("nprofile",
Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
}
/** nevent = TLV {1: event_id, 3: relay, [5 kind]} */
fun nevent(eventIdHex: String, relays: List<String> = emptyList(), kind: Int? = null): String {
val tlv = ArrayList<Byte>()
fun tag(type: Int, payload: ByteArray) {
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
}
tag(1, hexToBytes(eventIdHex))
relays.take(3).forEach { tag(3, it.toByteArray()) }
kind?.let { tag(5, byteArrayOf((it shr 8).toByte(), (it and 0xff).toByte())) }
return Bech32.encode("nevent", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
}
/** naddr = TLV {0: identifier(d), 1: relays, 2: pubkey, [3: kind]} — for 3044x music. */
fun naddr(dTag: String, pubkeyHex: String, kind: Int, relays: List<String> = emptyList()): String {
val tlv = ArrayList<Byte>()
fun tag(type: Int, payload: ByteArray) {
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
}
tag(0, dTag.toByteArray())
relays.take(3).forEach { tag(3, it.toByteArray()) }
tag(2, hexToBytes(pubkeyHex))
tag(3, byteArrayOf((kind shr 8).toByte(), (kind and 0xff).toByte()))
return Bech32.encode("naddr", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
}
fun decode(bech: String): Pair<String, ByteArray>? {
val (hrp, data5) = Bech32.decode(bech) ?: return null
return hrp to Bech32.convertBitsBack(data5)
}
}
fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim().removePrefix("0x")
require(clean.length % 2 == 0 && clean.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
"invalid hex"
}
return ByteArray(clean.length / 2) {
((Character.digit(clean[it * 2], 16) shl 4) + Character.digit(clean[it * 2 + 1], 16)).toByte()
}
}

View file

@ -0,0 +1,48 @@
package com.rada.nostr
import fr.acinq.secp256k1.Secp256k1
import java.util.Base64 as JBase64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* NIP-04: ECDH shared secret + AES-256-CBC for encrypted DMs and the NWC
* (NIP-47) JSON-RPC envelope. Uses the app-held key via [keys].
*/
object Nip04 {
/**
* NIP-04 shared secret = raw x-coordinate of priv*otherPub, NOT hashed.
* (ACINQ's ecdh() applies libsecp256k1's default sha256 hashfp, which is
* *not* what Nostr wants — pubKeyTweakMul gives the serialized point.)
*/
private fun ecdh(priv: ByteArray, pubkeyHex: String): ByteArray {
val compressed = byteArrayOf(0x02) + hexToBytes(pubkeyHex)
val point = Secp256k1.get().pubKeyTweakMul(compressed, priv)
return point.copyOfRange(1, 33) // x-coordinate, parity-independent
}
fun encrypt(priv: ByteArray, recipientPubkeyHex: String, plaintext: String): String {
val shared = ecdh(priv, recipientPubkeyHex)
val iv = ByteArray(16).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(shared, "AES"), IvParameterSpec(iv))
val ct = cipher.doFinal(plaintext.toByteArray())
return JBase64.getEncoder().encodeToString(ct) + "?iv=" +
JBase64.getEncoder().encodeToString(iv)
}
fun decrypt(priv: ByteArray, senderPubkeyHex: String, payload: String): String? = runCatching {
val (ctB64, ivB64) = payload.split("?iv=", limit = 2)
val shared = ecdh(priv, senderPubkeyHex)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(shared, "AES"),
IvParameterSpec(JBase64.getDecoder().decode(ivB64)),
)
String(cipher.doFinal(JBase64.getDecoder().decode(ctB64)))
}.getOrNull()
}

View file

@ -0,0 +1,143 @@
package com.rada.nostr
import fr.acinq.secp256k1.Secp256k1
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.security.MessageDigest
/**
* NIP-01 event. Minimal, immutable, serialization exactly per spec so the
* event id (SHA-256 of [serialized]) verifies on any relay/client.
*/
data class NostrEvent(
val id: String,
val pubkey: String,
val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
val sig: String,
) {
/** Canonical NIP-01 serialization: [0,pubkey,created_at,kind,tags,content]. */
fun serialized(): String {
val obj = buildJsonArray {
add(JsonPrimitive(0))
add(JsonPrimitive(pubkey))
add(JsonPrimitive(createdAt))
add(JsonPrimitive(kind))
add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
add(JsonPrimitive(content))
}
return CANONICAL.encodeToString(JsonElement.serializer(), obj)
}
fun toJson(): String {
val obj = buildJsonObject {
put("id", id)
put("pubkey", pubkey)
put("created_at", createdAt)
put("kind", kind)
put("tags", buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
put("content", content)
put("sig", sig)
}
return CANONICAL.encodeToString(JsonElement.serializer(), obj)
}
fun tag(name: String): String? = tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1)
fun tagValues(name: String): List<String> = tags.filter { it.firstOrNull() == name }.mapNotNull { it.getOrNull(1) }
/** Recompute id and verify schnorr sig. Never trust unverified relay data. */
fun verify(): Boolean = runCatching {
val expectedId = sha256Hex(serialized().toByteArray())
if (expectedId != id) return false
Secp256k1.get().verifySchnorr(
hexToBytes(sig),
hexToBytes(id),
hexToBytes(pubkey),
)
}.getOrDefault(false)
companion object {
internal val CANONICAL = Json { prettyPrint = false; encodeDefaults = true }
fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
fun unsigned(
pubkey: String,
kind: Int,
content: String,
tags: List<List<String>>,
createdAt: Long = System.currentTimeMillis() / 1000,
): UnsignedEvent = UnsignedEvent(pubkey, createdAt, kind, tags, content)
fun fromJson(raw: String): NostrEvent? = runCatching {
val el = CANONICAL.parseToJsonElement(raw)
val o = el as? kotlinx.serialization.json.JsonObject ?: return null
NostrEvent(
id = o["id"]?.jsonPrimitive?.content ?: return null,
pubkey = o["pubkey"]?.jsonPrimitive?.content ?: return null,
createdAt = o["created_at"]?.jsonPrimitive?.content?.toLongOrNull() ?: return null,
kind = o["kind"]?.jsonPrimitive?.content?.toIntOrNull() ?: return null,
tags = (o["tags"] as? JsonArray)?.map { a ->
a.jsonArray.map { it.jsonPrimitive.content }
} ?: emptyList(),
content = (o["content"] as? JsonPrimitive)?.content ?: "",
sig = o["sig"]?.jsonPrimitive?.content ?: return null,
)
}.getOrNull()
}
}
data class UnsignedEvent(
val pubkey: String,
val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
) {
fun serialized(): String {
val obj = buildJsonArray {
add(JsonPrimitive(0))
add(JsonPrimitive(pubkey))
add(JsonPrimitive(createdAt))
add(JsonPrimitive(kind))
add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
add(JsonPrimitive(content))
}
return NostrEvent.CANONICAL.encodeToString(JsonElement.serializer(), obj)
}
/** Sign with the app-held key (signer callback keeps key ownership in NostrKeys). */
fun sign(signer: (ByteArray) -> String?): NostrEvent? {
val digest = MessageDigest.getInstance("SHA-256").digest(serialized().toByteArray())
val sig = signer(digest) ?: return null
val id = digest.toHex()
return NostrEvent(id, pubkey, createdAt, kind, tags, content, sig)
}
}
/** Music kinds from the Wavlake music NIPs (parameterized replaceable, NIP-33). */
object MusicKinds {
const val TRACK = 30440
const val ALBUM = 30441
const val ARTIST = 30442
const val PLAYLIST = 30443
const val METADATA = 0
const val TEXT_NOTE = 1
const val FOLLOW_LIST = 3
const val DELETION = 5
const val REACTION = 7
const val ZAP_REQUEST = 9734
const val ZAP_RECEIPT = 9735
}

View file

@ -0,0 +1,154 @@
package com.rada.nostr
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import fr.acinq.secp256k1.Secp256k1
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
/**
* Optional Nostr identity for the device (NIP-01 keys).
*
* Security posture:
* - Private key is encrypted with an AndroidKeyStore AES-GCM key that is
* non-exportable; only the ciphertext blob ever leaves the Keystore.
* - The plaintext key exists in memory only while signing an event.
* - Nostr is entirely optional: without an identity every other app
* feature works unchanged, and nothing here ever blocks playback.
*/
@Singleton
class NostrKeys @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val alias = "satsamp_nostr_key"
private val prefs = context.getSharedPreferences("nostr_identity", Context.MODE_PRIVATE)
private val _pubkey = MutableStateFlow<String?>(null)
/** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */
val pubkey: StateFlow<String?> = _pubkey.asStateFlow()
val hasIdentity: Boolean get() = prefs.contains("ct")
init {
if (hasIdentity) {
// Derive pubkey once at startup (needs the key; decrypt, derive, wipe).
runCatching {
val priv = decryptSecret()
if (priv != null) _pubkey.value = xOnlyPubkey(priv)
}
}
}
/** Create a fresh identity. Returns the npub. Refuses if one exists (no silent overwrite). */
fun createIdentity(): String {
check(!hasIdentity) { "identity already exists" }
val rnd = java.security.SecureRandom()
var key = ByteArray(32)
do { rnd.nextBytes(key) } while (!Secp256k1.get().secKeyVerify(key))
val pub = xOnlyPubkey(key)
encryptAndStore(key)
_pubkey.value = pub
return Nip19.npub(pub)
}
/** Import an nsec (paste flow). Stored encrypted; the plaintext is wiped after use. */
fun importNsec(nsec: String): Boolean {
val (hrp, bytes) = Nip19.decode(nsec.trim()) ?: return false
if (hrp != "nsec" || bytes.size != 32) return false
if (!Secp256k1.get().secKeyVerify(bytes)) return false
encryptAndStore(bytes)
return true
}
fun npub(): String? = _pubkey.value?.let { Nip19.npub(it) }
/**
* Sign `digest` (32-byte SHA-256 of serialized event) with the stored key.
* Returns 64-byte BIP-340 schnorr sig hex, or null when no identity.
*/
fun signDigest(digest: ByteArray): String? {
val priv = decryptSecret() ?: return null
try {
return Secp256k1.get().signSchnorr(digest, priv, null).toHex()
} finally {
priv.fill(0)
}
}
/** NIP-47/NIP-44 style ECDH needs the full priv transiently too. */
fun <T> withPrivateKey(block: (ByteArray) -> T): T? {
val priv = decryptSecret() ?: return null
try { return block(priv) } finally { priv.fill(0) }
}
/** Erase the identity (ciphertext + keystore key). */
fun clearIdentity() {
prefs.edit().clear().apply()
runCatching {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
.deleteEntry(alias)
}
_pubkey.value = null
}
// ── Keystore plumbing ────────────────────────────────────────────────
private fun keystoreKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey(alias, null) as? SecretKey)?.let { return it }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return gen.generateKey()
}
private fun encryptAndStore(priv: ByteArray) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, keystoreKey())
val ct = cipher.doFinal(priv)
prefs.edit()
.putString("ct", Base64.encodeToString(ct, Base64.NO_WRAP))
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
priv.fill(0)
}
private fun decryptSecret(): ByteArray? {
val ct = prefs.getString("ct", null) ?: return null
val iv = prefs.getString("iv", null) ?: return null
return runCatching {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
keystoreKey(),
GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)),
)
cipher.doFinal(Base64.decode(ct, Base64.NO_WRAP))
}.getOrNull()
}
companion object {
/** compressed pubkey (33B) minus the 02/03 prefix = x-only (32B), NIP-01 style. */
fun xOnlyPubkey(priv: ByteArray): String {
val compressed = Secp256k1.get().pubkeyCreate(priv)
return compressed.copyOfRange(1, 33).toHex()
}
}
}

View file

@ -0,0 +1,225 @@
package com.rada.nostr
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.json.JSONArray
import org.json.JSONObject
data class RelayInfo(val url: String, val connected: Boolean)
/**
* The single Nostr entry point (publish/subscribe/query/profile/music).
* UI must never talk to RelayConnection directly.
*
* Failure posture: relays are best-effort. If every relay is down, flows
* simply never emit; nothing else in the app notices or degrades.
*/
@Singleton
class NostrService @Inject constructor(
@ApplicationContext context: Context,
private val keys: NostrKeys,
) {
private val prefs = context.getSharedPreferences("nostr_settings", Context.MODE_PRIVATE)
private val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // websockets: no read timeout
.build()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val defaultRelays = listOf(
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
)
private val relayUrls: List<String>
get() {
val saved = prefs.getString("relays", null)
?.split("\n")?.filter { it.isNotBlank() }
return if (saved.isNullOrEmpty()) defaultRelays else saved
}
private val relays = mutableMapOf<String, RelayConnection>()
private val _relayStates = MutableStateFlow<List<RelayInfo>>(emptyList())
val relayStates: StateFlow<List<RelayInfo>> = _relayStates.asStateFlow()
/** Merged verified events from all relays (deduped by id downstream). */
private val merged = kotlinx.coroutines.flow.MutableSharedFlow<NostrEvent>(
extraBufferCapacity = 512,
)
@Volatile private var started = false
/** Call once when the user enables Nostr; safe to call repeatedly. */
fun ensureConnected() {
if (started) return
synchronized(this) {
if (started) return
started = true
relayUrls.forEach { url ->
val conn = RelayConnection(url, client)
relays[url] = conn
scope.launch {
conn.state.collect { st ->
_relayStates.value = relays.map { (u, c) ->
RelayInfo(u, st is RelayConnection.State.Connected)
}
}
}
scope.launch { conn.events.collect { merged.emit(it) } }
conn.connect()
}
_relayStates.value = relays.map { RelayInfo(it.key, false) }
}
}
val events get() = merged as kotlinx.coroutines.flow.SharedFlow<NostrEvent>
val enabled: Boolean
get() = prefs.getBoolean("enabled", false) && keys.hasIdentity
fun setEnabled(on: Boolean) {
prefs.edit().putBoolean("enabled", on).apply()
if (on) ensureConnected()
else {
relays.values.forEach { it.close() }
started = false
}
}
fun relaysConfig(): List<String> = relayUrls
fun setRelays(list: List<String>) {
prefs.edit().putString("relays", list.filter { it.isNotBlank() }.joinToString("\n")).apply()
relays.values.forEach { it.close() }
relays.clear()
started = false
if (enabled) ensureConnected()
}
// ── publish ───────────────────────────────────────────────────────────
/** Publish a pre-signed event (zap requests use ephemeral keys). */
fun publishRaw(event: NostrEvent) {
ensureConnected()
relays.values.forEach { it.publish(event) }
}
/** Await the lnurl server's zap receipt (kind 9735) referencing [requestId]. */
suspend fun waitForZapReceipt(requestId: String, targetPubkey: String, timeoutMs: Long) {
ensureConnected()
runCatching {
kotlinx.coroutines.withTimeout(timeoutMs) {
events.first {
it.kind == MusicKinds.ZAP_RECEIPT &&
it.tagValues("e").contains(requestId) &&
it.pubkey == targetPubkey
}
}
}
}
fun publish(kind: Int, content: String, tags: List<List<String>>): NostrEvent? {
val ev = NostrEvent.unsigned(
pubkey = keys.pubkey.value ?: return null,
kind = kind, content = content, tags = tags,
).sign { digest -> keys.signDigest(digest) } ?: return null
relays.values.forEach { it.publish(ev) }
return ev
}
/** Kind 0 metadata (NIP-01 profile). */
fun publishProfile(name: String, about: String, pictureUrl: String): NostrEvent? {
val content = JSONObject().apply {
put("name", name)
put("about", about)
if (pictureUrl.isNotBlank()) put("picture", pictureUrl)
}.toString()
return publish(MusicKinds.METADATA, content, emptyList())
}
/** Kind 7 reaction ("+") to an event, optional lightning zap request. */
fun publishReaction(target: NostrEvent, content: String = "+"): NostrEvent? =
publish(
MusicKinds.REACTION, content,
listOf(
listOf("e", target.id),
listOf("p", target.pubkey),
),
)
// ── music metadata (Wavlake music NIPs 30440-30443) ──────────────────
/** Publish a track as kind 30440 with a NOM-flavored JSON payload. */
fun publishTrack(
dTag: String,
title: String,
creator: String,
enclosureUrl: String,
mimeType: String = "audio/mpeg",
durationSec: Long = 0,
): NostrEvent? {
val content = JSONObject().apply {
put("title", title)
put("guid", dTag)
put("creator", creator)
put("type", mimeType)
put("duration", durationSec)
put("published_at", (System.currentTimeMillis() / 1000).toString())
put("enclosure", enclosureUrl)
put("version", "0.1")
}.toString()
return publish(MusicKinds.TRACK, content, listOf(listOf("d", dTag)))
}
// ── query (one-shot-ish) ──────────────────────────────────────────────
/** Subscribe with [filter]; collected events (verified) flow into [onEvent]. */
fun query(filter: JSONObject, onEvent: (NostrEvent) -> Unit): String {
ensureConnected()
val ids = relays.values.map { it.subscribe(filter) }
scope.launch {
val seen = HashSet<String>()
events.collect { ev ->
if (seen.add(ev.id)) onEvent(ev)
}
}
return ids.firstOrNull() ?: ""
}
/** Fetch a profile (kind 0) for [pubkeyHex]; emits once if any relay has it. */
fun getProfile(pubkeyHex: String, timeoutMs: Long = 6000): kotlinx.coroutines.flow.Flow<NostrEvent?> {
val out = kotlinx.coroutines.flow.MutableStateFlow<NostrEvent?>(null)
ensureConnected()
val filter = JSONObject().apply {
put("kinds", JSONArray().put(MusicKinds.METADATA))
put("authors", JSONArray().put(pubkeyHex))
put("limit", 1)
}
val job = scope.launch {
events.first { it.kind == MusicKinds.METADATA && it.pubkey == pubkeyHex }
.let { out.value = it }
}
scope.launch {
kotlinx.coroutines.delay(timeoutMs)
job.cancel()
}
return out
}
}

View file

@ -0,0 +1,137 @@
package com.rada.nostr
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
/**
* Minimal NIP-01 relay client over OkHttp WebSocket — no new dependencies.
* Design guarantees for the app:
* - Every method is safe to call with relays down: failures surface as
* closed flows / nulls, never exceptions into the UI, never blocking
* playback.
* - Events arriving from relays are [NostrEvent.verify]-checked before
* they are emitted; untrusted relay data can't inject fakes.
*/
class RelayConnection(
val url: String,
private val client: OkHttpClient,
) {
sealed interface State {
data object Connecting : State
data object Connected : State
data class Disconnected(val reason: String?) : State
}
private val _state = MutableSharedFlow<State>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val state: SharedFlow<State> = _state.asSharedFlow()
private val _events = MutableSharedFlow<NostrEvent>(
replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Verified events matching any subscription on this relay. */
val events: SharedFlow<NostrEvent> = _events.asSharedFlow()
/** reqId -> original filter JSON, so we can CLOSE. */
private val subs = ConcurrentHashMap<String, JSONArray>()
private val reqCounter = AtomicLong(0)
@Volatile private var socket: WebSocket? = null
private val pendingOut = ArrayDeque<String>()
val isConnected: Boolean get() = socket != null
fun connect() {
if (socket != null) return
_state.tryEmit(State.Connecting)
runCatching {
socket = client.newWebSocket(
Request.Builder().url(url).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_state.tryEmit(State.Connected)
// Re-send subscriptions and queued events after (re)connect.
subs.values.forEach { w -> runCatching { webSocket.send(w.toString()) } }
while (pendingOut.isNotEmpty()) {
webSocket.send(pendingOut.removeFirst())
}
}
override fun onMessage(webSocket: WebSocket, text: String) = handleFrame(text)
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
socket = null
_state.tryEmit(State.Disconnected(t.message))
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
socket = null
_state.tryEmit(State.Disconnected(reason))
}
},
)
}.onFailure {
_state.tryEmit(State.Disconnected(it.message))
}
}
private fun handleFrame(text: String) {
runCatching {
val arr = JSONArray(text)
when (arr.optString(0)) {
"EVENT" -> {
val ev = NostrEvent.fromJson(arr.optString(2))
// Trust boundary: id + schnorr signature must verify.
if (ev != null && ev.verify()) _events.tryEmit(ev)
}
// ["OK", evt_id, ok|fail, msg] and ["EOSE", sub_id] are handled
// by callers that need them via their own flow when extended;
// for now presence is enough.
}
}
}
/** NIP-01 REQ. Returns subscription id; results arrive on [events]. */
fun subscribe(filter: JSONObject): String {
val id = "s${reqCounter.incrementAndGet()}"
// NIP-01: ["REQ", sub_id, filter]
subs[id] = JSONArray().put("REQ").put(id).put(filter)
val ws = socket
if (ws != null) runCatching { ws.send(subs[id].toString()) }
return id
}
fun unsubscribe(id: String) {
subs.remove(id)
runCatching {
socket?.send(JSONObject().apply { put("CLOSE", id) }.toString())
}
}
fun publish(event: NostrEvent) {
val msg = JSONArray().apply {
put("EVENT")
put(JSONObject(event.toJson()))
}.toString()
val ws = socket
if (ws != null && ws.send(msg)) return
// Queue for the next successful connect; bounded.
if (pendingOut.size < 32) pendingOut.addLast(msg)
}
fun close() {
subs.clear()
runCatching { socket?.close(1000, null) }
socket = null
}
}

View file

@ -0,0 +1,286 @@
package com.rada.payments
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.rada.nostr.Nip04
import com.rada.nostr.NostrEvent
import com.rada.nostr.NostrKeys
import com.rada.nostr.NostrService
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.KeyStore
import java.util.Base64 as JBase64
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
/**
* Nostr Wallet Connect (NIP-47) provider: the app talks to the user's
* wallet *through their relay* using encrypted kind-13194 info events and
* kind-13195/23194/23195 request/response DMs. The app never holds wallet
* keys; it holds only the pairing connection string (encrypted at rest).
*
* Connection string format (from Alby Hub / coinos etc.):
* nostr+walletconnect://<walletPubkey>?relay=<wss://..>&secret=<hex>
*/
@Singleton
class NwcProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val nostr: NostrService,
private val keys: NostrKeys,
) : PaymentProvider {
override val id = "nwc"
private val prefs = context.getSharedPreferences("nwc", Context.MODE_PRIVATE)
private var walletPubkey: String? = null
private var relayUrl: String? = null
private var secretHex: String? = null
val connected: Boolean get() = parseStored() != null
override val isConfigured: Boolean get() = connected
data class Pairing(val walletPubkey: String, val relayUrl: String, val secretHex: String)
/** Parse + validate a pairing string. Stores it; returns false if malformed. */
fun connect(pairingUri: String): Boolean {
val parsed = parse(pairingUri.trim()) ?: return false
// store encrypted (it contains a secret key!)
storeSecret(pairingUri.trim())
walletPubkey = parsed.walletPubkey
relayUrl = parsed.relayUrl
secretHex = parsed.secretHex
return true
}
fun disconnect() {
prefs.edit().clear().apply()
walletPubkey = null; relayUrl = null; secretHex = null
}
private fun parse(uri: String): Pairing? = runCatching {
require(uri.startsWith("nostr+walletconnect://")) { "not an NWC uri" }
val rest = uri.removePrefix("nostr+walletconnect://")
val pubkey = rest.substringBefore("?")
require(pubkey.length == 64 && pubkey.all { it in '0'..'9' || it in 'a'..'f' }) { "bad pubkey" }
val qs = rest.substringAfter("?", "")
val params = qs.split("&").mapNotNull {
val k = it.substringBefore("="); val v = it.substringAfter("=", "")
k to java.net.URLDecoder.decode(v, "UTF-8")
}.toMap()
val relay = params["relay"] ?: return null
val secret = params["secret"] ?: return null
require(secret.length == 64) { "bad secret" }
Pairing(pubkey, relay, secret)
}.getOrNull()
private fun parseStored(): Pairing? {
if (walletPubkey != null) return Pairing(walletPubkey!!, relayUrl!!, secretHex!!)
val ct = prefs.getString("pairing_ct", null) ?: return null
val iv = prefs.getString("pairing_iv", null) ?: return null
val uri = decrypt(ct, iv) ?: return null
val p = parse(uri) ?: return null
walletPubkey = p.walletPubkey; relayUrl = p.relayUrl; secretHex = p.secretHex
return p
}
// ── provider API ──────────────────────────────────────────────────────
override suspend fun payLightningAddress(
address: String,
sats: Long,
memo: String?,
): PaymentResult {
// NIP-47 pay_invoice needs a bolt11; a lightning *address* first
// resolves via LUD-06 to an invoice.
val identifier = if (address.contains("@")) {
resolveLightningAddress(address, sats, memo)
?: return PaymentResult.Failed("lnurl failed", "Couldn't reach that Lightning address.")
} else address
return nwcRequest("pay_invoice", JSONObject().apply {
put("invoice", identifier)
})
}
override suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult =
nwcRequest("pay_invoice", JSONObject().apply { put("invoice", bolt11) })
override suspend fun createInvoice(sats: Long, memo: String?): PaymentResult =
nwcRequest("make_invoice", JSONObject().apply {
put("amount", sats * 1000) // msats
memo?.let { put("description", it) }
})
/** NIP-06/LUD-16: lightning address -> {callback}?amount= -> bolt11 */
private suspend fun resolveLightningAddress(
address: String,
sats: Long,
memo: String?,
): String? = withContext(Dispatchers.IO) {
runCatching {
val (name, domain) = address.split("@", limit = 2)
val https = "https://$domain/.well-known/lnurlp/$name"
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
val lnl = client.newCall(okhttp3.Request.Builder().url(https).build())
.execute().use { r ->
if (!r.isSuccessful) return@runCatching null
JSONObject(r.body?.string() ?: return@runCatching null)
}
if (lnl.optString("tag") != "payRequest") return@runCatching null
val min = lnl.optLong("minSendable", 1000L) // msats
val max = lnl.optLong("maxSendable", Long.MAX_VALUE)
val msats = (sats * 1000).coerceIn(min, max)
val cb = lnl.getString("callback")
val url = "$cb${if (cb.contains("?")) "&" else "?"}amount=$msats" +
(memo?.let { "&comment=${java.net.URLEncoder.encode(it, "UTF-8")}" } ?: "")
val resp = client.newCall(okhttp3.Request.Builder().url(url).build())
.execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
if (resp.has("error")) {
return@runCatching null
}
resp.optString("pr").takeIf { it.isNotBlank() }
}.getOrNull()
}
/**
* Send a NIP-47 request event, await the wallet's encrypted response.
* Timeout-safe: returns Failed, never hangs the caller.
*/
private suspend fun nwcRequest(method: String, params: JSONObject): PaymentResult {
val pairing = parseStored() ?: return PaymentResult.Failed(
"not connected", "Connect your wallet in Settings → Decentralized.",
)
val ourPub = keys.pubkey.value ?: return PaymentResult.Failed(
"no identity", "Create a Nostr identity first (Settings → Decentralized → Nostr).",
)
val deferred = CompletableDeferred<JSONObject>()
// Subscribe to the wallet's response kind for our pubkey (NIP-47: 23195).
val filter = JSONObject().apply {
put("kinds", org.json.JSONArray().put(23195))
put("authors", org.json.JSONArray().put(pairing.walletPubkey))
put("#p", org.json.JSONArray().put(ourPub))
put("limit", 5)
}
val requestId = java.util.UUID.randomUUID().toString()
val payload = JSONObject().apply {
put("method", method)
put("params", params)
}
val enc = keys.withPrivateKey { priv ->
Nip04.encrypt(priv, pairing.walletPubkey, payload.toString())
} ?: return PaymentResult.Failed("crypto", "Couldn't encrypt the request.")
// Publish request (kind 23194) to the NWC relay AND app relays.
val ev = NostrEvent.unsigned(
pubkey = ourPub, kind = 23194, content = enc,
tags = listOf(listOf("p", pairing.walletPubkey)),
).sign { d -> keys.signDigest(d) }
?: return PaymentResult.Failed("signing", "Couldn't sign the request.")
// A dedicated relay connection to the NWC relay from the pairing.
val nwcRelay = com.rada.nostr.RelayConnection(
pairing.relayUrl,
okhttp3.OkHttpClient.Builder()
.pingInterval(20, java.util.concurrent.TimeUnit.SECONDS).build(),
)
return try {
nwcRelay.connect()
// Listen for the response on the NWC relay only.
val listen = CoroutineScope(Dispatchers.IO + SupervisorJob())
val listenJob = listen.launch {
nwcRelay.events.collect { e ->
if (e.kind == 23195 && !deferred.isCompleted) {
val plain = keys.withPrivateKey { priv ->
Nip04.decrypt(priv, pairing.walletPubkey, e.content)
} ?: return@collect
runCatching { JSONObject(plain) }
.onSuccess { deferred.complete(it) }
}
}
}
nwcRelay.publish(ev)
val result = withTimeoutOrNull(15_000) { deferred.await() }
listenJob.cancel()
result?.let { json ->
when {
json.has("error") -> PaymentResult.Failed(
json.optString("error"),
"Wallet declined: ${json.optString("error_message", json.optString("error"))}",
)
json.has("result_type") -> {
val res = json.optJSONObject("result")
when (method) {
"make_invoice" -> PaymentResult.InvoiceCreated(
res?.optString("invoice") ?: "", pairing.walletPubkey,
)
"pay_invoice" -> PaymentResult.Paid
"get_info" -> PaymentResult.Paid
else -> PaymentResult.Paid
}
}
else -> PaymentResult.Failed("bad response", "Unexpected wallet response.")
}
} ?: PaymentResult.Failed("timeout", "Wallet didn't respond in time.")
} finally {
nwcRelay.close()
}
}
/** Wallet info (NIP-47 get_info) — names the connected wallet. */
suspend fun getInfo(): String? {
val r = nwcRequest("get_info", JSONObject())
return if (r is PaymentResult.Paid) "connected" else null
}
// ── keystore at-rest protection for the pairing secret ───────────────
private fun ksKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey("satsamp_nwc", null) as? SecretKey)?.let { return it }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder("satsamp_nwc",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
return gen.generateKey()
}
private fun storeSecret(uri: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, ksKey())
prefs.edit()
.putString("pairing_ct", Base64.encodeToString(cipher.doFinal(uri.toByteArray()), Base64.NO_WRAP))
.putString("pairing_iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
}
private fun decrypt(ctB64: String, ivB64: String): String? = runCatching {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE, ksKey(),
GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
)
String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)))
}.getOrNull()
}

View file

@ -0,0 +1,112 @@
package com.rada.payments
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Payment abstraction — the rest of the app only ever talks to
* [PaymentService]; provider specifics (NWC, LNURL, Lightning Address,
* Cashu later) stay behind [PaymentProvider].
*
* Guarantees the app relies on:
* - No payment failure ever propagates into playback code.
* - Nothing auto-spends: pay() is only invoked from explicit UI actions
* or bounded value-for-value logic with user-set caps.
*/
sealed interface PaymentResult {
data class InvoiceCreated(val bolt11: String, val lightningAddress: String?) : PaymentResult
data object Paid : PaymentResult
data class Failed(val reason: String, val userFacing: String) : PaymentResult
}
interface PaymentProvider {
val id: String
val isConfigured: Boolean
/** Pay [sats] to a lightning address / lnurl (LUD-06/LUD-07/LUD-16 flow). */
suspend fun payLightningAddress(address: String, sats: Long, memo: String?): PaymentResult
/** Create an invoice to ourselves (NWC make_invoice) — for receiving. */
suspend fun createInvoice(sats: Long, memo: String?): PaymentResult
/** Pay a bolt11 invoice directly (zap flow). */
suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult
}
/**
* Central payment service. Providers are consulted in priority order;
* when none is configured every method fails fast with a clear reason
* and the UI renders the plain "⚡ Support" flow as connect-prompt.
*/
class PaymentService(
private val providers: () -> List<PaymentProvider>,
) {
private val _history = MutableStateFlow<List<PaymentRecord>>(emptyList())
val history: StateFlow<List<PaymentRecord>> = _history.asStateFlow()
val hasAnyWallet: Boolean get() = providers().any { it.isConfigured }
suspend fun tipArtist(
lightningAddress: String?,
sats: Long,
artistName: String,
trackTitle: String? = null,
): PaymentResult {
if (sats <= 0) return PaymentResult.Failed("invalid amount", "Enter an amount first.")
if (lightningAddress.isNullOrBlank()) {
return PaymentResult.Failed("no address", "$artistName has no Lightning address set.")
}
val provider = providers().firstOrNull { it.isConfigured }
?: return PaymentResult.Failed(
"no wallet",
"Connect a wallet first (Settings → Decentralized → Bitcoin).",
)
val memo = listOfNotNull(trackTitle?.takeIf { it.isNotBlank() }, "via SatsAmp")
.joinToString(" · ")
val result = runCatching { provider.payLightningAddress(lightningAddress, sats, memo) }
.getOrElse {
PaymentResult.Failed("provider error: ${it.message}", "Payment failed — try again.")
}
_history.value = listOf(
PaymentRecord(
timestampMs = System.currentTimeMillis(),
artist = artistName,
track = trackTitle,
sats = sats,
method = provider.id,
success = result is PaymentResult.Paid || result is PaymentResult.InvoiceCreated,
detail = when (result) {
is PaymentResult.Failed -> result.reason
is PaymentResult.InvoiceCreated -> result.bolt11.take(24)
else -> null
},
),
) + _history.value
return result
}
suspend fun payInvoiceDirect(bolt11: String, sats: Long): PaymentResult {
val provider = providers().firstOrNull { it.isConfigured }
?: return PaymentResult.Failed("no wallet", "No wallet connected.")
return runCatching { provider.payInvoice(bolt11, sats) }
.getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Payment failed.") }
}
suspend fun createInvoice(sats: Long, memo: String?): PaymentResult {
val provider = providers().firstOrNull { it.isConfigured }
?: return PaymentResult.Failed("no wallet", "No wallet connected.")
return runCatching { provider.createInvoice(sats, memo) }
.getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Invoice failed.") }
}
}
data class PaymentRecord(
val timestampMs: Long,
val artist: String,
val track: String?,
val sats: Long,
val method: String,
val success: Boolean,
val detail: String?,
)

View file

@ -0,0 +1,169 @@
package com.rada.payments
import android.util.Base64
import com.rada.nostr.MusicKinds
import com.rada.nostr.NostrEvent
import com.rada.nostr.NostrKeys
import com.rada.nostr.NostrService
import com.rada.nostr.hexToBytes
import com.rada.nostr.toHex
import fr.acinq.secp256k1.Secp256k1
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.json.JSONArray
import org.json.JSONObject
/**
* NIP-57 zaps, end to end:
* target npub -> kind-0 profile -> lud16 -> LUD-06 lnurl ->
* zap request (kind 9734, signed by an ephemeral key per NIP-57) ->
* lnurl callback with `nostr=` -> bolt11 -> paid via [PaymentService]
* (NWC today; any provider later).
*
* Every failure is reported as ZapOutcome.Failed with a human line; the
* caller must treat zaps as strictly optional garnish on playback.
*/
@Singleton
private object StopCollect : Exception()
class ZapService @Inject constructor(
private val nostr: NostrService,
private val keys: NostrKeys,
private val paymentService: PaymentService,
) {
sealed interface ZapOutcome {
data object Sent : ZapOutcome
data class Failed(val reason: String) : ZapOutcome
}
private val http = OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
suspend fun zapRecipient(
targetNpubHex: String,
sats: Long,
comment: String = "",
eventId: String? = null,
): ZapOutcome {
if (sats <= 0) return ZapOutcome.Failed("Enter an amount first.")
val relays = nostr.relaysConfig()
// 1. Target profile -> lud16 (lightning address).
val lud16: String? = withContext(Dispatchers.IO) {
var found: String? = null
try {
kotlinx.coroutines.withTimeout(6_000) {
nostr.getProfile(targetNpubHex).collect { ev ->
if (ev != null && found == null) {
found = JSONObject(ev.content).optString("lud16")
.takeIf { it.isNotBlank() }
if (found != null) throw StopCollect
}
}
}
} catch (e: StopCollect) { /* found set */ }
catch (e: kotlinx.coroutines.TimeoutCancellationException) { /* none */ }
found
} ?: return ZapOutcome.Failed("No Lightning address (lud16) on that profile.")
val address: String = lud16 ?: return ZapOutcome.Failed("No Lightning address set.")
return zapLightningAddress(address, sats, comment, targetNpubHex, eventId, relays)
}
suspend fun zapLightningAddress(
lud16: String,
sats: Long,
comment: String,
targetNpubHex: String,
eventId: String?,
relays: List<String>,
): ZapOutcome = withContext(Dispatchers.IO) {
// 2. LUD-06: https://domain/.well-known/lnurlp/name
val lnl = runCatching {
val (name, domain) = lud16.split("@", limit = 2)
http.newCall(
okhttp3.Request.Builder()
.url("https://$domain/.well-known/lnurlp/$name").build(),
).execute().use { r ->
if (!r.isSuccessful) return@runCatching null
JSONObject(r.body?.string() ?: return@runCatching null)
}
}.getOrNull() ?: return@withContext ZapOutcome.Failed("Couldn't reach the Lightning endpoint.")
if (lnl.optString("tag") != "payRequest")
return@withContext ZapOutcome.Failed("Endpoint isn't a pay request.")
val min = lnl.optLong("minSendable", 1000L) / 1000
val max = lnl.optLong("maxSendable", Long.MAX_VALUE) / 1000
if (sats < min || sats > max)
return@withContext ZapOutcome.Failed("Amount must be between $min and $max sats.")
// 3. Zap request event (NIP-57): signed by an EPHEMERAL key, not the
// user's identity key; `relays` tag points at our relays so the
// lnurl server can fetch it and verify.
val ephemeral = ByteArray(32).also { SecureRandom().nextBytes(it) }
.also { if (!Secp256k1.get().secKeyVerify(it)) return@withContext ZapOutcome.Failed("rng busy, retry") }
val ephPub = Secp256k1.get().pubkeyCreate(ephemeral).copyOfRange(1, 33).toHex()
val tags = mutableListOf(
listOf("p", targetNpubHex),
listOf("relays", *relays.take(3).toTypedArray()),
listOf("amount", (sats * 1000).toString()),
)
if (eventId != null) tags.add(listOf("e", eventId))
val zapReq = NostrEvent.unsigned(
pubkey = ephPub, kind = MusicKinds.ZAP_REQUEST, content = comment, tags = tags,
).sign { digest -> Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex() }
?: return@withContext ZapOutcome.Failed("Couldn't build the zap request.")
// Publish the zap request so the lnurl server can fetch it.
nostr.ensureConnected()
// (NostrService.publish signs with the *identity* key — for zap
// requests we must publish the pre-signed ephemeral event directly.)
nostr.publishRaw(zapReq)
// 4. Callback with nostr= param -> invoice.
val allowsNostr = lnl.optBoolean("allowsNostr", false)
if (!allowsNostr && comment.isNotEmpty()) {
// still payable without comment? proceed without nostr tag
}
val cb = lnl.optString("callback")
if (cb.isBlank()) return@withContext ZapOutcome.Failed("Endpoint missing callback.")
val url = buildString {
append(cb)
append(if (cb.contains("?")) "&" else "?")
append("amount=").append(sats * 1000)
if (allowsNostr) {
append("&nostr=").append(
java.net.URLEncoder.encode(zapReq.toJson(), "UTF-8"),
)
}
}
val resp = runCatching {
http.newCall(okhttp3.Request.Builder().url(url).build())
.execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
}.getOrNull() ?: return@withContext ZapOutcome.Failed("Zap callback unreachable.")
if (resp.has("error")) {
return@withContext ZapOutcome.Failed(resp.optString("reason", "Zap rejected."))
}
val bolt11 = resp.optString("pr")
if (bolt11.isBlank()) return@withContext ZapOutcome.Failed("No invoice returned.")
// 5. Pay via the connected wallet.
when (val pay = paymentService.payInvoiceDirect(bolt11, sats)) {
is PaymentResult.Paid -> {
nostr.waitForZapReceipt(zapReq.id, targetNpubHex, timeoutMs = 10_000)
ZapOutcome.Sent
}
is PaymentResult.Failed -> ZapOutcome.Failed(pay.userFacing)
else -> ZapOutcome.Failed("Unexpected wallet response.")
}
}
}

View file

@ -0,0 +1,311 @@
package com.rada.ui.decentralized
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.ui.theme.LocalTokens
import com.rada.ui.theme.SatsAmpGlass
/**
* Decentralized settings sheet — progressive disclosure per the design:
* listeners see "identity + wallet", advanced rows (relays, profile) sit
* behind a toggle. All state via [DecentralizedViewModel].
*/
@Composable
fun DecentralizedSheet(
onDismiss: () -> Unit,
viewModel: DecentralizedViewModel = hiltViewModel(),
) {
val tokens = LocalTokens.current
val npub by viewModel.npub.collectAsState()
val walletConnected by viewModel.walletConnected.collectAsState()
val relays by viewModel.relays.collectAsState()
val message by viewModel.message.collectAsState()
val busy by viewModel.busy.collectAsState()
val clipboard = LocalClipboardManager.current
var showAdvanced by remember { mutableStateOf(false) }
var showImport by remember { mutableStateOf(false) }
var nsecInput by remember { mutableStateOf("") }
var nwcInput by remember { mutableStateOf("") }
var nameInput by remember { mutableStateOf("") }
var aboutInput by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = tokens.panel,
titleContentColor = tokens.text,
shape = RoundedCornerShape(SatsAmpGlass.rCard),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
Spacer(Modifier.width(8.dp))
Text("Decentralized", fontWeight = FontWeight.Bold)
}
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// ── Nostr identity ────────────────────────────────────
SectionLabel("Nostr identity")
val pk = npub
if (pk == null) {
Text(
"Optional. Gives you a profile, follows, reactions and zaps. Everything else in SatsAmp works without it.",
style = MaterialTheme.typography.bodySmall,
color = tokens.textSecondary,
)
Button(
onClick = { viewModel.createIdentity() },
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
modifier = Modifier.fillMaxWidth(),
) { Text("Create Nostr identity") }
TextButton(onClick = { showImport = !showImport }) {
Text("Import existing nsec", color = tokens.accentText)
}
if (showImport) {
OutlinedTextField(
value = nsecInput,
onValueChange = { nsecInput = it },
placeholder = { Text("nsec… (stored encrypted)") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
TextButton(
onClick = { viewModel.importNsec(nsecInput) },
enabled = nsecInput.isNotBlank(),
) { Text("Import") }
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(tokens.glassFillStrong)
.border(1.dp, tokens.border, RoundedCornerShape(12.dp))
.clickable { clipboard.setText(AnnotatedString(pk)) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
pk,
style = MaterialTheme.typography.bodySmall,
color = tokens.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Default.ContentCopy, contentDescription = "Copy npub",
tint = tokens.textSecondary,
modifier = Modifier.size(16.dp),
)
}
Text(
"tap to copy",
style = MaterialTheme.typography.labelSmall,
color = tokens.textMuted,
)
}
Spacer(Modifier.height(4.dp))
// ── Wallet ────────────────────────────────────────────
SectionLabel("Lightning wallet")
if (!walletConnected) {
Text(
"Connect a NIP-47 wallet (Alby Hub, Coinos, etc.) with its pairing string to zap and tip artists.",
style = MaterialTheme.typography.bodySmall,
color = tokens.textSecondary,
)
OutlinedTextField(
value = nwcInput,
onValueChange = { nwcInput = it },
placeholder = { Text("nostr+walletconnect://…") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.connectWallet(nwcInput) },
enabled = nwcInput.isNotBlank() && busy.not(),
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
modifier = Modifier.fillMaxWidth(),
) {
if (busy) CircularProgressIndicator(
modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
color = tokens.accentOnFill,
) else Text("Connect wallet")
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(tokens.accentSoft)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Icon(Icons.Default.Check, contentDescription = null,
tint = tokens.accentText, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text("Wallet connected", color = tokens.accentText,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f))
TextButton(onClick = { viewModel.disconnectWallet() }) {
Text("Disconnect", color = tokens.textSecondary)
}
}
}
Spacer(Modifier.height(4.dp))
// ── Advanced ──────────────────────────────────────────
TextButton(onClick = { showAdvanced = !showAdvanced }) {
Text(
if (showAdvanced) "Hide advanced" else "Advanced: relays & profile",
color = tokens.accentText,
)
}
if (showAdvanced) {
SectionLabel("Relays")
relays.forEach { r ->
Text(
r,
style = MaterialTheme.typography.bodySmall,
color = tokens.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
TextButton(
onClick = {
viewModel.setRelays(
listOf("wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"),
)
},
) { Text("Reset to defaults", color = tokens.textSecondary) }
SectionLabel("Publish profile")
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
placeholder = { Text("Display name") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = aboutInput,
onValueChange = { aboutInput = it },
placeholder = { Text("About (optional)") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.publishProfile(nameInput, aboutInput) },
enabled = nameInput.isNotBlank() && busy.not(),
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
modifier = Modifier.fillMaxWidth(),
) { Text("Publish to relays") }
}
message?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = tokens.accentText,
)
TextButton(onClick = { viewModel.consumeMessage() }) {
Text("Dismiss", color = tokens.textMuted)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close", color = tokens.accentText) }
},
)
}
@Composable
private fun SectionLabel(text: String) {
Text(
text.uppercase(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.textMuted,
)
}
@Composable
private fun fieldColors(tokens: com.rada.ui.theme.UiTokens) =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
)

View file

@ -0,0 +1,93 @@
package com.rada.ui.decentralized
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.nostr.NostrKeys
import com.rada.nostr.NostrService
import com.rada.payments.NwcProvider
import com.rada.payments.PaymentService
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@HiltViewModel
class DecentralizedViewModel @Inject constructor(
private val keys: NostrKeys,
private val nostr: NostrService,
private val nwc: NwcProvider,
val paymentService: PaymentService,
) : ViewModel() {
/** NIP-19 npub for display/share (never raw hex). */
val npub: kotlinx.coroutines.flow.StateFlow<String?> =
keys.pubkey
.map { hex -> hex?.let { com.rada.nostr.Nip19.npub(it) } }
.stateIn(
viewModelScope,
kotlinx.coroutines.flow.SharingStarted.Eagerly,
keys.npub(),
)
val nostrEnabled = MutableStateFlow(nostr.enabled)
val walletConnected = MutableStateFlow(nwc.connected)
val message = MutableStateFlow<String?>(null)
val busy = MutableStateFlow(false)
private val _relays = MutableStateFlow(nostr.relaysConfig())
val relays: StateFlow<List<String>> = _relays.asStateFlow()
fun createIdentity() {
runCatching { keys.createIdentity() }
.onSuccess {
nostr.setEnabled(true)
nostrEnabled.value = true
message.value = "Nostr identity created ✅"
}
.onFailure { message.value = "Couldn't create identity: ${it.message}" }
}
fun importNsec(nsec: String) {
if (keys.importNsec(nsec)) {
nostr.setEnabled(true)
nostrEnabled.value = true
message.value = "Identity imported ✅"
} else {
message.value = "That doesn't look like a valid nsec."
}
}
fun publishProfile(name: String, about: String) {
busy.value = true
val ev = nostr.publishProfile(name, about, "")
busy.value = false
message.value = if (ev != null)
"Profile published to ${nostr.relaysConfig().size} relays"
else "Publish failed — check relays are reachable."
}
fun setRelays(list: List<String>) {
nostr.setRelays(list)
_relays.value = nostr.relaysConfig()
message.value = "Relays updated"
}
fun connectWallet(uri: String) {
if (nwc.connect(uri)) {
walletConnected.value = true
message.value = "Wallet connected via Nostr Wallet Connect ⚡"
} else {
message.value = "That pairing string isn't valid (nostr+walletconnect://…)."
}
}
fun disconnectWallet() {
nwc.disconnect()
walletConnected.value = false
message.value = "Wallet disconnected"
}
fun consumeMessage() { message.value = null }
}

View file

@ -0,0 +1,191 @@
package com.rada.ui.decentralized
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.ui.theme.LocalTokens
/**
* "⚡ Support" — the whole listener-facing payment surface. Presets +
* optional comment + one address field (remembered). Advanced wiring
* (NWC, relays) lives in the Decentralized sheet, not here.
*/
@Composable
fun TipSheet(
artistName: String,
trackTitle: String?,
onDismiss: () -> Unit,
viewModel: TipViewModel = hiltViewModel(),
) {
val tokens = LocalTokens.current
val status by viewModel.status.collectAsState()
val sending by viewModel.sending.collectAsState()
val hasWallet by viewModel.hasWallet.collectAsState()
val recents by viewModel.recentAddresses.collectAsState()
var amount by remember { mutableStateOf(100L) }
var customAmount by remember { mutableStateOf("") }
var address by remember { mutableStateOf(recents.firstOrNull() ?: "") }
var comment by remember { mutableStateOf("") }
val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount
AlertDialog(
onDismissRequest = onDismiss,
containerColor = tokens.panel,
titleContentColor = tokens.text,
shape = RoundedCornerShape(20.dp),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
Spacer(Modifier.size(8.dp))
Text("Support $artistName", fontWeight = FontWeight.Bold)
}
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
trackTitle?.takeIf { it.isNotBlank() }?.let {
Text(
"playing · $it",
style = MaterialTheme.typography.bodySmall,
color = tokens.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(21L, 100L, 500L, 1000L).forEach { preset ->
FilterChip(
selected = customAmount.isBlank() && amount == preset,
onClick = { amount = preset; customAmount = "" },
label = { Text("$preset") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = tokens.accentSoft,
selectedLabelColor = tokens.accentText,
labelColor = tokens.textSecondary,
),
)
}
}
OutlinedTextField(
value = customAmount,
onValueChange = { customAmount = it.filter(Char::isDigit) },
placeholder = { Text("Custom amount in sats") },
singleLine = true,
suffix = { Text("sats", color = tokens.textMuted) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = address,
onValueChange = { address = it },
placeholder = { Text("name@domain (Lightning address)") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
placeholder = { Text("Message (optional)") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
),
modifier = Modifier.fillMaxWidth(),
)
status?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = tokens.accentText)
}
}
},
confirmButton = {
Button(
onClick = { viewModel.tip(address, finalSats, artistName, trackTitle) },
enabled = !sending && address.contains("@") && finalSats > 0,
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(14.dp),
) {
if (sending) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
color = tokens.accentOnFill,
)
} else {
Text("Send $finalSats sats")
}
}
},
dismissButton = {
if (!hasWallet) {
Text(
"connect a wallet first\n(Settings ⚙ → Decentralized)",
style = MaterialTheme.typography.labelSmall,
color = tokens.textMuted,
)
}
},
)
}

View file

@ -0,0 +1,60 @@
package com.rada.ui.decentralized
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.content.Context
import com.rada.payments.PaymentResult
import com.rada.payments.PaymentService
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class TipViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val paymentService: PaymentService,
) : ViewModel() {
private val prefs = context.getSharedPreferences("tips", Context.MODE_PRIVATE)
val status = MutableStateFlow<String?>(null)
val sending = MutableStateFlow(false)
val hasWallet = MutableStateFlow(paymentService.hasAnyWallet)
/** Recently used lightning addresses (most recent first, max 8). */
private val _recentAddresses = MutableStateFlow(
prefs.getString("recent", "")!!.split("\n").filter { it.contains("@") },
)
val recentAddresses: StateFlow<List<String>> = _recentAddresses.asStateFlow()
fun tip(address: String, sats: Long, artistName: String, trackTitle: String?) {
val clean = address.trim().removePrefix("lightning:")
if (!clean.contains("@")) {
status.value = "Enter a name@domain Lightning address."
return
}
sending.value = true
status.value = null
viewModelScope.launch {
when (val r = paymentService.tipArtist(clean, sats, artistName, trackTitle)) {
is PaymentResult.Paid -> {
status.value = "⚡ $sats sats sent to $artistName"
_recentAddresses.value =
(listOf(clean) + _recentAddresses.value.filter { it != clean })
.distinct().take(8)
prefs.edit()
.putString("recent", _recentAddresses.value.joinToString("\n"))
.apply()
}
is PaymentResult.Failed -> status.value = r.userFacing
else -> status.value = "Invoice created."
}
sending.value = false
hasWallet.value = paymentService.hasAnyWallet
}
}
}

View file

@ -28,6 +28,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Radio import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -237,6 +238,27 @@ fun NowPlayingBar(
} }
} }
} }
// ⚡ Support — tip the artist/station from anywhere.
var showTip by remember { mutableStateOf(false) }
IconButton(
onClick = { showTip = true },
modifier = Modifier.size(40.dp),
) {
Icon(
Icons.Default.Bolt,
contentDescription = "Support artist",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
}
if (showTip) {
com.rada.ui.decentralized.TipSheet(
artistName = stationName,
trackTitle = title.takeIf { subtitle.isNotEmpty() },
onDismiss = { showTip = false },
)
}
IconButton( IconButton(
onClick = { viewModel.togglePlayPause() }, onClick = { viewModel.togglePlayPause() },
modifier = Modifier.size(44.dp), modifier = Modifier.size(44.dp),

View file

@ -124,6 +124,7 @@ fun PlayerScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var showImportDialog by remember { mutableStateOf(false) } var showImportDialog by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) } var showAppearance by remember { mutableStateOf(false) }
var showDecentralized by remember { mutableStateOf(false) }
val importSongsPicker = rememberLauncherForActivityResult( val importSongsPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments() ActivityResultContracts.OpenMultipleDocuments()
@ -201,7 +202,19 @@ fun PlayerScreen(
} }
if (showAppearance) { if (showAppearance) {
AppearanceDialog(onDismiss = { showAppearance = false }) AppearanceDialog(
onDismiss = { showAppearance = false },
onOpenDecentralized = {
showAppearance = false
showDecentralized = true
},
)
}
if (showDecentralized) {
com.rada.ui.decentralized.DecentralizedSheet(
onDismiss = { showDecentralized = false },
)
} }
Scaffold( Scaffold(

View file

@ -45,7 +45,10 @@ internal val AccentSwatches = mapOf(
* the whole UI recomposes live through LocalTokens. * the whole UI recomposes live through LocalTokens.
*/ */
@Composable @Composable
fun AppearanceDialog(onDismiss: () -> Unit) { fun AppearanceDialog(
onDismiss: () -> Unit,
onOpenDecentralized: (() -> Unit)? = null,
) {
val context = LocalContext.current val context = LocalContext.current
val tokens = LocalTokens.current val tokens = LocalTokens.current
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark() val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
@ -141,9 +144,16 @@ fun AppearanceDialog(onDismiss: () -> Unit) {
} }
}, },
confirmButton = { confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (onOpenDecentralized != null) {
TextButton(onClick = onOpenDecentralized) {
Text("⚡ Decentralized", color = tokens.accentText)
}
}
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold) Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold)
} }
}
}, },
) )
} }

View file

@ -0,0 +1,91 @@
package com.rada.nostr
import fr.acinq.secp256k1.Secp256k1
import fr.acinq.secp256k1.jni.NativeSecp256k1JvmLoader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Protocol conformance tests against official spec vectors.
* Runs on the JVM via secp256k1-kmp-jni-jvm (testImplementation).
*/
class NostrProtocolTest {
init {
// Load the JVM native binding for unit tests only.
NativeSecp256k1JvmLoader.load()
}
@Test
fun `NIP-19 npub encodes known vector`() {
val pub = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
assertEquals(
"npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg",
Nip19.npub(pub),
)
}
@Test
fun `NIP-19 nsec encodes and decodes known vector`() {
val priv = "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"
val enc = Nip19.nsec(priv)
assertEquals("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", enc)
val dec = Nip19.decode(enc)
assertNotNull(dec)
val (hrp, bytes) = dec!!
assertEquals("nsec", hrp)
assertEquals(priv, bytes.toHex())
}
@Test
fun `schnorr sign verifies with BIP-340 semantics`() {
// sk=3 -> x-only pubkey F9308A... (BIP-340 vector 0)
val sk = ByteArray(32).also { it[31] = 3 }
val pub = Secp256k1.get().pubkeyCreate(sk).copyOfRange(1, 33)
assertEquals(
"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
pub.toHex(),
)
val msg = ByteArray(32)
val sig = Secp256k1.get().signSchnorr(msg, sk, null)
assertEquals(64, sig.size)
assertTrue(Secp256k1.get().verifySchnorr(sig, msg, pub))
}
@Test
fun `event id is sha256 of NIP-01 serialization`() {
val unsigned = UnsignedEvent(
pubkey = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
createdAt = 1700000000,
kind = 1,
tags = listOf(listOf("e", "abc", "wss://relay.x"), listOf("d", "t1")),
content = "hello world",
)
// Canonical serialization per NIP-01 (no whitespace, fixed order).
assertEquals(
"""[0,"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",1700000000,1,[["e","abc","wss://relay.x"],["d","t1"]],"hello world"]""",
unsigned.serialized(),
)
// Sign with sk=3 and verify() must pass end to end.
val ev = unsigned.sign { digest -> Secp256k1.get().signSchnorr(digest, ByteArray(32).also { it[31] = 3 }, null).toHex() }
assertNotNull(ev)
assertTrue("event self-verification (id hash + schnorr)", ev!!.verify())
// Tampering must break verification.
assertFalse(ev.copy(content = "tampered").verify())
}
@Test
fun `nip-04 roundtrip between two identities`() {
val skA = ByteArray(32).also { it[31] = 11 }
val skB = ByteArray(32).also { it[31] = 22 }
val pubA = Secp256k1.get().pubkeyCreate(skA).copyOfRange(1, 33).toHex()
val pubB = Secp256k1.get().pubkeyCreate(skB).copyOfRange(1, 33).toHex()
val msg = "zap 21 sats ⚡"
val enc = Nip04.encrypt(skA, pubB, msg)
org.junit.Assert.assertEquals(msg, Nip04.decrypt(skB, pubA, enc))
}
private fun assertFalse(b: Boolean) = org.junit.Assert.assertFalse(b)
}