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:
parent
e2ed74a47d
commit
5ff5318db4
19 changed files with 2255 additions and 4 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
24
app/src/main/java/com/rada/di/PaymentsModule.kt
Normal file
24
app/src/main/java/com/rada/di/PaymentsModule.kt
Normal 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) })
|
||||||
|
}
|
||||||
156
app/src/main/java/com/rada/nostr/Bech32.kt
Normal file
156
app/src/main/java/com/rada/nostr/Bech32.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
48
app/src/main/java/com/rada/nostr/Nip04.kt
Normal file
48
app/src/main/java/com/rada/nostr/Nip04.kt
Normal 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()
|
||||||
|
}
|
||||||
143
app/src/main/java/com/rada/nostr/NostrEvent.kt
Normal file
143
app/src/main/java/com/rada/nostr/NostrEvent.kt
Normal 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
|
||||||
|
}
|
||||||
154
app/src/main/java/com/rada/nostr/NostrKeys.kt
Normal file
154
app/src/main/java/com/rada/nostr/NostrKeys.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
225
app/src/main/java/com/rada/nostr/NostrService.kt
Normal file
225
app/src/main/java/com/rada/nostr/NostrService.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/src/main/java/com/rada/nostr/RelayConnection.kt
Normal file
137
app/src/main/java/com/rada/nostr/RelayConnection.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
286
app/src/main/java/com/rada/payments/NwcProvider.kt
Normal file
286
app/src/main/java/com/rada/payments/NwcProvider.kt
Normal 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()
|
||||||
|
}
|
||||||
112
app/src/main/java/com/rada/payments/PaymentService.kt
Normal file
112
app/src/main/java/com/rada/payments/PaymentService.kt
Normal 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?,
|
||||||
|
)
|
||||||
169
app/src/main/java/com/rada/payments/ZapService.kt
Normal file
169
app/src/main/java/com/rada/payments/ZapService.kt
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
191
app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
Normal file
191
app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
60
app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
Normal file
60
app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
91
app/src/test/java/com/rada/nostr/NostrProtocolTest.kt
Normal file
91
app/src/test/java/com/rada/nostr/NostrProtocolTest.kt
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue