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:
The Daniel
2026-03-11 21:47:15 -04:00
parent f9e169ecf5
commit 19ca8fe76b
15 changed files with 3170 additions and 86 deletions
+12
View File
@@ -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)
}
+7
View File
@@ -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
+2
View File
@@ -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" }
+1
View File
@@ -19,6 +19,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://mvn.breez.technology/releases") }
}
}