diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8dfd23..6b2bf88 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,9 +104,15 @@ dependencies { // not exist on Maven Central), which made the build fail during // dependency resolution before Kotlin was ever compiled. 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 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(composeBom) androidTestImplementation("androidx.compose.ui:ui-test-junit4") diff --git a/app/src/main/java/com/rada/di/PaymentsModule.kt b/app/src/main/java/com/rada/di/PaymentsModule.kt new file mode 100644 index 0000000..06b3c03 --- /dev/null +++ b/app/src/main/java/com/rada/di/PaymentsModule.kt @@ -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) }) +} diff --git a/app/src/main/java/com/rada/nostr/Bech32.kt b/app/src/main/java/com/rada/nostr/Bech32.kt new file mode 100644 index 0000000..470bafc --- /dev/null +++ b/app/src/main/java/com/rada/nostr/Bech32.kt @@ -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? { + 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() + 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() + 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 { + val tlv = ArrayList() + 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 = emptyList(), kind: Int? = null): String { + val tlv = ArrayList() + 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 = emptyList()): String { + val tlv = ArrayList() + 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? { + 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() + } +} + diff --git a/app/src/main/java/com/rada/nostr/Nip04.kt b/app/src/main/java/com/rada/nostr/Nip04.kt new file mode 100644 index 0000000..9770edd --- /dev/null +++ b/app/src/main/java/com/rada/nostr/Nip04.kt @@ -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() +} diff --git a/app/src/main/java/com/rada/nostr/NostrEvent.kt b/app/src/main/java/com/rada/nostr/NostrEvent.kt new file mode 100644 index 0000000..81ea317 --- /dev/null +++ b/app/src/main/java/com/rada/nostr/NostrEvent.kt @@ -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>, + 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 = 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>, + 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>, + 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 +} diff --git a/app/src/main/java/com/rada/nostr/NostrKeys.kt b/app/src/main/java/com/rada/nostr/NostrKeys.kt new file mode 100644 index 0000000..c882b7c --- /dev/null +++ b/app/src/main/java/com/rada/nostr/NostrKeys.kt @@ -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(null) + /** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */ + val pubkey: StateFlow = _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 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() + } + } +} diff --git a/app/src/main/java/com/rada/nostr/NostrService.kt b/app/src/main/java/com/rada/nostr/NostrService.kt new file mode 100644 index 0000000..e8d09ec --- /dev/null +++ b/app/src/main/java/com/rada/nostr/NostrService.kt @@ -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 + get() { + val saved = prefs.getString("relays", null) + ?.split("\n")?.filter { it.isNotBlank() } + return if (saved.isNullOrEmpty()) defaultRelays else saved + } + + private val relays = mutableMapOf() + + private val _relayStates = MutableStateFlow>(emptyList()) + val relayStates: StateFlow> = _relayStates.asStateFlow() + + /** Merged verified events from all relays (deduped by id downstream). */ + private val merged = kotlinx.coroutines.flow.MutableSharedFlow( + 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 + + 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 = relayUrls + fun setRelays(list: List) { + 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>): 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() + 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 { + val out = kotlinx.coroutines.flow.MutableStateFlow(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 + } +} diff --git a/app/src/main/java/com/rada/nostr/RelayConnection.kt b/app/src/main/java/com/rada/nostr/RelayConnection.kt new file mode 100644 index 0000000..c137ff1 --- /dev/null +++ b/app/src/main/java/com/rada/nostr/RelayConnection.kt @@ -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(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val state: SharedFlow = _state.asSharedFlow() + + private val _events = MutableSharedFlow( + replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + /** Verified events matching any subscription on this relay. */ + val events: SharedFlow = _events.asSharedFlow() + + /** reqId -> original filter JSON, so we can CLOSE. */ + private val subs = ConcurrentHashMap() + private val reqCounter = AtomicLong(0) + @Volatile private var socket: WebSocket? = null + private val pendingOut = ArrayDeque() + + 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 + } +} diff --git a/app/src/main/java/com/rada/payments/NwcProvider.kt b/app/src/main/java/com/rada/payments/NwcProvider.kt new file mode 100644 index 0000000..9f296fc --- /dev/null +++ b/app/src/main/java/com/rada/payments/NwcProvider.kt @@ -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://?relay=&secret= + */ +@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() + // 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() +} diff --git a/app/src/main/java/com/rada/payments/PaymentService.kt b/app/src/main/java/com/rada/payments/PaymentService.kt new file mode 100644 index 0000000..ff966d4 --- /dev/null +++ b/app/src/main/java/com/rada/payments/PaymentService.kt @@ -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, +) { + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _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?, +) diff --git a/app/src/main/java/com/rada/payments/ZapService.kt b/app/src/main/java/com/rada/payments/ZapService.kt new file mode 100644 index 0000000..b7ec591 --- /dev/null +++ b/app/src/main/java/com/rada/payments/ZapService.kt @@ -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, + ): 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.") + } + } +} diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt new file mode 100644 index 0000000..9e93d7f --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt @@ -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, + ) diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt new file mode 100644 index 0000000..0db3cb3 --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt @@ -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 = + 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(null) + val busy = MutableStateFlow(false) + + private val _relays = MutableStateFlow(nostr.relaysConfig()) + val relays: StateFlow> = _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) { + 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 } +} diff --git a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt new file mode 100644 index 0000000..49a9019 --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt @@ -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, + ) + } + }, + ) +} diff --git a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt new file mode 100644 index 0000000..3066316 --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt @@ -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(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> = _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 + } + } +} diff --git a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt index b9cf1b4..230bd51 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Radio import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon 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( onClick = { viewModel.togglePlayPause() }, modifier = Modifier.size(44.dp), diff --git a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt index b077843..04a5e11 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -124,6 +124,7 @@ fun PlayerScreen( val snackbarHostState = remember { SnackbarHostState() } var showImportDialog by remember { mutableStateOf(false) } var showAppearance by remember { mutableStateOf(false) } + var showDecentralized by remember { mutableStateOf(false) } val importSongsPicker = rememberLauncherForActivityResult( ActivityResultContracts.OpenMultipleDocuments() @@ -201,7 +202,19 @@ fun PlayerScreen( } 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( diff --git a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt index 6de7aa1..fc1c1d6 100644 --- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt +++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt @@ -45,7 +45,10 @@ internal val AccentSwatches = mapOf( * the whole UI recomposes live through LocalTokens. */ @Composable -fun AppearanceDialog(onDismiss: () -> Unit) { +fun AppearanceDialog( + onDismiss: () -> Unit, + onOpenDecentralized: (() -> Unit)? = null, +) { val context = LocalContext.current val tokens = LocalTokens.current val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark() @@ -141,8 +144,15 @@ fun AppearanceDialog(onDismiss: () -> Unit) { } }, confirmButton = { - TextButton(onClick = onDismiss) { - Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (onOpenDecentralized != null) { + TextButton(onClick = onOpenDecentralized) { + Text("⚡ Decentralized", color = tokens.accentText) + } + } + TextButton(onClick = onDismiss) { + Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold) + } } }, ) diff --git a/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt b/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt new file mode 100644 index 0000000..587bab5 --- /dev/null +++ b/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt @@ -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) +}