feat: add Breez Spark non-custodial Lightning wallet
Integrate Breez SDK Spark as a second wallet backend alongside NWC. Users can create/restore a non-custodial Lightning wallet directly in the app with seamless onboarding (no forced backup on first use). - WalletProvider interface abstracts NWC and Spark backends - WalletModeRepository persists active wallet mode - SparkRepository wraps Breez SDK: connect, send, receive, transactions - Wallet mode selection UI with back navigation - Receive flow auto-detects payment via sync polling + SDK events - Payment received success screen with auto-navigation - Dynamic wallet provider resolution for zaps (no stale captures) - Proper SDK lifecycle: disconnect on app exit, cleanup on teardown - 60s connection timeout for Spark (vs 20s for NWC) - Wallet-connected indicator works for both providers - BIP39 mnemonic generation with bundled English wordlist - ProGuard rules for UniFFI/JNA bindings
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -20,6 +22,14 @@ android {
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
}
|
||||
|
||||
val localProps = rootProject.file("local.properties")
|
||||
val breezApiKey = if (localProps.exists()) {
|
||||
val props = Properties()
|
||||
localProps.inputStream().use { props.load(it) }
|
||||
props.getProperty("breez.api.key", "")
|
||||
} else ""
|
||||
buildConfigField("String", "BREEZ_API_KEY", "\"$breezApiKey\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -45,6 +55,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +95,5 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
implementation(libs.objectbox.android)
|
||||
implementation(libs.objectbox.kotlin)
|
||||
implementation(libs.breez.sdk.spark)
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -54,6 +54,13 @@
|
||||
-dontwarn io.objectbox.**
|
||||
-keep class com.wisp.app.db.** { *; }
|
||||
|
||||
# Breez SDK Spark (UniFFI bindings)
|
||||
-keep class breez_sdk_spark.** { *; }
|
||||
-dontwarn breez_sdk_spark.**
|
||||
# JNA (used by Breez SDK UniFFI)
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-dontwarn com.sun.jna.**
|
||||
|
||||
# java.lang.management (not available on Android)
|
||||
-dontwarn java.lang.management.ManagementFactory
|
||||
-dontwarn java.lang.management.RuntimeMXBean
|
||||
|
||||
@@ -160,7 +160,11 @@ fun WispNavHost(
|
||||
factory = object : androidx.lifecycle.ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||
return WalletViewModel(feedViewModel.nwcRepo) as T
|
||||
return WalletViewModel(
|
||||
feedViewModel.nwcRepo,
|
||||
feedViewModel.sparkRepo,
|
||||
feedViewModel.walletModeRepo
|
||||
) as T
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -821,7 +825,7 @@ fun WispNavHost(
|
||||
onReact = { event, emoji -> feedViewModel.toggleReaction(event, emoji) },
|
||||
onZap = { event, amountMsats, message, isAnonymous, isPrivate -> feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) },
|
||||
userPubkey = feedViewModel.getUserPubkey(),
|
||||
isWalletConnected = feedViewModel.nwcRepo.hasConnection(),
|
||||
isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(),
|
||||
onWallet = { navController.navigate(Routes.WALLET) },
|
||||
zapSuccess = feedViewModel.zapSuccess,
|
||||
zapError = feedViewModel.zapError,
|
||||
@@ -974,7 +978,7 @@ fun WispNavHost(
|
||||
var threadZapTarget by remember { mutableStateOf<NostrEvent?>(null) }
|
||||
val threadZapInProgress by feedViewModel.zapInProgress.collectAsState()
|
||||
var threadZapAnimatingIds by remember { mutableStateOf(emptySet<String>()) }
|
||||
val isNwcConnected = feedViewModel.nwcRepo.hasConnection()
|
||||
val isNwcConnected = feedViewModel.activeWalletProvider.hasConnection()
|
||||
var showThreadEmojiLibrary by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -1190,7 +1194,7 @@ fun WispNavHost(
|
||||
var articleZapTarget by remember { mutableStateOf<com.wisp.app.nostr.NostrEvent?>(null) }
|
||||
val articleZapInProgress by feedViewModel.zapInProgress.collectAsState()
|
||||
var articleZapAnimatingIds by remember { mutableStateOf(emptySet<String>()) }
|
||||
val isNwcConnected = feedViewModel.nwcRepo.hasConnection()
|
||||
val isNwcConnected = feedViewModel.activeWalletProvider.hasConnection()
|
||||
val articleSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState()
|
||||
val articleBookmarkedIds by feedViewModel.bookmarkRepo.bookmarkedIds.collectAsState()
|
||||
val articleListedIds = remember(articleSetListedIds, articleBookmarkedIds) { articleSetListedIds + articleBookmarkedIds }
|
||||
@@ -1674,7 +1678,7 @@ fun WispNavHost(
|
||||
val notifSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState()
|
||||
val notifBookmarkedIds by feedViewModel.bookmarkRepo.bookmarkedIds.collectAsState()
|
||||
val notifListedIds = remember(notifSetListedIds, notifBookmarkedIds) { notifSetListedIds + notifBookmarkedIds }
|
||||
val isNwcConnected = feedViewModel.nwcRepo.hasConnection()
|
||||
val isNwcConnected = feedViewModel.activeWalletProvider.hasConnection()
|
||||
var showNotifEmojiLibrary by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
class NwcRepository(private val context: Context, private val relayPool: RelayPool? = null, pubkeyHex: String? = null) {
|
||||
class NwcRepository(private val context: Context, private val relayPool: RelayPool? = null, pubkeyHex: String? = null) : WalletProvider {
|
||||
private val TAG = "NwcRepository"
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
@@ -42,24 +42,27 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo
|
||||
private val pendingRequests = mutableMapOf<String, CompletableDeferred<Nip47.NwcResponse>>()
|
||||
|
||||
private val _balance = MutableStateFlow<Long?>(null)
|
||||
val balance: StateFlow<Long?> = _balance
|
||||
override val balance: StateFlow<Long?> = _balance
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected
|
||||
override val isConnected: StateFlow<Boolean> = _isConnected
|
||||
|
||||
/** True once encryption is negotiated and the response subscription is active. */
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
|
||||
/** Granular status updates emitted during connect flow */
|
||||
private val _statusLog = MutableSharedFlow<String>(extraBufferCapacity = 32)
|
||||
val statusLog: SharedFlow<String> = _statusLog
|
||||
override val statusLog: SharedFlow<String> = _statusLog
|
||||
|
||||
private val _paymentReceived = MutableSharedFlow<Long>(extraBufferCapacity = 8)
|
||||
override val paymentReceived: SharedFlow<Long> = _paymentReceived
|
||||
|
||||
private fun emitStatus(msg: String) {
|
||||
Log.d(TAG, msg)
|
||||
_statusLog.tryEmit(msg)
|
||||
}
|
||||
|
||||
fun hasConnection(): Boolean = encPrefs.getString("nwc_uri", null) != null
|
||||
override fun hasConnection(): Boolean = encPrefs.getString("nwc_uri", null) != null
|
||||
|
||||
fun saveConnectionString(uri: String) {
|
||||
encPrefs.edit().putString("nwc_uri", uri).apply()
|
||||
@@ -80,7 +83,7 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo
|
||||
_balance.value = null
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
override fun connect() {
|
||||
val uri = getConnectionString() ?: return
|
||||
val conn = Nip47.parseConnectionString(uri) ?: run {
|
||||
emitStatus("Failed to parse connection string")
|
||||
@@ -263,7 +266,7 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchBalance(): Result<Long> {
|
||||
override suspend fun fetchBalance(): Result<Long> {
|
||||
emitStatus("Fetching balance...")
|
||||
val result = sendRequest(Nip47.NwcRequest.GetBalance)
|
||||
return result.map { response ->
|
||||
@@ -273,22 +276,37 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun payInvoice(bolt11: String): Result<String> {
|
||||
override suspend fun payInvoice(bolt11: String): Result<String> {
|
||||
val result = sendRequest(Nip47.NwcRequest.PayInvoice(bolt11))
|
||||
return result.map { (it as Nip47.NwcResponse.PayInvoiceResult).preimage }
|
||||
}
|
||||
|
||||
suspend fun makeInvoice(amountMsats: Long, description: String): Result<String> {
|
||||
override suspend fun makeInvoice(amountMsats: Long, description: String): Result<String> {
|
||||
val result = sendRequest(Nip47.NwcRequest.MakeInvoice(amountMsats, description))
|
||||
return result.map { (it as Nip47.NwcResponse.MakeInvoiceResult).invoice }
|
||||
}
|
||||
|
||||
suspend fun listTransactions(limit: Int = 50): Result<List<Nip47.Transaction>> {
|
||||
suspend fun listNwcTransactions(limit: Int = 50): Result<List<Nip47.Transaction>> {
|
||||
val result = sendRequest(Nip47.NwcRequest.ListTransactions(limit = limit))
|
||||
return result.map { (it as Nip47.NwcResponse.ListTransactionsResult).transactions }
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
override suspend fun listTransactions(limit: Int): Result<List<WalletTransaction>> {
|
||||
return listNwcTransactions(limit).map { txs ->
|
||||
txs.map { tx ->
|
||||
WalletTransaction(
|
||||
type = tx.type,
|
||||
description = tx.description,
|
||||
paymentHash = tx.paymentHash,
|
||||
amountMsats = tx.amount,
|
||||
createdAt = tx.createdAt,
|
||||
settledAt = tx.settledAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
relay?.disconnect()
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package com.wisp.app.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import breez_sdk_spark.ConnectRequest
|
||||
import breez_sdk_spark.EventListener
|
||||
import breez_sdk_spark.GetInfoRequest
|
||||
import breez_sdk_spark.ListPaymentsRequest
|
||||
import breez_sdk_spark.Network
|
||||
import breez_sdk_spark.PaymentDetails
|
||||
import breez_sdk_spark.PaymentType
|
||||
import breez_sdk_spark.PrepareSendPaymentRequest
|
||||
import breez_sdk_spark.ReceivePaymentMethod
|
||||
import breez_sdk_spark.ReceivePaymentRequest
|
||||
import breez_sdk_spark.SdkEvent
|
||||
import breez_sdk_spark.Seed
|
||||
import breez_sdk_spark.SendPaymentOptions
|
||||
import breez_sdk_spark.SendPaymentRequest
|
||||
import breez_sdk_spark.SyncWalletRequest
|
||||
import breez_sdk_spark.connect
|
||||
import breez_sdk_spark.defaultConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.wisp.app.BuildConfig
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
|
||||
class SparkRepository(
|
||||
private val context: Context,
|
||||
pubkeyHex: String? = null
|
||||
) : WalletProvider {
|
||||
private val TAG = "SparkRepository"
|
||||
|
||||
companion object {
|
||||
private val BREEZ_API_KEY: String get() = BuildConfig.BREEZ_API_KEY
|
||||
|
||||
// BIP39 English wordlist subset is large; for mnemonic generation we use
|
||||
// the SDK's Seed.Mnemonic which validates the mnemonic on connect.
|
||||
// We generate a 16-byte entropy and convert to mnemonic externally.
|
||||
// For now, we'll generate a random 12-word phrase placeholder that the user
|
||||
// should replace with a proper BIP39 mnemonic from the SDK's built-in generator.
|
||||
|
||||
private val BIP39_WORDS: List<String> by lazy {
|
||||
// Load the BIP39 wordlist from the bundled resource, or use a minimal fallback
|
||||
try {
|
||||
val stream = SparkRepository::class.java.getResourceAsStream("/bip39-english.txt")
|
||||
stream?.bufferedReader()?.readLines()?.filter { it.isNotBlank() } ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val encPrefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
if (pubkeyHex != null) "wisp_spark_$pubkeyHex" else "wisp_spark",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
private var sdk: breez_sdk_spark.BreezSdk? = null
|
||||
private var eventListenerId: String? = null
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
private val _balance = MutableStateFlow<Long?>(null)
|
||||
override val balance: StateFlow<Long?> = _balance
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean> = _isConnected
|
||||
|
||||
private val _statusLog = MutableSharedFlow<String>(extraBufferCapacity = 32)
|
||||
override val statusLog: SharedFlow<String> = _statusLog
|
||||
|
||||
private val _paymentReceived = MutableSharedFlow<Long>(extraBufferCapacity = 8)
|
||||
override val paymentReceived: SharedFlow<Long> = _paymentReceived
|
||||
|
||||
private fun emitStatus(msg: String) {
|
||||
Log.d(TAG, msg)
|
||||
_statusLog.tryEmit(msg)
|
||||
}
|
||||
|
||||
// --- Mnemonic management ---
|
||||
|
||||
fun hasMnemonic(): Boolean = encPrefs.getString("spark_mnemonic", null) != null
|
||||
|
||||
override fun hasConnection(): Boolean = hasMnemonic()
|
||||
|
||||
fun newMnemonic(): String {
|
||||
val wordlist = BIP39_WORDS
|
||||
if (wordlist.size < 2048) {
|
||||
// Fallback: generate 16 random bytes and hex-encode as a placeholder.
|
||||
// The user should provide a proper BIP39 mnemonic via restore.
|
||||
error("BIP39 wordlist not available. Bundle bip39-english.txt in resources.")
|
||||
}
|
||||
val random = SecureRandom()
|
||||
val entropy = ByteArray(16) // 128 bits → 12 words
|
||||
random.nextBytes(entropy)
|
||||
return entropyToMnemonic(entropy, wordlist)
|
||||
}
|
||||
|
||||
private fun entropyToMnemonic(entropy: ByteArray, wordlist: List<String>): String {
|
||||
// SHA-256 hash of entropy for checksum
|
||||
val hash = java.security.MessageDigest.getInstance("SHA-256").digest(entropy)
|
||||
val checksumBits = entropy.size / 4 // 4 bits for 16 bytes
|
||||
|
||||
// Convert entropy + checksum to bits
|
||||
val bits = StringBuilder()
|
||||
for (b in entropy) bits.append(String.format("%8s", Integer.toBinaryString(b.toInt() and 0xFF)).replace(' ', '0'))
|
||||
val hashBits = String.format("%8s", Integer.toBinaryString(hash[0].toInt() and 0xFF)).replace(' ', '0')
|
||||
bits.append(hashBits.substring(0, checksumBits))
|
||||
|
||||
// Split into 11-bit groups
|
||||
val words = mutableListOf<String>()
|
||||
val bitStr = bits.toString()
|
||||
for (i in bitStr.indices step 11) {
|
||||
val end = minOf(i + 11, bitStr.length)
|
||||
val index = Integer.parseInt(bitStr.substring(i, end), 2)
|
||||
words.add(wordlist[index])
|
||||
}
|
||||
return words.joinToString(" ")
|
||||
}
|
||||
|
||||
fun saveMnemonic(mnemonic: String) {
|
||||
encPrefs.edit().putString("spark_mnemonic", mnemonic).apply()
|
||||
}
|
||||
|
||||
fun getMnemonic(): String? = encPrefs.getString("spark_mnemonic", null)
|
||||
|
||||
fun clearMnemonic() {
|
||||
encPrefs.edit().remove("spark_mnemonic").apply()
|
||||
_balance.value = null
|
||||
_isConnected.value = false
|
||||
}
|
||||
|
||||
// --- SDK lifecycle ---
|
||||
|
||||
private val storageDir: File
|
||||
get() = File(context.filesDir, "spark_data").also { it.mkdirs() }
|
||||
|
||||
override fun connect() {
|
||||
val mnemonic = getMnemonic() ?: run {
|
||||
emitStatus("No mnemonic configured")
|
||||
return
|
||||
}
|
||||
|
||||
scope?.cancel()
|
||||
val newScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
scope = newScope
|
||||
|
||||
newScope.launch {
|
||||
try {
|
||||
emitStatus("Initializing Spark SDK...")
|
||||
|
||||
val config = defaultConfig(Network.MAINNET)
|
||||
config.apiKey = BREEZ_API_KEY
|
||||
|
||||
val seed = Seed.Mnemonic(mnemonic, null)
|
||||
val request = ConnectRequest(
|
||||
config = config,
|
||||
seed = seed,
|
||||
storageDir = storageDir.absolutePath
|
||||
)
|
||||
|
||||
val instance = connect(request)
|
||||
sdk = instance
|
||||
|
||||
// Register event listener
|
||||
val listener = object : EventListener {
|
||||
override suspend fun onEvent(e: SdkEvent) {
|
||||
when (e) {
|
||||
is SdkEvent.Synced -> {
|
||||
emitStatus("Synced")
|
||||
}
|
||||
is SdkEvent.PaymentSucceeded -> {
|
||||
emitStatus("Payment succeeded")
|
||||
refreshBalanceInternal()
|
||||
if (e.payment.paymentType == PaymentType.RECEIVE) {
|
||||
_paymentReceived.tryEmit(e.payment.amount.toLong() * 1000)
|
||||
}
|
||||
}
|
||||
is SdkEvent.PaymentFailed -> {
|
||||
emitStatus("Payment failed")
|
||||
}
|
||||
is SdkEvent.PaymentPending -> {
|
||||
emitStatus("Payment pending")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
eventListenerId = instance.addEventListener(listener)
|
||||
|
||||
_isConnected.value = true
|
||||
emitStatus("Connected to Spark")
|
||||
|
||||
refreshBalanceInternal()
|
||||
} catch (e: Exception) {
|
||||
emitStatus("Connection failed: ${e.message}")
|
||||
Log.e(TAG, "Spark connect failed", e)
|
||||
_isConnected.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
val instance = sdk
|
||||
val listenerId = eventListenerId
|
||||
sdk = null
|
||||
eventListenerId = null
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
_isConnected.value = false
|
||||
|
||||
// Clean up native SDK on a standalone scope so cancelling our main scope
|
||||
// doesn't kill the teardown coroutine
|
||||
if (instance != null) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (listenerId != null) {
|
||||
instance.removeEventListener(listenerId)
|
||||
}
|
||||
instance.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Spark disconnect error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Balance ---
|
||||
|
||||
private suspend fun refreshBalanceInternal() {
|
||||
try {
|
||||
val instance = sdk ?: return
|
||||
val info = instance.getInfo(GetInfoRequest(ensureSynced = false))
|
||||
_balance.value = info.balanceSats.toLong() * 1000 // convert sats to msats
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to refresh balance", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchBalance(): Result<Long> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val instance = sdk ?: return@withContext Result.failure(Exception("Not connected"))
|
||||
val info = instance.getInfo(GetInfoRequest(ensureSynced = false))
|
||||
val balanceMsats = info.balanceSats.toLong() * 1000
|
||||
_balance.value = balanceMsats
|
||||
Result.success(balanceMsats)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send ---
|
||||
|
||||
override suspend fun payInvoice(bolt11: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val instance = sdk ?: return@withContext Result.failure(Exception("Not connected"))
|
||||
emitStatus("Preparing payment...")
|
||||
|
||||
val prepareReq = PrepareSendPaymentRequest(paymentRequest = bolt11)
|
||||
val prepareResponse = instance.prepareSendPayment(prepareReq)
|
||||
|
||||
emitStatus("Sending payment...")
|
||||
val options = SendPaymentOptions.Bolt11Invoice(
|
||||
preferSpark = false,
|
||||
completionTimeoutSecs = 30u
|
||||
)
|
||||
val sendResponse = instance.sendPayment(
|
||||
SendPaymentRequest(prepareResponse, options)
|
||||
)
|
||||
|
||||
val paymentId = sendResponse.payment.id
|
||||
emitStatus("Payment sent")
|
||||
Result.success(paymentId)
|
||||
} catch (e: Exception) {
|
||||
emitStatus("Payment failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Receive ---
|
||||
|
||||
override suspend fun makeInvoice(amountMsats: Long, description: String): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val instance = sdk ?: return@withContext Result.failure(Exception("Not connected"))
|
||||
emitStatus("Creating invoice...")
|
||||
|
||||
val amountSats = (amountMsats / 1000).toULong()
|
||||
val method = ReceivePaymentMethod.Bolt11Invoice(
|
||||
description = description.ifEmpty { "Wisp wallet" },
|
||||
amountSats = amountSats,
|
||||
expirySecs = 3600u,
|
||||
paymentHash = null
|
||||
)
|
||||
val response = instance.receivePayment(ReceivePaymentRequest(method))
|
||||
emitStatus("Invoice created")
|
||||
Result.success(response.paymentRequest)
|
||||
} catch (e: Exception) {
|
||||
emitStatus("Invoice creation failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sync polling ---
|
||||
|
||||
/** Trigger an SDK sync to speed up payment detection. */
|
||||
suspend fun syncWallet() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
sdk?.syncWallet(SyncWalletRequest)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Sync failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Transactions ---
|
||||
|
||||
override suspend fun listTransactions(limit: Int): Result<List<WalletTransaction>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val instance = sdk ?: return@withContext Result.failure(Exception("Not connected"))
|
||||
val response = instance.listPayments(ListPaymentsRequest(
|
||||
limit = limit.toUInt(),
|
||||
sortAscending = false
|
||||
))
|
||||
val transactions = response.payments.map { payment ->
|
||||
val description = when (val details = payment.details) {
|
||||
is PaymentDetails.Lightning -> details.description
|
||||
else -> null
|
||||
}
|
||||
WalletTransaction(
|
||||
type = when (payment.paymentType) {
|
||||
PaymentType.SEND -> "outgoing"
|
||||
else -> "incoming"
|
||||
},
|
||||
description = description,
|
||||
paymentHash = payment.id,
|
||||
amountMsats = payment.amount.toLong() * 1000,
|
||||
createdAt = payment.timestamp.toLong(),
|
||||
settledAt = payment.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
Result.success(transactions)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.wisp.app.repo
|
||||
|
||||
import android.content.Context
|
||||
|
||||
enum class WalletMode { NONE, NWC, SPARK }
|
||||
|
||||
class WalletModeRepository(context: Context, pubkeyHex: String? = null) {
|
||||
|
||||
private val prefs = context.getSharedPreferences(
|
||||
if (pubkeyHex != null) "wisp_wallet_mode_$pubkeyHex" else "wisp_wallet_mode",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
fun getMode(): WalletMode {
|
||||
val name = prefs.getString("wallet_mode", null) ?: return WalletMode.NONE
|
||||
return try { WalletMode.valueOf(name) } catch (_: Exception) { WalletMode.NONE }
|
||||
}
|
||||
|
||||
fun setMode(mode: WalletMode) {
|
||||
prefs.edit().putString("wallet_mode", mode.name).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.wisp.app.repo
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface WalletProvider {
|
||||
val balance: StateFlow<Long?>
|
||||
val isConnected: StateFlow<Boolean>
|
||||
val statusLog: SharedFlow<String>
|
||||
|
||||
/** Emits the amount in msats whenever an incoming payment is received. */
|
||||
val paymentReceived: SharedFlow<Long>
|
||||
|
||||
fun hasConnection(): Boolean
|
||||
fun connect()
|
||||
fun disconnect()
|
||||
suspend fun fetchBalance(): Result<Long>
|
||||
suspend fun payInvoice(bolt11: String): Result<String>
|
||||
suspend fun makeInvoice(amountMsats: Long, description: String): Result<String>
|
||||
suspend fun listTransactions(limit: Int = 50): Result<List<WalletTransaction>>
|
||||
}
|
||||
|
||||
data class WalletTransaction(
|
||||
val type: String,
|
||||
val description: String?,
|
||||
val paymentHash: String,
|
||||
val amountMsats: Long,
|
||||
val createdAt: Long,
|
||||
val settledAt: Long?
|
||||
)
|
||||
@@ -8,7 +8,7 @@ import okhttp3.OkHttpClient
|
||||
|
||||
class ZapSender(
|
||||
private val keyRepo: KeyRepository,
|
||||
private val nwcRepo: NwcRepository,
|
||||
private val getWalletProvider: () -> WalletProvider,
|
||||
private val relayPool: RelayPool,
|
||||
private val relayListRepo: RelayListRepository,
|
||||
private val httpClient: OkHttpClient
|
||||
@@ -99,8 +99,8 @@ class ZapSender(
|
||||
val bolt11 = Nip57.fetchInvoice(payInfo.callback, amountMsats, zapRequest, httpClient)
|
||||
?: return Result.failure(Exception("Could not get invoice from lightning provider"))
|
||||
|
||||
// 4. Pay via NWC
|
||||
val payResult = nwcRepo.payInvoice(bolt11)
|
||||
// 4. Pay via wallet
|
||||
val payResult = getWalletProvider().payInvoice(bolt11)
|
||||
return payResult.map { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,8 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.wisp.app.nostr.Nip47
|
||||
import com.wisp.app.repo.WalletMode
|
||||
import com.wisp.app.repo.WalletTransaction
|
||||
import com.wisp.app.ui.component.SatsNumpad
|
||||
import com.wisp.app.viewmodel.WalletPage
|
||||
import com.wisp.app.viewmodel.WalletState
|
||||
@@ -111,10 +112,8 @@ fun WalletScreen(
|
||||
title = { Text("Wallet") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
if (walletState !is WalletState.Connected || viewModel.isOnHome) {
|
||||
if (!viewModel.navigateBack()) {
|
||||
onBack()
|
||||
} else {
|
||||
viewModel.navigateBack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
@@ -137,21 +136,60 @@ fun WalletScreen(
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
WalletConnectionContent(
|
||||
walletState = walletState,
|
||||
connectionString = viewModel.connectionString.collectAsState().value,
|
||||
statusLines = viewModel.statusLines.collectAsState().value,
|
||||
onConnectionStringChange = { viewModel.updateConnectionString(it) },
|
||||
onConnect = { viewModel.connectWallet() },
|
||||
onDisconnect = { viewModel.disconnectWallet() }
|
||||
)
|
||||
when (currentPage) {
|
||||
is WalletPage.NwcSetup -> WalletConnectionContent(
|
||||
walletState = walletState,
|
||||
connectionString = viewModel.connectionString.collectAsState().value,
|
||||
statusLines = viewModel.statusLines.collectAsState().value,
|
||||
onConnectionStringChange = { viewModel.updateConnectionString(it) },
|
||||
onConnect = { viewModel.connectNwcWallet() },
|
||||
onDisconnect = { viewModel.disconnectWallet() }
|
||||
)
|
||||
is WalletPage.SparkSetup -> SparkSetupContent(
|
||||
walletState = walletState,
|
||||
statusLines = viewModel.statusLines.collectAsState().value,
|
||||
restoreMnemonic = viewModel.restoreMnemonic.collectAsState().value,
|
||||
error = viewModel.sendError.collectAsState().value,
|
||||
onCreateWallet = { viewModel.generateSparkWallet() },
|
||||
onRestoreMnemonicChange = { viewModel.updateRestoreMnemonic(it) },
|
||||
onRestoreWallet = { viewModel.restoreSparkWallet() },
|
||||
onDisconnect = { viewModel.disconnectWallet() }
|
||||
)
|
||||
is WalletPage.SparkBackup -> {
|
||||
val page = currentPage as WalletPage.SparkBackup
|
||||
SparkBackupContent(
|
||||
mnemonic = page.mnemonic,
|
||||
onConfirm = { viewModel.confirmSparkBackup() }
|
||||
)
|
||||
}
|
||||
else -> WalletModeSelectionContent(
|
||||
onSelectNwc = { viewModel.selectNwcMode() },
|
||||
onSelectSpark = { viewModel.selectSparkMode() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is WalletState.Connected -> {
|
||||
val balanceMsats = (walletState as WalletState.Connected).balanceMsats
|
||||
when (currentPage) {
|
||||
is WalletPage.SparkBackup -> {
|
||||
val page = currentPage as WalletPage.SparkBackup
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SparkBackupContent(
|
||||
mnemonic = page.mnemonic,
|
||||
onConfirm = { viewModel.navigateHome() }
|
||||
)
|
||||
}
|
||||
}
|
||||
is WalletPage.Home -> WalletHomeContent(
|
||||
balanceMsats = balanceMsats,
|
||||
walletMode = viewModel.walletMode.collectAsState().value,
|
||||
onSend = { viewModel.navigateTo(WalletPage.SendInput) },
|
||||
onReceive = {
|
||||
viewModel.navigateTo(WalletPage.ReceiveAmount)
|
||||
@@ -162,6 +200,9 @@ fun WalletScreen(
|
||||
},
|
||||
onRefresh = { viewModel.refreshBalance() },
|
||||
onDisconnect = { viewModel.disconnectWallet() },
|
||||
onBackupMnemonic = if (viewModel.walletMode.collectAsState().value == WalletMode.SPARK) {
|
||||
{ viewModel.showMnemonicBackup() }
|
||||
} else null,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
is WalletPage.SendInput -> SendInputContent(
|
||||
@@ -230,12 +271,36 @@ fun WalletScreen(
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
is WalletPage.ReceiveSuccess -> {
|
||||
val page = currentPage as WalletPage.ReceiveSuccess
|
||||
ReceiveSuccessContent(
|
||||
amountSats = page.amountSats,
|
||||
onDone = { viewModel.navigateHome() },
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
is WalletPage.Transactions -> TransactionHistoryContent(
|
||||
transactions = viewModel.transactions.collectAsState().value,
|
||||
error = viewModel.transactionsError.collectAsState().value,
|
||||
isLoading = viewModel.isLoading.collectAsState().value,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
else -> {
|
||||
// ModeSelection, NwcSetup, SparkSetup — shouldn't appear while connected
|
||||
WalletHomeContent(
|
||||
balanceMsats = balanceMsats,
|
||||
walletMode = viewModel.walletMode.collectAsState().value,
|
||||
onSend = { viewModel.navigateTo(WalletPage.SendInput) },
|
||||
onReceive = { viewModel.navigateTo(WalletPage.ReceiveAmount) },
|
||||
onTransactions = {
|
||||
viewModel.loadTransactions()
|
||||
viewModel.navigateTo(WalletPage.Transactions)
|
||||
},
|
||||
onRefresh = { viewModel.refreshBalance() },
|
||||
onDisconnect = { viewModel.disconnectWallet() },
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,11 +459,13 @@ private fun WalletConnectionContent(
|
||||
@Composable
|
||||
private fun WalletHomeContent(
|
||||
balanceMsats: Long,
|
||||
walletMode: WalletMode = WalletMode.NWC,
|
||||
onSend: () -> Unit,
|
||||
onReceive: () -> Unit,
|
||||
onTransactions: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onBackupMnemonic: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val balanceSats = balanceMsats / 1000
|
||||
@@ -482,8 +549,22 @@ private fun WalletHomeContent(
|
||||
Text("Transaction History")
|
||||
}
|
||||
|
||||
if (onBackupMnemonic != null) {
|
||||
TextButton(onClick = onBackupMnemonic) {
|
||||
Text("Backup Recovery Phrase")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
if (walletMode == WalletMode.SPARK) "Spark Wallet" else "NWC",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = onDisconnect,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
@@ -949,11 +1030,60 @@ private fun ReceiveInvoiceContent(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Receive Success ---
|
||||
|
||||
@Composable
|
||||
private fun ReceiveSuccessContent(
|
||||
amountSats: Long,
|
||||
onDone: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
||||
.padding(12.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
"Payment Received",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
"%,d sats".format(amountSats),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Button(onClick = onDone) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Transaction History ---
|
||||
|
||||
@Composable
|
||||
private fun TransactionHistoryContent(
|
||||
transactions: List<Nip47.Transaction>,
|
||||
transactions: List<WalletTransaction>,
|
||||
error: String?,
|
||||
isLoading: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -1025,9 +1155,9 @@ private fun TransactionHistoryContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionRow(tx: Nip47.Transaction) {
|
||||
private fun TransactionRow(tx: WalletTransaction) {
|
||||
val isIncoming = tx.type == "incoming"
|
||||
val amountSats = tx.amount / 1000
|
||||
val amountSats = tx.amountMsats / 1000
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -1088,6 +1218,273 @@ private fun TransactionRow(tx: Nip47.Transaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wallet Mode Selection ---
|
||||
|
||||
@Composable
|
||||
private fun WalletModeSelectionContent(
|
||||
onSelectNwc: () -> Unit,
|
||||
onSelectSpark: () -> Unit
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
"Connect a Wallet",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Choose how to connect a Lightning wallet for sending and receiving sats.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectSpark() },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Spark Wallet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Non-custodial Lightning wallet built into the app. No external wallet needed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectNwc() },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Nostr Wallet Connect",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Connect an external Lightning wallet using a NWC connection string.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
// --- Spark Setup ---
|
||||
|
||||
@Composable
|
||||
private fun SparkSetupContent(
|
||||
walletState: WalletState,
|
||||
statusLines: List<String>,
|
||||
restoreMnemonic: String,
|
||||
error: String?,
|
||||
onCreateWallet: () -> Unit,
|
||||
onRestoreMnemonicChange: (String) -> Unit,
|
||||
onRestoreWallet: () -> Unit,
|
||||
onDisconnect: () -> Unit
|
||||
) {
|
||||
val isConnecting = walletState is WalletState.Connecting
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
"Spark Wallet",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Create a new non-custodial Lightning wallet or restore from a recovery phrase.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
if (!isConnecting) {
|
||||
Button(
|
||||
onClick = onCreateWallet,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Create New Wallet")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
"Or restore an existing wallet",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = restoreMnemonic,
|
||||
onValueChange = onRestoreMnemonicChange,
|
||||
label = { Text("Recovery phrase") },
|
||||
placeholder = { Text("Enter 12 or 24 words...") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = false,
|
||||
maxLines = 3
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onRestoreWallet,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = restoreMnemonic.isNotBlank()
|
||||
) {
|
||||
Text("Restore Wallet")
|
||||
}
|
||||
}
|
||||
|
||||
if (walletState is WalletState.Error) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
walletState.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Connecting...", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
if (statusLines.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
statusLines.forEach { line ->
|
||||
Text(
|
||||
line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = onDisconnect,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
// --- Spark Backup ---
|
||||
|
||||
@Composable
|
||||
private fun SparkBackupContent(
|
||||
mnemonic: String,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
val words = mnemonic.split(" ")
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
"Recovery Phrase",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Write down these words in order and store them safely. This is the only way to recover your wallet.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Display words in two columns
|
||||
for (i in words.indices step 2) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
"${i + 1}. ${words[i]}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (i + 1 < words.size) {
|
||||
Text(
|
||||
"${i + 2}. ${words[i + 1]}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("I've backed this up")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
private fun formatRelativeTime(timestamp: Long): String {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val diff = now - timestamp
|
||||
|
||||
@@ -37,6 +37,10 @@ import com.wisp.app.repo.NotificationRepository
|
||||
import com.wisp.app.repo.PinRepository
|
||||
import com.wisp.app.repo.ProfileRepository
|
||||
import com.wisp.app.repo.NwcRepository
|
||||
import com.wisp.app.repo.SparkRepository
|
||||
import com.wisp.app.repo.WalletMode
|
||||
import com.wisp.app.repo.WalletModeRepository
|
||||
import com.wisp.app.repo.WalletProvider
|
||||
import com.wisp.app.repo.CustomEmojiRepository
|
||||
import com.wisp.app.repo.PowPreferences
|
||||
import com.wisp.app.repo.ZapPreferences
|
||||
@@ -187,7 +191,16 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
val nwcRepo = NwcRepository(app, relayPool, pubkeyHex)
|
||||
val zapSender = ZapSender(keyRepo, nwcRepo, relayPool, relayListRepo, HttpClientFactory.createRelayClient())
|
||||
val sparkRepo = SparkRepository(app, pubkeyHex)
|
||||
val walletModeRepo = WalletModeRepository(app, pubkeyHex)
|
||||
|
||||
val activeWalletProvider: WalletProvider
|
||||
get() = when (walletModeRepo.getMode()) {
|
||||
WalletMode.SPARK -> sparkRepo
|
||||
else -> nwcRepo
|
||||
}
|
||||
|
||||
val zapSender = ZapSender(keyRepo, { activeWalletProvider }, relayPool, relayListRepo, HttpClientFactory.createRelayClient())
|
||||
val powManager = PowManager(powPrefs, relayPool, outboxRouter, eventRepo, viewModelScope)
|
||||
|
||||
// -- Manager classes --
|
||||
@@ -210,7 +223,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
val socialActions: SocialActionManager = SocialActionManager(
|
||||
relayPool, outboxRouter, eventRepo, contactRepo, muteRepo, notifRepo, dmRepo,
|
||||
pinRepo, deletedEventsRepo, nwcRepo, customEmojiRepo, zapSender, powPrefs, viewModelScope,
|
||||
pinRepo, deletedEventsRepo, { activeWalletProvider }, customEmojiRepo, zapSender, powPrefs, viewModelScope,
|
||||
getSigner = { signer },
|
||||
getUserPubkey = { getUserPubkey() }
|
||||
)
|
||||
@@ -539,6 +552,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
nwcRepo.disconnect()
|
||||
sparkRepo.disconnect()
|
||||
relayPool.disconnectAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import com.wisp.app.repo.DmRepository
|
||||
import com.wisp.app.repo.EventRepository
|
||||
import com.wisp.app.repo.MuteRepository
|
||||
import com.wisp.app.repo.NotificationRepository
|
||||
import com.wisp.app.repo.NwcRepository
|
||||
import com.wisp.app.repo.WalletProvider
|
||||
import com.wisp.app.repo.PinRepository
|
||||
import com.wisp.app.repo.CustomEmojiRepository
|
||||
import com.wisp.app.repo.DeletedEventsRepository
|
||||
@@ -49,7 +49,7 @@ class SocialActionManager(
|
||||
private val dmRepo: DmRepository,
|
||||
private val pinRepo: PinRepository,
|
||||
private val deletedEventsRepo: DeletedEventsRepository,
|
||||
private val nwcRepo: NwcRepository,
|
||||
private val getWalletProvider: () -> WalletProvider,
|
||||
private val customEmojiRepo: CustomEmojiRepository,
|
||||
private val zapSender: ZapSender,
|
||||
private val powPrefs: PowPreferences,
|
||||
@@ -261,9 +261,10 @@ class SocialActionManager(
|
||||
_zapError.tryEmit("This user has no lightning address")
|
||||
return
|
||||
}
|
||||
// Reconnect NWC relay if credentials exist but relay disconnected
|
||||
if (nwcRepo.hasConnection() && !nwcRepo.isConnected.value) {
|
||||
nwcRepo.connect()
|
||||
// Reconnect wallet if credentials exist but not connected
|
||||
val wallet = getWalletProvider()
|
||||
if (wallet.hasConnection() && !wallet.isConnected.value) {
|
||||
wallet.connect()
|
||||
}
|
||||
scope.launch {
|
||||
_zapInProgress.value = _zapInProgress.value + event.id
|
||||
|
||||
@@ -3,10 +3,15 @@ package com.wisp.app.viewmodel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wisp.app.nostr.Bolt11
|
||||
import com.wisp.app.nostr.Nip47
|
||||
import com.wisp.app.nostr.Nip57
|
||||
import com.wisp.app.repo.NwcRepository
|
||||
import com.wisp.app.repo.SparkRepository
|
||||
import com.wisp.app.repo.WalletMode
|
||||
import com.wisp.app.repo.WalletModeRepository
|
||||
import com.wisp.app.repo.WalletProvider
|
||||
import com.wisp.app.repo.WalletTransaction
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -21,6 +26,10 @@ sealed class WalletState {
|
||||
|
||||
sealed class WalletPage {
|
||||
object Home : WalletPage()
|
||||
object ModeSelection : WalletPage()
|
||||
object NwcSetup : WalletPage()
|
||||
object SparkSetup : WalletPage()
|
||||
data class SparkBackup(val mnemonic: String) : WalletPage()
|
||||
object SendInput : WalletPage()
|
||||
data class SendAmount(val address: String) : WalletPage()
|
||||
data class SendConfirm(
|
||||
@@ -33,13 +42,27 @@ sealed class WalletPage {
|
||||
data class SendResult(val success: Boolean, val message: String) : WalletPage()
|
||||
object ReceiveAmount : WalletPage()
|
||||
data class ReceiveInvoice(val invoice: String, val amountSats: Long) : WalletPage()
|
||||
data class ReceiveSuccess(val amountSats: Long) : WalletPage()
|
||||
object Transactions : WalletPage()
|
||||
}
|
||||
|
||||
class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
class WalletViewModel(
|
||||
val nwcRepo: NwcRepository,
|
||||
val sparkRepo: SparkRepository,
|
||||
val walletModeRepo: WalletModeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _walletMode = MutableStateFlow(walletModeRepo.getMode())
|
||||
val walletMode: StateFlow<WalletMode> = _walletMode
|
||||
|
||||
private val activeProvider: WalletProvider
|
||||
get() = when (_walletMode.value) {
|
||||
WalletMode.SPARK -> sparkRepo
|
||||
else -> nwcRepo
|
||||
}
|
||||
|
||||
private val _walletState = MutableStateFlow<WalletState>(
|
||||
if (nwcRepo.hasConnection()) WalletState.Connecting else WalletState.NotConnected
|
||||
if (activeProvider.hasConnection()) WalletState.Connecting else WalletState.NotConnected
|
||||
)
|
||||
val walletState: StateFlow<WalletState> = _walletState
|
||||
|
||||
@@ -70,8 +93,8 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
val receiveAmount: StateFlow<String> = _receiveAmount
|
||||
|
||||
// Transactions
|
||||
private val _transactions = MutableStateFlow<List<Nip47.Transaction>>(emptyList())
|
||||
val transactions: StateFlow<List<Nip47.Transaction>> = _transactions
|
||||
private val _transactions = MutableStateFlow<List<WalletTransaction>>(emptyList())
|
||||
val transactions: StateFlow<List<WalletTransaction>> = _transactions
|
||||
|
||||
private val _transactionsError = MutableStateFlow<String?>(null)
|
||||
val transactionsError: StateFlow<String?> = _transactionsError
|
||||
@@ -79,17 +102,58 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
// Spark setup
|
||||
private val _restoreMnemonic = MutableStateFlow("")
|
||||
val restoreMnemonic: StateFlow<String> = _restoreMnemonic
|
||||
|
||||
private var connectJob: Job? = null
|
||||
private var statusCollectJob: Job? = null
|
||||
private var connectionMonitorJob: Job? = null
|
||||
private var syncPollJob: Job? = null
|
||||
private val httpClient get() = com.wisp.app.relay.HttpClientFactory.createRelayClient()
|
||||
|
||||
init {
|
||||
// Connection only happens when the wallet tab is opened (here) or
|
||||
// on-demand when sending a zap (FeedViewModel.sendZap handles that).
|
||||
if (nwcRepo.hasConnection()) {
|
||||
_connectionString.value = nwcRepo.getConnectionString() ?: ""
|
||||
connectWallet(nwcRepo.getConnectionString() ?: "")
|
||||
val mode = walletModeRepo.getMode()
|
||||
when (mode) {
|
||||
WalletMode.NWC -> {
|
||||
if (nwcRepo.hasConnection()) {
|
||||
_connectionString.value = nwcRepo.getConnectionString() ?: ""
|
||||
connectNwcWallet(nwcRepo.getConnectionString() ?: "")
|
||||
}
|
||||
}
|
||||
WalletMode.SPARK -> {
|
||||
if (sparkRepo.hasMnemonic()) {
|
||||
connectSparkWallet()
|
||||
}
|
||||
}
|
||||
WalletMode.NONE -> {}
|
||||
}
|
||||
|
||||
// Auto-navigate to success screen when an incoming payment is received
|
||||
viewModelScope.launch {
|
||||
sparkRepo.paymentReceived.collect { amountMsats ->
|
||||
if (_currentPage.value is WalletPage.ReceiveInvoice) {
|
||||
stopSyncPolling()
|
||||
val amountSats = amountMsats / 1000
|
||||
pageStack.removeAt(pageStack.lastIndex)
|
||||
val successPage = WalletPage.ReceiveSuccess(amountSats)
|
||||
pageStack.add(successPage)
|
||||
_currentPage.value = successPage
|
||||
refreshBalance()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
nwcRepo.paymentReceived.collect { amountMsats ->
|
||||
if (_currentPage.value is WalletPage.ReceiveInvoice) {
|
||||
val amountSats = amountMsats / 1000
|
||||
pageStack.removeAt(pageStack.lastIndex)
|
||||
val successPage = WalletPage.ReceiveSuccess(amountSats)
|
||||
pageStack.add(successPage)
|
||||
_currentPage.value = successPage
|
||||
refreshBalance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +172,7 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
}
|
||||
|
||||
fun navigateHome() {
|
||||
stopSyncPolling()
|
||||
pageStack.clear()
|
||||
pageStack.add(WalletPage.Home)
|
||||
_currentPage.value = WalletPage.Home
|
||||
@@ -115,58 +180,123 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
_sendAmount.value = ""
|
||||
_sendError.value = null
|
||||
_receiveAmount.value = ""
|
||||
_restoreMnemonic.value = ""
|
||||
}
|
||||
|
||||
val isOnHome: Boolean get() = pageStack.size <= 1
|
||||
|
||||
// --- Connection ---
|
||||
// --- Wallet Mode Selection ---
|
||||
|
||||
fun selectNwcMode() {
|
||||
navigateTo(WalletPage.NwcSetup)
|
||||
}
|
||||
|
||||
fun selectSparkMode() {
|
||||
navigateTo(WalletPage.SparkSetup)
|
||||
}
|
||||
|
||||
// --- NWC Connection ---
|
||||
|
||||
fun updateConnectionString(value: String) {
|
||||
_connectionString.value = value
|
||||
}
|
||||
|
||||
fun connectWallet(uri: String = _connectionString.value, silent: Boolean = false) {
|
||||
fun connectNwcWallet(uri: String = _connectionString.value, silent: Boolean = false) {
|
||||
val trimmed = uri.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
|
||||
val parsed = Nip47.parseConnectionString(trimmed)
|
||||
val parsed = com.wisp.app.nostr.Nip47.parseConnectionString(trimmed)
|
||||
if (parsed == null) {
|
||||
_walletState.value = WalletState.Error("Invalid connection string")
|
||||
return
|
||||
}
|
||||
|
||||
walletModeRepo.setMode(WalletMode.NWC)
|
||||
_walletMode.value = WalletMode.NWC
|
||||
|
||||
_statusLines.value = emptyList()
|
||||
if (!silent) _walletState.value = WalletState.Connecting
|
||||
nwcRepo.saveConnectionString(trimmed)
|
||||
_connectionString.value = trimmed
|
||||
|
||||
startStatusCollection(nwcRepo)
|
||||
nwcRepo.connect()
|
||||
startConnectionMonitor(nwcRepo)
|
||||
}
|
||||
|
||||
// --- Spark Connection ---
|
||||
|
||||
fun generateSparkWallet() {
|
||||
val mnemonic = sparkRepo.newMnemonic()
|
||||
sparkRepo.saveMnemonic(mnemonic)
|
||||
connectSparkWallet()
|
||||
}
|
||||
|
||||
fun updateRestoreMnemonic(value: String) {
|
||||
_restoreMnemonic.value = value
|
||||
}
|
||||
|
||||
fun restoreSparkWallet(mnemonic: String = _restoreMnemonic.value) {
|
||||
val trimmed = mnemonic.trim().lowercase()
|
||||
val words = trimmed.split("\\s+".toRegex())
|
||||
if (words.size != 12 && words.size != 24) {
|
||||
_sendError.value = "Mnemonic must be 12 or 24 words"
|
||||
return
|
||||
}
|
||||
sparkRepo.saveMnemonic(trimmed)
|
||||
connectSparkWallet()
|
||||
}
|
||||
|
||||
fun confirmSparkBackup() {
|
||||
connectSparkWallet()
|
||||
}
|
||||
|
||||
private fun connectSparkWallet(silent: Boolean = false) {
|
||||
walletModeRepo.setMode(WalletMode.SPARK)
|
||||
_walletMode.value = WalletMode.SPARK
|
||||
|
||||
_statusLines.value = emptyList()
|
||||
if (!silent) _walletState.value = WalletState.Connecting
|
||||
|
||||
startStatusCollection(sparkRepo)
|
||||
sparkRepo.connect()
|
||||
startConnectionMonitor(sparkRepo)
|
||||
}
|
||||
|
||||
fun showMnemonicBackup() {
|
||||
val mnemonic = sparkRepo.getMnemonic() ?: return
|
||||
navigateTo(WalletPage.SparkBackup(mnemonic))
|
||||
}
|
||||
|
||||
// --- Shared connection helpers ---
|
||||
|
||||
private fun startStatusCollection(provider: WalletProvider) {
|
||||
statusCollectJob?.cancel()
|
||||
statusCollectJob = viewModelScope.launch {
|
||||
nwcRepo.statusLog.collect { line ->
|
||||
provider.statusLog.collect { line ->
|
||||
_statusLines.value = _statusLines.value + line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nwcRepo.connect()
|
||||
|
||||
// Initial connection timeout
|
||||
private fun startConnectionMonitor(provider: WalletProvider) {
|
||||
connectJob?.cancel()
|
||||
val timeoutMs = if (_walletMode.value == WalletMode.SPARK) 60_000L else 20_000L
|
||||
connectJob = viewModelScope.launch {
|
||||
val connected = kotlinx.coroutines.withTimeoutOrNull(10_000) {
|
||||
nwcRepo.isConnected.first { it }
|
||||
val connected = kotlinx.coroutines.withTimeoutOrNull(timeoutMs) {
|
||||
provider.isConnected.first { it }
|
||||
}
|
||||
if (connected == null && _walletState.value !is WalletState.Connected) {
|
||||
_statusLines.value = _statusLines.value + "Connection timed out (10s)"
|
||||
_statusLines.value = _statusLines.value + "Connection timed out"
|
||||
_walletState.value = WalletState.Error("Connection timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent monitor: fetch balance on connect/reconnect
|
||||
connectionMonitorJob?.cancel()
|
||||
connectionMonitorJob = viewModelScope.launch {
|
||||
nwcRepo.isConnected.collect { connected ->
|
||||
provider.isConnected.collect { connected ->
|
||||
if (connected) {
|
||||
val result = nwcRepo.fetchBalance()
|
||||
val result = provider.fetchBalance()
|
||||
result.fold(
|
||||
onSuccess = { balanceMsats ->
|
||||
_walletState.value = WalletState.Connected(balanceMsats)
|
||||
@@ -176,17 +306,13 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
}
|
||||
)
|
||||
}
|
||||
// Note: we intentionally do NOT set state back to Connecting when
|
||||
// the relay disconnects. The balance display stays visible with the
|
||||
// last known value. refreshState() (called on screen entry) will
|
||||
// reconnect and fetch a fresh balance as needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshBalance() {
|
||||
viewModelScope.launch {
|
||||
val result = nwcRepo.fetchBalance()
|
||||
val result = activeProvider.fetchBalance()
|
||||
result.fold(
|
||||
onSuccess = { balanceMsats ->
|
||||
_walletState.value = WalletState.Connected(balanceMsats)
|
||||
@@ -202,8 +328,21 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
connectJob?.cancel()
|
||||
statusCollectJob?.cancel()
|
||||
connectionMonitorJob?.cancel()
|
||||
nwcRepo.disconnect()
|
||||
nwcRepo.clearConnection()
|
||||
|
||||
when (_walletMode.value) {
|
||||
WalletMode.NWC -> {
|
||||
nwcRepo.disconnect()
|
||||
nwcRepo.clearConnection()
|
||||
}
|
||||
WalletMode.SPARK -> {
|
||||
sparkRepo.disconnect()
|
||||
sparkRepo.clearMnemonic()
|
||||
}
|
||||
WalletMode.NONE -> {}
|
||||
}
|
||||
|
||||
walletModeRepo.setMode(WalletMode.NONE)
|
||||
_walletMode.value = WalletMode.NONE
|
||||
_walletState.value = WalletState.NotConnected
|
||||
_connectionString.value = ""
|
||||
_statusLines.value = emptyList()
|
||||
@@ -211,24 +350,29 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
}
|
||||
|
||||
fun refreshState() {
|
||||
if (!nwcRepo.hasConnection()) {
|
||||
val mode = _walletMode.value
|
||||
val provider = activeProvider
|
||||
|
||||
if (!provider.hasConnection()) {
|
||||
_walletState.value = WalletState.NotConnected
|
||||
_connectionString.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
if (nwcRepo.isConnected.value) {
|
||||
// Relay is connected — just refresh the balance. Keep the current
|
||||
// state visible (no flash to Connecting).
|
||||
if (provider.isConnected.value) {
|
||||
refreshBalance()
|
||||
} else if (_walletState.value is WalletState.Connected) {
|
||||
// Was previously connected but relay dropped — reconnect silently
|
||||
// while keeping the last known balance visible. The monitor will
|
||||
// update to Connected once balance is fetched.
|
||||
connectWallet(nwcRepo.getConnectionString() ?: "", silent = true)
|
||||
// Was previously connected — reconnect silently
|
||||
when (mode) {
|
||||
WalletMode.NWC -> connectNwcWallet(nwcRepo.getConnectionString() ?: "", silent = true)
|
||||
WalletMode.SPARK -> connectSparkWallet(silent = true)
|
||||
WalletMode.NONE -> {}
|
||||
}
|
||||
} else {
|
||||
// No prior connected state — full connect flow with Connecting UI.
|
||||
connectWallet(nwcRepo.getConnectionString() ?: "")
|
||||
when (mode) {
|
||||
WalletMode.NWC -> connectNwcWallet(nwcRepo.getConnectionString() ?: "")
|
||||
WalletMode.SPARK -> connectSparkWallet()
|
||||
WalletMode.NONE -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,10 +472,9 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
fun payInvoice(invoice: String) {
|
||||
navigateTo(WalletPage.Sending(invoice))
|
||||
viewModelScope.launch {
|
||||
val result = nwcRepo.payInvoice(invoice)
|
||||
val result = activeProvider.payInvoice(invoice)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
// Replace Sending page with result
|
||||
pageStack.removeAt(pageStack.lastIndex)
|
||||
val resultPage = WalletPage.SendResult(true, "Payment sent!")
|
||||
pageStack.add(resultPage)
|
||||
@@ -367,10 +510,11 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
fun generateInvoice(amountSats: Long) {
|
||||
_isLoading.value = true
|
||||
viewModelScope.launch {
|
||||
val result = nwcRepo.makeInvoice(amountSats * 1000, "")
|
||||
val result = activeProvider.makeInvoice(amountSats * 1000, "")
|
||||
result.fold(
|
||||
onSuccess = { invoice ->
|
||||
navigateTo(WalletPage.ReceiveInvoice(invoice, amountSats))
|
||||
startSyncPolling()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_sendError.value = e.message ?: "Failed to create invoice"
|
||||
@@ -386,7 +530,7 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
_isLoading.value = true
|
||||
_transactionsError.value = null
|
||||
viewModelScope.launch {
|
||||
val result = nwcRepo.listTransactions()
|
||||
val result = activeProvider.listTransactions()
|
||||
result.fold(
|
||||
onSuccess = { txs ->
|
||||
_transactions.value = txs
|
||||
@@ -398,4 +542,22 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sync polling for receive ---
|
||||
|
||||
private fun startSyncPolling() {
|
||||
syncPollJob?.cancel()
|
||||
if (_walletMode.value != WalletMode.SPARK) return
|
||||
syncPollJob = viewModelScope.launch {
|
||||
while (_currentPage.value is WalletPage.ReceiveInvoice) {
|
||||
sparkRepo.syncWallet()
|
||||
delay(3_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSyncPolling() {
|
||||
syncPollJob?.cancel()
|
||||
syncPollJob = null
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ kmp-tor-resource = "408.12.0"
|
||||
mlkit-translate = "17.0.3"
|
||||
mlkit-language-id = "17.0.6"
|
||||
objectbox = "5.2.0"
|
||||
breez-sdk-spark = "0.10.0"
|
||||
activity-compose = "1.9.3"
|
||||
navigation-compose = "2.8.5"
|
||||
lifecycle = "2.8.7"
|
||||
@@ -59,6 +60,7 @@ mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.
|
||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
|
||||
objectbox-android = { group = "io.objectbox", name = "objectbox-android", version.ref = "objectbox" }
|
||||
objectbox-kotlin = { group = "io.objectbox", name = "objectbox-kotlin", version.ref = "objectbox" }
|
||||
breez-sdk-spark = { group = "breez_sdk_spark", name = "bindings-android", version.ref = "breez-sdk-spark" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://mvn.breez.technology/releases") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user