diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 86303ca..d4f801b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 01f9f64..e965c36 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index b35e009..5451154 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -160,7 +160,11 @@ fun WispNavHost( factory = object : androidx.lifecycle.ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): 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(null) } val threadZapInProgress by feedViewModel.zapInProgress.collectAsState() var threadZapAnimatingIds by remember { mutableStateOf(emptySet()) } - 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(null) } val articleZapInProgress by feedViewModel.zapInProgress.collectAsState() var articleZapAnimatingIds by remember { mutableStateOf(emptySet()) } - 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) { diff --git a/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt index 45459a6..0f4b293 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt @@ -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>() private val _balance = MutableStateFlow(null) - val balance: StateFlow = _balance + override val balance: StateFlow = _balance private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected + override val isConnected: StateFlow = _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(extraBufferCapacity = 32) - val statusLog: SharedFlow = _statusLog + override val statusLog: SharedFlow = _statusLog + + private val _paymentReceived = MutableSharedFlow(extraBufferCapacity = 8) + override val paymentReceived: SharedFlow = _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 { + override suspend fun fetchBalance(): Result { 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 { + override suspend fun payInvoice(bolt11: String): Result { val result = sendRequest(Nip47.NwcRequest.PayInvoice(bolt11)) return result.map { (it as Nip47.NwcResponse.PayInvoiceResult).preimage } } - suspend fun makeInvoice(amountMsats: Long, description: String): Result { + override suspend fun makeInvoice(amountMsats: Long, description: String): Result { val result = sendRequest(Nip47.NwcRequest.MakeInvoice(amountMsats, description)) return result.map { (it as Nip47.NwcResponse.MakeInvoiceResult).invoice } } - suspend fun listTransactions(limit: Int = 50): Result> { + suspend fun listNwcTransactions(limit: Int = 50): Result> { 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> { + 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() diff --git a/app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt new file mode 100644 index 0000000..69ed9e1 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt @@ -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 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(null) + override val balance: StateFlow = _balance + + private val _isConnected = MutableStateFlow(false) + override val isConnected: StateFlow = _isConnected + + private val _statusLog = MutableSharedFlow(extraBufferCapacity = 32) + override val statusLog: SharedFlow = _statusLog + + private val _paymentReceived = MutableSharedFlow(extraBufferCapacity = 8) + override val paymentReceived: SharedFlow = _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 { + // 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() + 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 = 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 = 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 = + 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> = + 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) + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/repo/WalletModeRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/WalletModeRepository.kt new file mode 100644 index 0000000..a0b7d7b --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/WalletModeRepository.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt b/app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt new file mode 100644 index 0000000..76781ea --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt @@ -0,0 +1,30 @@ +package com.wisp.app.repo + +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +interface WalletProvider { + val balance: StateFlow + val isConnected: StateFlow + val statusLog: SharedFlow + + /** Emits the amount in msats whenever an incoming payment is received. */ + val paymentReceived: SharedFlow + + fun hasConnection(): Boolean + fun connect() + fun disconnect() + suspend fun fetchBalance(): Result + suspend fun payInvoice(bolt11: String): Result + suspend fun makeInvoice(amountMsats: Long, description: String): Result + suspend fun listTransactions(limit: Int = 50): Result> +} + +data class WalletTransaction( + val type: String, + val description: String?, + val paymentHash: String, + val amountMsats: Long, + val createdAt: Long, + val settledAt: Long? +) diff --git a/app/src/main/kotlin/com/wisp/app/repo/ZapSender.kt b/app/src/main/kotlin/com/wisp/app/repo/ZapSender.kt index 1298aa4..b43a67c 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/ZapSender.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/ZapSender.kt @@ -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 { } } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index aafdb72..c1c4b37 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -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, + transactions: List, 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, + 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 diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt index cae2882..a8861b0 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -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() } } diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt index 3f9edfc..e377d9c 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt @@ -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 diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index a9a12e8..fe874c4 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -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 + + private val activeProvider: WalletProvider + get() = when (_walletMode.value) { + WalletMode.SPARK -> sparkRepo + else -> nwcRepo + } private val _walletState = MutableStateFlow( - if (nwcRepo.hasConnection()) WalletState.Connecting else WalletState.NotConnected + if (activeProvider.hasConnection()) WalletState.Connecting else WalletState.NotConnected ) val walletState: StateFlow = _walletState @@ -70,8 +93,8 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() { val receiveAmount: StateFlow = _receiveAmount // Transactions - private val _transactions = MutableStateFlow>(emptyList()) - val transactions: StateFlow> = _transactions + private val _transactions = MutableStateFlow>(emptyList()) + val transactions: StateFlow> = _transactions private val _transactionsError = MutableStateFlow(null) val transactionsError: StateFlow = _transactionsError @@ -79,17 +102,58 @@ class WalletViewModel(val nwcRepo: NwcRepository) : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading + // Spark setup + private val _restoreMnemonic = MutableStateFlow("") + val restoreMnemonic: StateFlow = _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 + } } diff --git a/app/src/main/resources/bip39-english.txt b/app/src/main/resources/bip39-english.txt new file mode 100644 index 0000000..942040e --- /dev/null +++ b/app/src/main/resources/bip39-english.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba9504f..a7e3272 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2780f48..24473cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://mvn.breez.technology/releases") } } }