diff --git a/.cursor/notes/libs.md b/.cursor/notes/libs.md index 8bf348e91c..3e3cddb284 100644 --- a/.cursor/notes/libs.md +++ b/.cursor/notes/libs.md @@ -31,6 +31,10 @@ This document provides a comprehensive reference for all libraries used in the b ### Layout - **ConstraintLayout Compose**: https://developer.android.com/jetpack/compose/layouts/constraintlayout +### WebKit +- **Documentation**: https://developer.android.com/jetpack/androidx/releases/webkit +- **WebMessageListener**: https://developer.android.com/develop/ui/views/layout/webapps/native-api-access-jsbridge + ## Architecture & Dependency Injection ### Hilt diff --git a/CHANGELOG.md b/CHANGELOG.md index 497e65df3e..7e2e4dcdca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.1] - 2026-08-21 + +### Changed +- Bitkit now keeps the Lightning node running a little longer when you briefly leave the app, so quick trips to another app no longer reload the wallet on return. #1146 + +### Fixed +- Lightning node teardown now releases native resources deterministically and no longer restarts the node when the app is only briefly backgrounded. #1100 +- Fixed native library compatibility on Android devices using 16 KB memory pages. #1107 +- Lightning node shutdown and peer persistence now complete without native crashes or app hangs. #1122 +- Fixed Pubky authorization links opening Bitkit when the feature is unavailable or no local identity can approve them. #1162 +- Fixed the wallet backup failing repeatedly when Paykit state could not be read. #1092 + +### Security +- Lightning no longer automatically starts from outdated channel monitor data after a storage mismatch. #1155 +- Wallet backups no longer fall back to unauthenticated VSS when LNURL-auth is missing. #1156 +- Shop checkout only accepts Bitrefill payment requests, and payment links wait until the wallet is unlocked. #1158 +- Wallet backups now use VSS 0.5.23, which rejects unauthenticated encryption. #1164 + + + + + ## [2.4.0] - 2026-07-15 ### Added @@ -148,7 +170,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - About screen (content merged into Support) #857 - Standalone General, Security, and Advanced settings screens (merged into tabs) #857 -[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.4.0...HEAD +[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.4.1...HEAD +[2.4.1]: https://github.com/synonymdev/bitkit-android/compare/v2.4.0...v2.4.1 [2.4.0]: https://github.com/synonymdev/bitkit-android/compare/v2.3.2...v2.4.0 [2.3.2]: https://github.com/synonymdev/bitkit-android/compare/v2.3.1...v2.3.2 [2.3.1]: https://github.com/synonymdev/bitkit-android/compare/v2.3.0...v2.3.1 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 602aeb1f38..1b9f09ae77 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -169,8 +169,8 @@ android { applicationId = "to.bitkit" minSdk = 28 targetSdk = 36 - versionCode = 187 - versionName = "2.4.0" + versionCode = 188 + versionName = "2.4.1" testInstrumentationRunner = "to.bitkit.test.HiltTestRunner" bitkitAndroidTestAnnotation?.let { testInstrumentationRunnerArguments["annotation"] = it @@ -368,6 +368,7 @@ dependencies { implementation(libs.core.ktx) implementation(libs.core.splashscreen) implementation(libs.appcompat) + implementation(libs.webkit) implementation(libs.activity.compose) implementation(libs.material) implementation(libs.datastore.preferences) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 466b64fdb9..c20fd33b3b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -125,7 +125,6 @@ - @@ -164,6 +163,20 @@ android:resource="@xml/shortcuts" /> + + + + + + + + + + diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 859d4ba71b..321a000f90 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -14,6 +14,7 @@ import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.AppWidgetRefreshScheduler import to.bitkit.env.Env import to.bitkit.services.BluetoothInit +import to.bitkit.services.PubkyAuthHandlerRegistrar import javax.inject.Inject @HiltAndroidApp @@ -27,6 +28,9 @@ internal open class App : Application(), Configuration.Provider { @Inject lateinit var appWidgetRefreshScheduler: AppWidgetRefreshScheduler + @Inject + lateinit var pubkyAuthHandlerRegistrar: PubkyAuthHandlerRegistrar + override val workManagerConfiguration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -40,6 +44,7 @@ internal open class App : Application(), Configuration.Provider { appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.APP_START) // Initialize btleplug for Bluetooth support (required before any BLE usage) BluetoothInit.ensureInitialized() + pubkyAuthHandlerRegistrar.start() } companion object { diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index ba3eeb9a4a..d404555329 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -5,7 +5,6 @@ import com.synonym.vssclient.VssItem import com.synonym.vssclient.vssDelete import com.synonym.vssclient.vssGet import com.synonym.vssclient.vssListKeys -import com.synonym.vssclient.vssNewClient import com.synonym.vssclient.vssNewClientWithLnurlAuth import com.synonym.vssclient.vssStore import kotlinx.coroutines.CompletableDeferred @@ -19,6 +18,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -51,22 +51,18 @@ class VssBackupClient @Inject constructor( val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex) Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG) Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG) - if (lnurlAuthServerUrl.isNotEmpty()) { - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - - vssNewClientWithLnurlAuth( - baseUrl = vssUrl, - storeId = vssStoreId, - mnemonic = mnemonic, - passphrase = passphrase, - lnurlAuthServerUrl = lnurlAuthServerUrl, - ) - } else { - vssNewClient( - baseUrl = vssUrl, - storeId = vssStoreId, - ) + if (lnurlAuthServerUrl.isBlank()) { + throw ServiceError.VssAuthRequired() } + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + + vssNewClientWithLnurlAuth( + baseUrl = vssUrl, + storeId = vssStoreId, + mnemonic = mnemonic, + passphrase = passphrase, + lnurlAuthServerUrl = lnurlAuthServerUrl, + ) isSetup.complete(Unit) Logger.info("VSS client setup with server: '$vssUrl'", context = TAG) } diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt index 2eec56a291..061723bea4 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt @@ -17,6 +17,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -52,6 +53,9 @@ class VssBackupClientLdk @Inject constructor( ?: throw MnemonicNotAvailableException() withTimeout(30.seconds) { + if (Env.lnurlAuthServerUrl.isBlank()) { + throw ServiceError.VssAuthRequired() + } val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) vssNewLdkClientWithLnurlAuth( baseUrl = Env.vssServerUrl, diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0d681c7487..f06cb76d4b 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -44,6 +44,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -425,7 +426,7 @@ class BackupRepo @Inject constructor( } } - suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) { + suspend fun triggerBackup(category: BackupCategory): Result = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) val backupRequired = currentTimeMillis() @@ -435,7 +436,13 @@ class BackupRepo @Inject constructor( it.copy(running = true, required = backupRequired) } - vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)) + val data = runSuspendCatching { getBackupDataBytes(category) } + .getOrElse { + markBackupFailed(category, backupRequired, it) + return@withContext Result.failure(it) + } + + vssBackupClient.putObject(key = category.name, data = data) .onSuccess { runningBackups -= category failedBackupRequired -= category @@ -447,18 +454,21 @@ class BackupRepo @Inject constructor( } Logger.info("Backup succeeded for: '$category'", context = TAG) } - .onFailure { e -> - runningBackups -= category - cacheStore.updateBackupStatus(category) { - if (it.required == backupRequired) { - failedBackupRequired[category] = backupRequired - } else { - failedBackupRequired -= category - } - it.copy(running = false) - } - Logger.error("Backup failed for: '$category'", e, context = TAG) + .onFailure { markBackupFailed(category, backupRequired, it) } + .map {} + } + + private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) { + runningBackups -= category + cacheStore.updateBackupStatus(category) { + if (it.required == backupRequired) { + failedBackupRequired[category] = backupRequired + } else { + failedBackupRequired -= category } + it.copy(running = false) + } + Logger.error("Backup failed for: '$category'", e, context = TAG) } private suspend fun getBackupDataBytes(category: BackupCategory): ByteArray = when (category) { @@ -534,16 +544,8 @@ class BackupRepo @Inject constructor( private suspend fun getWalletBackupDataBytes(): ByteArray { val transfers = db.transferDao().getAll() - val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot() - .onFailure { - Logger.warn("Failed to snapshot private Paykit reservations", it, context = TAG) - } - .getOrThrow() - val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot() - .onFailure { - Logger.warn("Failed to snapshot Paykit SDK state", it, context = TAG) - } - .getOrThrow() + val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() + val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot().getOrThrow() val payload = WalletBackupV1( createdAt = currentTimeMillis(), diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1e8800355c..29fd57d684 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -19,6 +19,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -65,10 +66,12 @@ import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.CoinSelectionPreference +import to.bitkit.models.ElectrumServer import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult @@ -81,6 +84,7 @@ import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService +import to.bitkit.services.ElectrumProbeService import to.bitkit.services.LightningService import to.bitkit.services.LnurlChannelResponse import to.bitkit.services.LnurlService @@ -121,6 +125,7 @@ class LightningRepo @Inject constructor( private val connectivityRepo: ConnectivityRepo, private val vssBackupClientLdk: VssBackupClientLdk, private val urlValidator: UrlValidator, + private val electrumProbeService: ElectrumProbeService, ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -141,7 +146,18 @@ class LightningRepo @Inject constructor( private val syncMutex = Mutex() private val syncPending = AtomicBoolean(false) private val syncRetryJob = AtomicReference(null) + private val pendingStopJob = AtomicReference(null) + private val pendingStopLock = Any() private val lifecycleMutex = Mutex() + + /** + * Serializes a whole server-change transaction (stop, start, persist) against the background + * recovery a failed change launches. [lifecycleMutex] is taken separately by `stop()` and + * `start()`, so without this a detached recovery can restart the previous config between a + * request's stop and start, leaving the request to report and persist a config that never + * started. Recovery is cheap to wait on: it skips the release gate, so it is a plain rebuild. + */ + private val configChangeMutex = Mutex() private val isChangingAddressType = AtomicBoolean(false) init { @@ -307,6 +323,8 @@ class LightningRepo @Inject constructor( return@withContext Result.failure(RecoveryModeError()) } + cancelPendingStop() + eventHandler?.let { _eventHandlers.add(it) } // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) @@ -317,9 +335,11 @@ class LightningRepo @Inject constructor( val result = lifecycleMutex.withLock { initialLifecycleState = _lightningState.value.nodeLifecycleState if (initialLifecycleState.isRunningOrStarting()) { - Logger.info("LDK node start skipped, lifecycle state: $initialLifecycleState", context = TAG) - lightningService.startEventListener(::onEvent) - return@withLock Result.success(Unit) + return@withLock skipStartForRunningNode( + lifecycleState = initialLifecycleState, + customServerUrl = customServerUrl, + customRgsServerUrl = customRgsServerUrl, + ) } runCatching { @@ -327,7 +347,8 @@ class LightningRepo @Inject constructor( // Setup if needed if (lightningService.node == null) { - val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) + val setupResult = + setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) if (setupResult.isFailure) { _lightningState.update { it.copy( @@ -432,6 +453,23 @@ class LightningRepo @Inject constructor( result } + private suspend fun skipStartForRunningNode( + lifecycleState: NodeLifecycleState, + customServerUrl: String?, + customRgsServerUrl: String?, + ): Result { + if (customServerUrl != null || customRgsServerUrl != null) { + // A node that is already up was not built with this config, so reporting success would + // let the caller persist a server that never started. + Logger.warn("Skipped LDK node start with custom config, state: $lifecycleState", context = TAG) + return Result.failure(NodeConfigNotAppliedError()) + } + + Logger.info("LDK node start skipped, lifecycle state: $lifecycleState", context = TAG) + lightningService.startEventListener(::onEvent) + return Result.success(Unit) + } + fun removeEventHandler(handler: NodeEventHandler) { _eventHandlers.remove(handler) } @@ -457,6 +495,35 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } + /** + * Defers [stop] so a brief background/foreground cycle does not tear the node down and rebuild it. + * Runs on the repo scope so a cancelled ViewModel cannot drop the pending stop. + * + * Scheduling and cancelling are atomic: [cancelPendingStop] runs on the repo dispatcher while this + * runs on the caller thread, so an interleaved cancel could otherwise miss the job being installed + * and stop the node after the app is back in the foreground. + * + * [BACKGROUND_STOP_DELAY] must stay under the cached-app freezer debounce (~10s once the process + * drops to `oom_adj` 900) so [stop] is at least entered before the process can be frozen. Past + * that the process freezes mid-delay and the stop only fires on unfreeze, racing + * [cancelPendingStop] to tear the node down just as the user returns — and [stop] runs + * `NonCancellable`, so losing that race is unrecoverable. + * + * Whether the stop *completes* in that window is out of scope here: on a wallet with real + * payment history ldk_node reliably hits its own 30s event-handling deadline, so no delay value + * makes the teardown fit. That also makes an avoided teardown valuable, since a user returning + * mid-stop waits it out before the ~8s node rebuild can start. + */ + fun stopDebounced() = synchronized(pendingStopLock) { + val job = scope.launch { + delay(BACKGROUND_STOP_DELAY) + stop() + } + pendingStopJob.getAndSet(job)?.cancel() + } + + fun cancelPendingStop() = synchronized(pendingStopLock) { pendingStopJob.getAndSet(null)?.cancel() } + suspend fun stop(): Result = withContext(bgDispatcher) { lifecycleMutex.withLock { if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { @@ -465,10 +532,12 @@ class LightningRepo @Inject constructor( } runCatching { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } - lightningService.stop() - clearProbeOutcomes() - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + withContext(NonCancellable) { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } + lightningService.stop() + clearProbeOutcomes() + _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + } }.onFailure { Logger.error("Node stop error", it, context = TAG) // On failure, check actual node state and update accordingly @@ -665,24 +734,33 @@ class LightningRepo @Inject constructor( suspend fun restartWithElectrumServer(newServerUrl: String): Result = withContext(bgDispatcher) { Logger.info("Changing ldk-node electrum server to: '$newServerUrl'", context = TAG) - waitForNodeToStop().onFailure { return@withContext Result.failure(it) } - stop().onFailure { - Logger.error("Failed to stop node during electrum server change", it, context = TAG) + validateElectrumServer(newServerUrl).onFailure { + Logger.warn("Rejected electrum server '$newServerUrl'", it, context = TAG) return@withContext Result.failure(it) } - Logger.debug("Starting node with new electrum server: '$newServerUrl'", context = TAG) + configChangeMutex.withLock { + waitForNodeToStop().onFailure { return@withContext Result.failure(it) } + stop().onFailure { + Logger.error("Failed to stop node during electrum server change", it, context = TAG) + return@withContext Result.failure(it) + } - start( - shouldRetry = false, - customServerUrl = newServerUrl, - ).onFailure { - Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) - restartWithPreviousConfig() - }.onSuccess { - settingsStore.update { it.copy(electrumServer = newServerUrl) } + Logger.debug("Starting node with new electrum server: '$newServerUrl'", context = TAG) - Logger.info("Successfully changed electrum server", context = TAG) + start( + shouldRetry = false, + customServerUrl = newServerUrl, + ).onFailure { + // Recover in the background: a wedged node's release can gate the rebuild for tens of + // seconds, and the caller must surface this failure now rather than block on recovery. + Logger.warn("Failed ldk-node config change, recovering in background…", context = TAG) + scope.launch { restartWithPreviousConfig() } + }.onSuccess { + settingsStore.update { it.copy(electrumServer = newServerUrl) } + + Logger.info("Successfully changed electrum server", context = TAG) + } } } @@ -694,24 +772,28 @@ class LightningRepo @Inject constructor( return@withContext Result.failure(it) } - waitForNodeToStop().onFailure { return@withContext Result.failure(it) } - stop().onFailure { - Logger.error("Failed to stop node during RGS server change", it, context = TAG) - return@withContext Result.failure(it) - } - - Logger.debug("Starting node with new RGS server: '$newRgsUrl'", context = TAG) + configChangeMutex.withLock { + waitForNodeToStop().onFailure { return@withContext Result.failure(it) } + stop().onFailure { + Logger.error("Failed to stop node during RGS server change", it, context = TAG) + return@withContext Result.failure(it) + } - start( - shouldRetry = false, - customRgsServerUrl = newRgsUrl, - ).onFailure { - Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) - restartWithPreviousConfig() - }.onSuccess { - settingsStore.update { it.copy(rgsServerUrl = newRgsUrl) } + Logger.debug("Starting node with new RGS server: '$newRgsUrl'", context = TAG) - Logger.info("Successfully changed RGS server", context = TAG) + start( + shouldRetry = false, + customRgsServerUrl = newRgsUrl, + ).onFailure { + // Recover in the background: a wedged node's release can gate the rebuild for tens of + // seconds, and the caller must surface this failure now rather than block on recovery. + Logger.warn("Failed ldk-node config change, recovering in background…", context = TAG) + scope.launch { restartWithPreviousConfig() } + }.onSuccess { + settingsStore.update { it.copy(rgsServerUrl = newRgsUrl) } + + Logger.info("Successfully changed RGS server", context = TAG) + } } } @@ -721,6 +803,14 @@ class LightningRepo @Inject constructor( urlValidator.validate(testUrl) } + private suspend fun validateElectrumServer(url: String): Result = withContext(bgDispatcher) { + runSuspendCatching { ElectrumServer.parse(url) } + .fold( + onSuccess = { electrumProbeService.probe(it) }, + onFailure = { Result.failure(it) }, + ) + } + suspend fun getBalanceForAddressType(addressType: AddressType): Result = withContext(bgDispatcher) { executeWhenNodeRunning("getBalanceForAddressType") { runCatching { @@ -901,21 +991,23 @@ class LightningRepo @Inject constructor( } private suspend fun restartWithPreviousConfig(): Result = withContext(bgDispatcher) { - Logger.debug("Stopping node for recovery attempt", context = TAG) - - stop().onFailure { e -> - Logger.error("Failed to stop node during recovery", e, context = TAG) - return@withContext Result.failure(e) - } + // Runs detached from the failed change, so it takes the same transaction lock: without it + // this recovery can restart the previous config between a later change's stop and start. + configChangeMutex.withLock { + Logger.debug("Stopping node for recovery attempt", context = TAG) + + stop().onFailure { e -> + Logger.error("Failed to stop node during recovery", e, context = TAG) + return@withContext Result.failure(e) + } - Logger.debug("Starting node with previous config for recovery", context = TAG) + Logger.debug("Starting node with previous config for recovery", context = TAG) - start( - shouldRetry = false, - ).onSuccess { - Logger.debug("Successfully started node with previous config", context = TAG) - }.onFailure { - Logger.error("Failed starting node with previous config", it, context = TAG) + start(shouldRetry = false).onSuccess { + Logger.debug("Successfully started node with previous config", context = TAG) + }.onFailure { + Logger.error("Failed starting node with previous config", it, context = TAG) + } } } @@ -1673,6 +1765,9 @@ class LightningRepo @Inject constructor( check(lifecycleState == NodeLifecycleState.Stopped) { "Node lifecycle changed to '$lifecycleState' during pathfinding scores reset" } + // Gate the destructive VSS deletes on the previous node's release, so the old node cannot + // re-persist scores over the delete while it is still draining. + lightningService.awaitNodeRelease() vssBackupClientLdk.setup(walletIndex).getOrThrow() vssBackupClientLdk.deleteObject(VSS_KEY_SCORER).getOrThrow() vssBackupClientLdk.deleteObject(VSS_KEY_EXTERNAL_SCORES_CACHE).getOrThrow() @@ -1715,6 +1810,7 @@ class LightningRepo @Inject constructor( private const val VSS_KEY_EXTERNAL_SCORES_CACHE = "external_pathfinding_scores_cache" private const val MS_SYNC_LOOP_DEBOUNCE = 500L private const val SYNC_RETRY_DELAY_MS = 15_000L + private val BACKGROUND_STOP_DELAY = 5.seconds private val CHANNELS_USABLE_TIMEOUT = 15.seconds private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds val SEND_LN_TIMEOUT = 10.seconds @@ -1725,6 +1821,7 @@ class LightningRepo @Inject constructor( class RecoveryModeError : AppError("App in recovery mode, skipping node start") class NodeSetupError : AppError("Unknown node setup error") class NodeStopTimeoutError : AppError("Timeout waiting for node to stop") +class NodeConfigNotAppliedError : AppError("Node already running, requested config was not applied") class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'") class GetPaymentsError : AppError("It wasn't possible get the payments") class SyncUnhealthyError : AppError("Wallet sync failed before send") diff --git a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt new file mode 100644 index 0000000000..85cb1e6a44 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt @@ -0,0 +1,203 @@ +package to.bitkit.services + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.lightningdevkit.ldknode.Network +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.ElectrumProtocol +import to.bitkit.models.ElectrumServer +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.io.BufferedReader +import java.io.Writer +import java.net.InetSocketAddress +import java.net.Socket +import javax.inject.Inject +import javax.inject.Singleton +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import kotlin.time.Duration.Companion.seconds + +/** + * Probes an electrum server over its own socket before the node is asked to use it. + * + * A failed `node.start()` leaves the node's electrum background tasks wedged, so `free_node` then + * blocks for tens of seconds instead of milliseconds. Rejecting a misconfigured server here means + * the node is never torn down and rebuilt for one, so that path stops producing wedged releases. + */ +@Singleton +class ElectrumProbeService @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + companion object { + private const val TAG = "ElectrumProbeService" + + /** Budget for the TCP connect and, on SSL, the TLS handshake. */ + private val CONNECT_TIMEOUT = 5.seconds + + /** Budget for each JSON-RPC response line. */ + private val RESPONSE_TIMEOUT = 5.seconds + + private const val CLIENT_NAME = "bitkit" + private const val PROTOCOL_VERSION = "1.4" + + /** JSON-RPC id of the `server.version` request, echoed back by a well-behaved server. */ + private const val VERSION_REQUEST_ID = 0 + + /** JSON-RPC id of the `server.features` request. */ + private const val FEATURES_REQUEST_ID = 1 + } + + // Deliberately not the injected Json: that one sets prettyPrint, and electrum is line-delimited, + // so a multi-line request would be read as a truncated line. encodeDefaults keeps `jsonrpc` and + // an empty `params` on the wire, which servers expect. + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + suspend fun probe( + server: ElectrumServer, + network: Network = Env.network, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + openSocket(server).use { socket -> + socket.soTimeout = RESPONSE_TIMEOUT.inWholeMilliseconds.toInt() + val reader = socket.getInputStream().bufferedReader() + val writer = socket.getOutputStream().bufferedWriter() + + // A host that accepts the connection but does not speak electrum drops or resets it + // mid-exchange, so report that as a probe verdict rather than a raw socket error. + runCatching { + // Version negotiation has to succeed before server.features may be treated as + // optional, otherwise a server erroring on both would probe clean. + request(reader, writer, VERSION_REQUEST_ID, "server.version", versionParams()).getOrThrow() + + val features = request(reader, writer, FEATURES_REQUEST_ID, "server.features") + verifyNetwork(features.getOrNull(), server, network) + }.getOrElse { + throw it as? ElectrumProbeError ?: ElectrumProbeError.NotElectrum(server, it) + } + } + Logger.info("Probed electrum server '$server' successfully", context = TAG) + } + } + + private fun openSocket(server: ElectrumServer): Socket { + val plain = Socket() + runCatching { + plain.connect(InetSocketAddress(server.host, server.getPort()), CONNECT_TIMEOUT.inWholeMilliseconds.toInt()) + }.onFailure { + plain.runCatching { close() } + throw ElectrumProbeError.Unreachable(server, it) + } + + if (server.protocol == ElectrumProtocol.TCP) return plain + + // A TLS handshake against a plain-TCP server hangs without a read timeout, which is the + // misconfiguration that wedges the node's release when it is left to node.start(). + return runCatching { + val factory = SSLSocketFactory.getDefault() as SSLSocketFactory + val ssl = factory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket + ssl.soTimeout = CONNECT_TIMEOUT.inWholeMilliseconds.toInt() + ssl.startHandshake() + ssl + }.getOrElse { + plain.runCatching { close() } + throw ElectrumProbeError.ProtocolMismatch(server, it) + } + } + + private fun request( + reader: BufferedReader, + writer: Writer, + id: Int, + method: String, + params: List = emptyList(), + ): Result = runCatching { + writer.appendLine(json.encodeToString(RpcRequest(id = id, method = method, params = params))) + writer.flush() + + val line = reader.readLine() ?: throw AppError("Closed connection before answering '$method'") + val response = json.decodeFromString(line) + + when { + response.id != id -> + throw AppError("Answered '$method' with id '${response.id}', expected '$id'") + + !response.error.isNullOrJsonNull() -> + throw AppError("Answered '$method' with error '${response.error}'") + + response.result.isNullOrJsonNull() -> + throw AppError("Answered '$method' without a result") + + else -> response + } + } + + private fun verifyNetwork(features: RpcResponse?, server: ElectrumServer, network: Network) { + val genesis = (features?.result as? JsonObject)?.get("genesis_hash")?.jsonPrimitive?.contentOrNull + if (genesis == null) { + // server.features is optional, so a server that answered version negotiation but cannot + // report a genesis hash stays usable; only the network check is skipped. + Logger.warn("Skipped network check, server '$server' reported no genesis hash", context = TAG) + return + } + + val expected = genesisHashOf(network) + if (!genesis.equals(expected, ignoreCase = true)) { + throw ElectrumProbeError.NetworkMismatch(server, expected = expected, actual = genesis) + } + } + + private fun versionParams() = listOf(CLIENT_NAME, PROTOCOL_VERSION) + + private fun genesisHashOf(network: Network): String = when (network) { + Network.BITCOIN -> "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + Network.TESTNET -> "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" + Network.SIGNET -> "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6" + Network.REGTEST -> "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" + } +} + +/** A JSON-RPC call, matching the envelope the probe expects back. */ +@Serializable +private data class RpcRequest( + val id: Int, + val jsonrpc: String = "2.0", + val method: String, + val params: List = emptyList(), +) + +/** A JSON-RPC reply, kept only as far as the probe needs to trust it. */ +@Serializable +private data class RpcResponse( + val id: Int? = null, + val result: JsonElement? = null, + val error: JsonElement? = null, +) + +private fun JsonElement?.isNullOrJsonNull() = this == null || this is JsonNull + +sealed class ElectrumProbeError(message: String, cause: Throwable? = null) : AppError(message, cause) { + class Unreachable(server: ElectrumServer, cause: Throwable) : + ElectrumProbeError("Could not reach electrum server '$server'", cause) + + class ProtocolMismatch(server: ElectrumServer, cause: Throwable) : + ElectrumProbeError("Failed TLS handshake with electrum server '$server', check the protocol", cause) + + class NotElectrum(server: ElectrumServer, cause: Throwable? = null) : + ElectrumProbeError("Received no electrum response from '$server'", cause) + + class NetworkMismatch(server: ElectrumServer, expected: String, actual: String) : + ElectrumProbeError("Rejected electrum server '$server' on wrong network, expected '$expected' got '$actual'") +} diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index c58b1151cf..872584173b 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -3,7 +3,9 @@ package to.bitkit.services import com.synonym.bitkitcore.AddressType import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -12,6 +14,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.AddressTypeBalance @@ -48,8 +51,10 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.di.IoDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.uByteList import to.bitkit.ext.uri import to.bitkit.models.OpenChannelResult @@ -65,13 +70,21 @@ import to.bitkit.utils.jsonLogOf import java.io.File import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.io.path.Path import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import org.lightningdevkit.ldknode.AddressType as LdkAddressType typealias NodeEventHandler = suspend (Event) -> Unit +/** Tags the event-listener coroutine so a handler-triggered stop can skip joining its own job. */ +private class EventListenerContext : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} + data class AddressDerivationInfo( val address: String, val index: Int, @@ -81,6 +94,7 @@ data class AddressDerivationInfo( @Singleton class LightningService @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val vssStoreIdProvider: VssStoreIdProvider, private val settingsStore: SettingsStore, @@ -97,6 +111,15 @@ class LightningService @Inject constructor( private const val SCORING_CONSIDERED_IMPOSSIBLE_PENALTY_MSAT = 1_000_000_000_000uL private const val SCORING_PROBING_DIVERSITY_PENALTY_MSAT = 60_000uL + /** How long a node stop waits for the rust handle to be released before moving on. */ + private val NODE_RELEASE_TIMEOUT = 1.seconds + + /** + * Upper bound for the release gate. Longer than an observed wedged free_node (~40s) so a real + * drain still gates, but bounded so a stuck free_node cannot permanently block rebuilds. + */ + private val NODE_RELEASE_GATE_TIMEOUT = 90.seconds + private val DEFAULT_SCORING_FEE_PARAMETERS = ScoringFeeParameters( basePenaltyMsat = 1_024uL, basePenaltyAmountMultiplierMsat = 131_072uL, @@ -121,6 +144,11 @@ class LightningService @Inject constructor( private var listenerJob: Job? = null + // Release of the previous node's rust handle (free_node). Rebuild and destructive storage work + // wait on this so a new node never touches storage the old one is still draining. + @Volatile + private var releaseJob: Job? = null + suspend fun setup( walletIndex: Int, customServerUrl: String? = null, @@ -128,6 +156,7 @@ class LightningService @Inject constructor( trustedPeers: List? = null, channelMigration: ChannelDataMigration? = null, ) { + awaitNodeRelease() Logger.debug("Building node…", context = TAG) val config = config(walletIndex, trustedPeers) @@ -217,28 +246,11 @@ class LightningService @Inject constructor( context = TAG, ) - fun buildNode() = runCatching { - if (lnurlAuthServerUrl.isNotEmpty()) { - builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders) - } else { - builder.buildWithVssStoreAndFixedHeaders(vssUrl, vssStoreId, fixedHeaders) - } + if (lnurlAuthServerUrl.isBlank()) { + throw ServiceError.VssAuthRequired() } - buildNode().recoverCatching { error -> - if (error !is BuildException.DangerousValue) throw error - Logger.warn( - "Retrying build failed with 'DangerousValue' using 'setAcceptStaleChannelMonitors' for recovery.", - error, - context = TAG, - ) - builder.setAcceptStaleChannelMonitors(true) - buildNode() - .onFailure { - Logger.error("Failed recovery retry using 'setAcceptStaleChannelMonitors'.", it, context = TAG) - } - .getOrThrow() - }.getOrThrow() + builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders) } catch (e: BuildException) { throw LdkError(e) } finally { @@ -275,6 +287,8 @@ class LightningService @Inject constructor( feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, ), connectionTimeoutSecs = Env.walletSyncTimeoutSecs, + additionalWalletFullScanBatchSize = 100u, + additionalWalletFullScanStopGap = 1000u, ), ) } @@ -295,7 +309,7 @@ class LightningService @Inject constructor( // start event listener after node started onEvent?.let { eventHandler -> shouldListenForEvents = true - listenerJob = launch { + listenerJob = launch(EventListenerContext()) { runCatching { Logger.debug("LDK event listener started", context = TAG) if (timeout != null) { @@ -314,34 +328,84 @@ class LightningService @Inject constructor( Logger.info("Node started", context = TAG) } - suspend fun stop() { + // Teardown must not be abandoned midway: a cancelled caller would leave the rust node alive and + // let the GC free it later on the finalizer thread, racing the next node. This covers the whole + // body, including the listener join, so cancellation during listener cleanup cannot skip it. + suspend fun stop() = withContext(NonCancellable) { shouldListenForEvents = false - listenerJob?.cancelAndJoin() + // A stop requested from inside an event handler runs on the listener job itself; joining it + // here would deadlock, so let the loop exit on shouldListenForEvents instead. + if (currentCoroutineContext()[EventListenerContext.Key] == null) { + listenerJob?.cancelAndJoin() + } listenerJob = null - val node = this.node ?: run { + val node = this@LightningService.node ?: run { Logger.debug("Node already stopped", context = TAG) - return + return@withContext } Logger.debug("Stopping node…", context = TAG) ServiceQueue.LDK.background { - runCatching { node.stop() } - .onFailure { if (it !is NodeException.NotRunning) throw it } + runSuspendCatching { node.stop() } + .onFailure { + if (it !is NodeException.NotRunning) Logger.warn("Node stop error", it, context = TAG) + } this@LightningService.node = null } + releaseHandle(node) Logger.info("Node stopped", context = TAG) } - fun wipeStorage(walletIndex: Int) { + /** + * Releases the rust handle instead of leaving it to the GC finalizer, keeping it off the + * single-threaded LDK queue and off the lifecycle critical path. + * + * `free_node` only returns once the node's background tasks drain, which takes tens of seconds + * when a failed start left them wedged, so the wait is bounded and the release is joined rather + * than run inline: cancelling a blocking FFI call does nothing, but abandoning the join lets the + * caller continue while the release finishes on its own. [awaitNodeRelease] gates the next + * rebuild or destructive storage work on the same job so native lifetimes never overlap. + */ + private suspend fun releaseHandle(node: Node) { + val release = launch(ioDispatcher) { + runSuspendCatching { node.destroy() } + .onFailure { Logger.warn("Node handle release error", it, context = TAG) } + } + releaseJob = release + // Clear once done so a later gate check no-ops instead of joining a stale completed job. + release.invokeOnCompletion { if (releaseJob === release) releaseJob = null } + withTimeoutOrNull(NODE_RELEASE_TIMEOUT) { release.join() } + ?: Logger.warn("Node handle release still pending after $NODE_RELEASE_TIMEOUT", context = TAG) + } + + /** + * Blocks until the previous node's release ([releaseHandle] / `free_node`) has finished, so a + * rebuild or destructive storage operation never overlaps the old node's native lifetime. The + * wait is bounded: a stuck free_node throws rather than proceeding (which would overlap) or + * blocking forever (which would brick every future rebuild). + */ + suspend fun awaitNodeRelease(timeout: Duration = NODE_RELEASE_GATE_TIMEOUT) { + val release = releaseJob ?: return + if (release.isActive) Logger.debug("Waiting for previous node release to finish…", context = TAG) + withTimeoutOrNull(timeout) { release.join() } + ?: run { + Logger.error("Previous node release did not finish within $timeout", context = TAG) + throw ServiceError.NodeReleaseTimeout() + } + } + + suspend fun wipeStorage(walletIndex: Int) { if (node != null) throw ServiceError.NodeStillRunning() + awaitNodeRelease() Logger.warn("Wiping LDK storage…", context = TAG) Path(Env.ldkStoragePath(walletIndex)).toFile().deleteRecursively() Logger.info("LDK storage wiped", context = TAG) } - fun resetNetworkGraph(walletIndex: Int) { + suspend fun resetNetworkGraph(walletIndex: Int) { if (node != null) throw ServiceError.NodeStillRunning() + awaitNodeRelease() Logger.warn("Resetting network graph cache…", context = TAG) val ldkPath = Path(Env.ldkStoragePath(walletIndex)).toFile() val graphFile = ldkPath.resolve("network_graph_cache") @@ -1040,13 +1104,20 @@ class LightningService @Inject constructor( // endregion // region events + // Volatile: written by stop()/startEventListener() and read by the listener loop on another thread. + @Volatile private var shouldListenForEvents = true - suspend fun startEventListener(onEvent: NodeEventHandler? = null): Result = runCatching { + suspend fun startEventListener(onEvent: NodeEventHandler? = null): Result = runSuspendCatching { val node = this.node ?: throw ServiceError.NodeNotSetup() + // A re-arm requested from inside an event handler runs on the listener job itself: joining it + // would deadlock and relaunching would double-poll the node. Skip re-arming and keep the + // running listener. This drops the passed onEvent, which is safe only because the callers + // (LightningRepo.start) always re-arm with the same dispatch handler the listener already runs. + if (currentCoroutineContext()[EventListenerContext.Key] != null) return@runSuspendCatching listenerJob?.cancelAndJoin() shouldListenForEvents = true - listenerJob = launch { + listenerJob = launch(EventListenerContext()) { runCatching { Logger.debug("LDK event listener started", context = TAG) listenForEvents(node, onEvent) @@ -1059,7 +1130,10 @@ class LightningService @Inject constructor( } private suspend fun listenForEvents(node: Node, onEvent: NodeEventHandler? = null) = withContext(bgDispatcher) { - while (shouldListenForEvents) { + // Key the loop on node identity, not just the shared flag: a handler-triggered stop nulls + // and frees this node, and a racing start() could flip shouldListenForEvents back on. Without + // the identity check the loop would call nextEventAsync() on the freed node. + while (shouldListenForEvents && node === this@LightningService.node) { ensureActive() val event = runCatching { node.nextEventAsync() }.getOrElse { diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt new file mode 100644 index 0000000000..0ddad2eb34 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt @@ -0,0 +1,95 @@ +package to.bitkit.services + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.flags.PaykitFeatureFlags +import to.bitkit.repositories.PubkyRepo +import to.bitkit.utils.Logger +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +/** Advertises Bitkit as a `pubkyauth` handler only while it can authorize requests locally. */ +@Singleton +internal class PubkyAuthHandlerRegistrar @Inject constructor( + @ApplicationContext private val context: Context, + private val pubkyRepo: PubkyRepo, + private val settingsStore: SettingsStore, + @IoDispatcher ioDispatcher: CoroutineDispatcher, +) { + private val scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val aliasComponent = ComponentName(context.packageName, PUBKY_AUTH_ALIAS_CLASS) + private val started = AtomicBoolean() + + fun start() = start(scope) + + internal fun start(collectionScope: CoroutineScope) { + if (!started.compareAndSet(false, true)) return + + collectionScope.launch { + combine(settingsStore.isPaykitEnabled, pubkyRepo.publicKey) { localFlagEnabled, publicKey -> + localFlagEnabled to publicKey + } + .distinctUntilChanged() + .collectLatest { (localFlagEnabled, publicKey) -> + val isPaykitUiEnabled = PaykitFeatureFlags.isUiEnabled(localFlagEnabled) + val hasIdentity = publicKey != null + val hasSecretKey = isPaykitUiEnabled && hasIdentity && pubkyRepo.hasSecretKey() + + setAliasEnabled( + canHandlePubkyAuth( + isPaykitUiEnabled = isPaykitUiEnabled, + hasIdentity = hasIdentity, + hasSecretKey = hasSecretKey, + ), + ) + } + } + } + + private fun setAliasEnabled(enabled: Boolean) { + val state = + if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + runCatching { + context.packageManager.setComponentEnabledSetting( + aliasComponent, + state, + PackageManager.DONT_KILL_APP, + ) + }.onSuccess { + Logger.info( + "Updated pubkyauth handler to '${if (enabled) "enabled" else "disabled"}'", + context = TAG, + ) + }.onFailure { + Logger.error("Failed to update pubkyauth handler", it, context = TAG) + } + } + + companion object { + private const val TAG = "PubkyAuthHandlerRegistrar" + private const val PUBKY_AUTH_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkyAuth" + } +} + +internal fun canHandlePubkyAuth( + isPaykitUiEnabled: Boolean, + hasIdentity: Boolean, + hasSecretKey: Boolean, +): Boolean = isPaykitUiEnabled && hasIdentity && hasSecretKey diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b41ddca8e3..c9f053c254 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -49,6 +50,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.appWidgetRefreshScheduler import to.bitkit.env.Env @@ -1398,6 +1400,7 @@ private fun NavGraphBuilder.shop( ) } composableWithDefaultTransitions { + val blockedNavigationMessage = stringResource(R.string.other__shop__external_link_blocked) ShopWebViewScreen( onClose = { navController.navigateToHome() }, onBack = { navController.popBackStack() }, @@ -1405,7 +1408,13 @@ private fun NavGraphBuilder.shop( title = it.toRoute().title, onPaymentIntent = { data -> appViewModel.onScanResult(data) - } + }, + onBlockedNavigation = { + appViewModel.toast( + type = Toast.ToastType.WARNING, + title = blockedNavigationMessage, + ) + }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt new file mode 100644 index 0000000000..38cb88d0dc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -0,0 +1,65 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import to.bitkit.env.Env +import java.net.URI + +/** Root host for Bitrefill shop pages and payment_intent messages. */ +internal const val BITREFILL_ROOT_HOST = "bitrefill.com" + +/** Default HTTPS port accepted for the trusted shop payment origin. */ +private const val HTTPS_DEFAULT_PORT = 443 + +internal fun isAllowedShopHost(host: String?): Boolean { + val normalized = host?.lowercase()?.trim('.') ?: return false + return normalized == BITREFILL_ROOT_HOST || normalized.endsWith(".$BITREFILL_ROOT_HOST") +} + +internal fun isAllowedShopOrigin(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false + if (parsed.scheme?.equals("https", ignoreCase = true) != true) return false + return isAllowedShopHost(parsed.host) +} + +private val bitrefillEmbedOrigin = URI(Env.BITREFILL_URL) + +private fun hasTrustedPaymentOrigin(parsed: URI): Boolean { + val hasTrustedScheme = parsed.scheme.equals(bitrefillEmbedOrigin.scheme, ignoreCase = true) + val hasTrustedHost = parsed.host.equals(bitrefillEmbedOrigin.host, ignoreCase = true) + val hasTrustedPort = parsed.port == -1 || parsed.port == HTTPS_DEFAULT_PORT + return hasTrustedScheme && hasTrustedHost && hasTrustedPort && parsed.rawUserInfo == null +} + +internal fun isAllowedShopPaymentPage(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false + return hasTrustedPaymentOrigin(parsed) +} + +internal fun isAllowedShopPaymentOrigin(origin: String?): Boolean { + if (origin.isNullOrBlank()) return false + val parsed = runCatching { URI(origin.trim()) }.getOrNull() ?: return false + return hasTrustedPaymentOrigin(parsed) && + parsed.rawPath.isNullOrEmpty() && + parsed.rawQuery == null && + parsed.rawFragment == null +} + +internal fun shopPaymentOriginRules(): Set = setOf(Env.BITREFILL_URL) + +internal fun shopMessageBridgeScript(): String = """ + if (!window.__bitkitShopBridgeInstalled) { + window.__bitkitShopBridgeInstalled = true; + window.ReactNativeWebView = { + postMessage: function(data) { + Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); + } + }; + window.addEventListener('message', function(event) { + if (event.origin !== '${Env.BITREFILL_URL}') return; + var data = event.data; + if (data == null) return; + Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); + }); + } +""".trimIndent() diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt index fa5fb52f0a..b61133e6db 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -12,8 +12,13 @@ import to.bitkit.utils.Logger */ class ShopWebViewClient( private val onLoadingStateChanged: (Boolean) -> Unit, - private val onError: () -> Unit + private val onError: () -> Unit, + private val onBlockedNavigation: () -> Unit, + private val isPaymentBridgeSupported: () -> Boolean, ) : WebViewClient() { + private companion object { + const val TAG = "ShopWebViewClient" + } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) @@ -24,29 +29,18 @@ class ShopWebViewClient( super.onPageFinished(view, url) onLoadingStateChanged(false) - // Inject JavaScript to bridge postMessage to Android - view?.evaluateJavascript( - """ - window.ReactNativeWebView = { - postMessage: function(data) { - Android.postMessage(data); - } - }; + if (isPaymentBridgeSupported() && isAllowedShopPaymentPage(url)) { + view?.evaluateJavascript(shopMessageBridgeScript(), null) + } + } - // Override the default postMessage if it exists - if (window.postMessage) { - window.originalPostMessage = window.postMessage; - window.postMessage = function(data) { - if (typeof data === 'string') { - Android.postMessage(data); - } else { - Android.postMessage(JSON.stringify(data)); - } - }; - } - """.trimIndent(), - null - ) + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + if (request?.isForMainFrame != true) return false + val url = request.url?.toString() + if (isAllowedShopOrigin(url)) return false + Logger.warn("Blocked shop navigation to untrusted origin '$url'", context = TAG) + onBlockedNavigation() + return true } @Suppress("ComplexCondition") @@ -58,7 +52,7 @@ class ShopWebViewClient( super.onReceivedError(view, request, error) Logger.warn( "Error: ${error?.description}, Code: ${error?.errorCode}, URL: ${request?.url}", - context = "ShopWebViewScreen" + context = TAG, ) onLoadingStateChanged(false) diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index 9d14a6eb22..88166a7373 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -1,70 +1,92 @@ package to.bitkit.ui.screens.shop.shopWebView -import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import kotlinx.serialization.json.Json import to.bitkit.utils.Logger /** * JavaScript interface for handling WebView messages. * - * SECURITY NOTE: This interface is exposed to JavaScript running in the WebView. - * Only methods annotated with @JavascriptInterface are accessible from JavaScript - * on API 17+ (Android 4.2+). All methods should validate input and handle errors - * gracefully since they run on a background thread. - * - * Thread Safety: JavaScript interacts with this object on a private background - * thread. All callbacks should be thread-safe or use appropriate dispatching. + * [attachTo] uses an origin-scoped WebMessageListener. Payment handling is + * disabled when that listener is unavailable because legacy JavaScript + * interfaces cannot identify the calling frame. */ class ShopWebViewInterface( private val onPaymentIntent: (String) -> Unit, + private val isWebMessageListenerSupported: () -> Boolean = { + WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + }, + private val addWebMessageListener: ( + WebView, + String, + Set, + WebViewCompat.WebMessageListener, + ) -> Unit = { webView, jsObjectName, allowedOriginRules, listener -> + WebViewCompat.addWebMessageListener(webView, jsObjectName, allowedOriginRules, listener) + }, ) { + private companion object { + const val TAG = "ShopWebViewInterface" + const val JS_OBJECT_NAME = "Android" + const val PAYMENT_INTENT_EVENT = "payment_intent" + } + private val json = Json { ignoreUnknownKeys = true } + private val webMessageListenerSupported by lazy(isWebMessageListenerSupported) - /** - * Handles messages posted from JavaScript. - * This method is called on a background thread - ensure thread safety. - * - * @param message JSON string containing the message data - */ - @Suppress("NestedBlockDepth") - @JavascriptInterface - fun postMessage(message: String) { - if (message.isBlank()) { - Logger.warn("Received empty message", context = "WebView") + internal fun supportsPaymentBridge() = webMessageListenerSupported + + fun attachTo(webView: WebView) { + if (!supportsPaymentBridge()) { + Logger.warn("Disabled shop payment bridge because WebMessageListener is unavailable", context = TAG) return } - runCatching { - val data = json.decodeFromString(message) - when (data.event) { - "payment_intent" -> { - data.paymentUri?.let { uri -> - // Validate URI before passing it along - if (uri.isNotBlank()) { - onPaymentIntent(uri) - } else { - Logger.warn("Received payment_intent with empty URI", context = "WebView") - } - } ?: Logger.warn("Received payment_intent without URI", context = "WebView") - } + addWebMessageListener( + webView, + JS_OBJECT_NAME, + shopPaymentOriginRules(), + ) { _, message, sourceOrigin, _, _ -> + onWebMessage(message, sourceOrigin.toString()) + } + } - else -> { - Logger.debug("Unknown event type: ${data.event}", context = "WebView") - } - } - }.onFailure { - Logger.error("Error parsing message: $message", it, context = "WebView") + internal fun onWebMessage(message: WebMessageCompat, sourceOrigin: String?) { + if (message.type != WebMessageCompat.TYPE_STRING) { + Logger.warn("Rejected non-string shop WebView message", context = TAG) + return } + val data = message.data.orEmpty() + if (data.isBlank()) { + Logger.warn("Received empty shop WebView message", context = TAG) + return + } + handlePaymentMessage(data, sourceOrigin) } - /** - * Returns whether the interface is ready to receive messages. - * - * @return true if the interface is initialized and ready - */ - @Suppress("FunctionOnlyReturningConstant") - @JavascriptInterface - fun isReady(): Boolean { - return true + internal fun handlePaymentMessage(message: String, sourceOrigin: String?) { + if (!isAllowedShopPaymentOrigin(sourceOrigin)) { + Logger.warn("Rejected shop payment_intent from untrusted origin '$sourceOrigin'", context = TAG) + return + } + + val data = runCatching { json.decodeFromString(message) }.getOrElse { + Logger.debug("Ignored unrecognized shop WebView message", context = TAG) + return + } + when (data.event) { + PAYMENT_INTENT_EVENT -> { + val uri = data.paymentUri?.trim().orEmpty() + if (uri.isBlank()) { + Logger.warn("Received payment_intent with empty URI", context = TAG) + return + } + onPaymentIntent(uri) + } + else -> Logger.debug("Ignored shop WebView event '${data.event}'", context = TAG) + } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt index 77a3f12881..2a42fe1c63 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt @@ -25,23 +25,30 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface -@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") +@SuppressLint("SetJavaScriptEnabled") @Composable fun ShopWebViewScreen( onClose: () -> Unit, onBack: () -> Unit, onPaymentIntent: (String) -> Unit, + onBlockedNavigation: () -> Unit, page: String, title: String, ) { var isLoading by remember { mutableStateOf(true) } var webView: WebView? by remember { mutableStateOf(null) } - val webViewInterface = remember { ShopWebViewInterface(onPaymentIntent) } + val webViewInterface = remember { + ShopWebViewInterface( + onPaymentIntent = onPaymentIntent, + ) + } val webViewClient = remember { ShopWebViewClient( onLoadingStateChanged = { loading -> isLoading = loading }, - onError = onClose + onError = onClose, + onBlockedNavigation = onBlockedNavigation, + isPaymentBridgeSupported = webViewInterface::supportsPaymentBridge, ) } @@ -54,7 +61,6 @@ fun ShopWebViewScreen( Box(modifier = Modifier.weight(1f)) { AndroidView( - modifier = Modifier.fillMaxSize(), factory = { context -> WebView(context).apply { layoutParams = ViewGroup.LayoutParams( @@ -62,13 +68,14 @@ fun ShopWebViewScreen( ViewGroup.LayoutParams.MATCH_PARENT, ) + webView = this this.webViewClient = webViewClient configureForBasicWebContent() - addJavascriptInterface(webViewInterface, "Android") + webViewInterface.attachTo(this) loadUrl(bitrefillUrlOf(page)) - webView = this } }, + modifier = Modifier.fillMaxSize() ) if (isLoading) { @@ -96,6 +103,7 @@ private fun Preview() { onClose = {}, onBack = {}, onPaymentIntent = {}, + onBlockedNavigation = {}, page = "esims", title = "Gift Cards" ) diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 5ec25ced03..f8132a54aa 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -16,7 +16,9 @@ sealed class ServiceError(message: String) : AppError(message) { class NodeNotSetup : ServiceError("Node is not setup") class NodeNotStarted : ServiceError("Node is not started") class MnemonicNotFound : ServiceError("Mnemonic not found") + class VssAuthRequired : ServiceError("VSS requires LNURL-auth") class NodeStillRunning : ServiceError("Node is still running") + class NodeReleaseTimeout : ServiceError("Previous node release did not finish in time") class InvalidNodeSigningMessage : ServiceError("Invalid node signing message") class CurrencyRateUnavailable : ServiceError("Currency rate unavailable") class BlocktankInfoUnavailable : ServiceError("Blocktank info not available") diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 317da179db..34e671d946 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -34,6 +34,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -56,6 +57,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.lightningdevkit.ldknode.ChannelDataMigration @@ -93,6 +96,7 @@ import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.setClipboardText import to.bitkit.ext.toHex import to.bitkit.ext.toUserMessage @@ -236,10 +240,13 @@ class AppViewModel @Inject constructor( private val _quickPayData = MutableStateFlow(null) val quickPayData = _quickPayData.asStateFlow() - private var activeScanJob: Job? = null + private val scanMutex = Mutex() @Volatile - private var activeScanInput: String? = null + private var scheduledScan: ScheduledScan? = null + + private val deferredScanLock = Any() + private var deferredScan: DeferredScan? = null private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() @@ -260,6 +267,7 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() + private var sheetTransitionJob: Job? = null private var queuedPairingCodeRequestId: Long? = null private val processedPaymentsLock = Any() @@ -287,6 +295,7 @@ class AppViewModel @Inject constructor( fun setIsAuthenticated(value: Boolean) { _isAuthenticated.value = value + if (value) flushDeferredScan() } val pinAttemptsRemaining = keychain.pinAttemptsRemaining() @@ -347,9 +356,12 @@ class AppViewModel @Inject constructor( showPairingCodeSheet(requestId) } else { queuedPairingCodeRequestId = null + val shouldFlush = _currentSheet.value is Sheet.Hardware && + (_currentSheet.value as? Sheet.Hardware)?.route is HardwareRoute.PairCode _currentSheet.update { sheet -> if (sheet is Sheet.Hardware && sheet.route is HardwareRoute.PairCode) null else sheet } + if (shouldFlush) flushDeferredScan() } } } @@ -363,10 +375,11 @@ class AppViewModel @Inject constructor( showSheet(Sheet.TimedSheet(sheetType)) } } else { - // Clear the timed sheet when manager sets it to null + val shouldFlush = _currentSheet.value is Sheet.TimedSheet _currentSheet.update { current -> if (current is Sheet.TimedSheet) null else current } + if (shouldFlush) flushDeferredScan() } } } @@ -1289,7 +1302,7 @@ class AppViewModel @Inject constructor( private suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = params?.get("lightning")?.let { bolt11 -> - runCatching { coreService.decode(bolt11) }.getOrNull() + runSuspendCatching { coreService.decode(bolt11) }.getOrNull() ?.let { it as? Scanner.Lightning } ?.invoice ?.takeIf { lnInv -> @@ -1344,31 +1357,147 @@ class AppViewModel @Inject constructor( data: String, startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, + contactPaymentContext: ContactPaymentContext? = null, + preserveUntilComplete: Boolean = false, ) { - val normalized = data.removeLightningSchemes() - val scanLogInput = SamRockSetupRequest.sanitizedDescription(normalized) ?: data - val scanId = if (scanLogInput.length > 24) { - "${scanLogInput.take(11)}…${scanLogInput.takeLast(11)}" - } else { - scanLogInput + if (!_isAuthenticated.value) { + enqueueDeferredScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, + ) + return } - if (normalized == activeScanInput && activeScanJob?.isActive == true) { + val normalized = data.removeLightningSchemes() + val scanId = scanLogId(data) + + val scheduled = scheduledScan + val isSameActiveScan = normalized == scheduled?.normalizedInput && + scheduled.job.isActive && + (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) + if (isSameActiveScan) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) return } - activeScanJob?.let { + if (scheduled?.job?.isActive == true && scheduled.mustComplete) { + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) + return + } + + val previousJob = scheduled?.job + val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { + scanMutex.withLock { + setActiveContactPaymentContext(contactPaymentContext) + if (startDelay > Duration.ZERO) delay(startDelay) + handleScan(data, routePubkyKeys) + } + } + val nextScheduledScan = ScheduledScan( + job = nextJob, + normalizedInput = normalized, + contactPaymentContext = contactPaymentContext, + mustComplete = preserveUntilComplete, + ) + + scheduledScan = nextScheduledScan + nextJob.invokeOnCompletion { + if (scheduledScan === nextScheduledScan) scheduledScan = null + if (nextJob.isCancelled) return@invokeOnCompletion + viewModelScope.launch { flushDeferredScan() } + } + + Logger.debug("Starting scan from '${source.label}': '$scanId'", context = TAG) + nextJob.start() + previousJob?.let { Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG) it.cancel() } + } - activeScanInput = normalized - Logger.debug("Starting scan from '${source.label}': '$scanId'", context = TAG) - activeScanJob = viewModelScope.launch { - if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys) - }.also { it.invokeOnCompletion { if (activeScanInput == normalized) activeScanInput = null } } + private fun scanLogId(data: String): String { + val scanLogInput = SamRockSetupRequest.sanitizedDescription(data.removeLightningSchemes()) ?: data + return if (scanLogInput.length > SCAN_LOG_ID_MAX_LENGTH) { + "${scanLogInput.take(SCAN_LOG_ID_AFFIX_LENGTH)}…${scanLogInput.takeLast(SCAN_LOG_ID_AFFIX_LENGTH)}" + } else { + scanLogInput + } + } + + private fun enqueueDeferredScan( + source: ScanSource, + data: String, + startDelay: Duration, + routePubkyKeys: Boolean, + contactPaymentContext: ContactPaymentContext?, + ) { + val scanId = scanLogId(data) + val normalized = data.removeLightningSchemes() + synchronized(deferredScanLock) { + val queued = deferredScan + if (queued?.data?.removeLightningSchemes() == normalized) { + if (contactPaymentContext != null) { + deferredScan = DeferredScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, + ) + return + } + Logger.info("Skipping duplicate queued scan from '${source.label}': '$scanId'", context = TAG) + return + } + if (queued != null) { + Logger.warn( + "Replacing deferred scan from '${queued.source.label}': '${scanLogId(queued.data)}'", + context = TAG, + ) + } + deferredScan = DeferredScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, + ) + } + Logger.info("Queuing '${source.label}' scan for deferred handling: '$scanId'", context = TAG) + } + + private fun isScanPendingOrActive(): Boolean { + if (scheduledScan?.job?.isActive == true) return true + return synchronized(deferredScanLock) { deferredScan != null } + } + + private fun isPaymentRequestPresentationBlocked() = !_isAuthenticated.value || + currentSheet.value != null || + sheetTransitionJob?.isActive == true || + hasActiveContactPaymentContext() || + isScanPendingOrActive() + + private fun flushDeferredScan() { + if (!_isAuthenticated.value) return + if (scheduledScan?.job?.isActive == true) return + if (sheetTransitionJob?.isActive == true) return + if (_currentSheet.value != null) return + + val pending = synchronized(deferredScanLock) { + deferredScan.also { deferredScan = null } + } ?: return + + launchScan( + source = pending.source, + data = pending.data, + startDelay = pending.startDelay, + routePubkyKeys = pending.routePubkyKeys, + contactPaymentContext = pending.contactPaymentContext, + preserveUntilComplete = true, + ) } private fun onAddressContinue(data: String) { @@ -1593,20 +1722,22 @@ class AppViewModel @Inject constructor( data: String, startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, + contactPaymentContext: ContactPaymentContext? = null, ) { launchScan( source = ScanSource.SCAN_RESULT, data = data, startDelay = startDelay, routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, ) } fun openContactPayment(paymentRequest: String, publicKey: String) { - synchronized(contactPaymentContextLock) { - activeContactPaymentContext = ContactPaymentContext(publicKey) - } - onScanResult(paymentRequest) + onScanResult( + paymentRequest, + contactPaymentContext = ContactPaymentContext(publicKey), + ) } fun preserveContactPaymentContext(paymentHash: String) { @@ -1687,7 +1818,7 @@ class AppViewModel @Inject constructor( } val safeLogInput = SamRockSetupRequest.sanitizedDescription(input) ?: input - val scan = runCatching { coreService.decode(input) } + val scan = runSuspendCatching { coreService.decode(input) } .onFailure { Logger.error("Failed to decode scan data: '$safeLogInput'", it, context = TAG) } .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() @@ -1767,6 +1898,12 @@ class AppViewModel @Inject constructor( } } + private fun setActiveContactPaymentContext(context: ContactPaymentContext?) { + synchronized(contactPaymentContextLock) { + activeContactPaymentContext = context + } + } + private fun clearPendingContactPaymentContext(paymentHash: String) { synchronized(contactPaymentContextLock) { pendingContactPaymentContexts.remove(paymentHash) @@ -2136,9 +2273,7 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) { - return false - } + if (!settings.isQuickPayEnabled || amountSats == 0uL) return false val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -2754,11 +2889,12 @@ class AppViewModel @Inject constructor( val handler = scanResultHandler val shouldHandleAsProtocol = SamRockSetupRequest.isProtocolUrl(data.removeLightningSchemes()) scanResultHandler = null - hideSheet() + hideSheet(shouldFlushDeferredScan = false) if (handler != null && !shouldHandleAsProtocol) { viewModelScope.launch { delay(SCREEN_TRANSITION_DELAY) handler(data) + flushDeferredScan() } } else { launchScan( @@ -2823,17 +2959,29 @@ class AppViewModel @Inject constructor( } fun showSheet(sheetType: Sheet) { - viewModelScope.launch { + val previousJob = sheetTransitionJob + val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { _currentSheet.value?.let { _currentSheet.update { null } delay(SCREEN_TRANSITION_DELAY) } _currentSheet.update { sheetType } } + sheetTransitionJob = nextJob + nextJob.invokeOnCompletion { + if (sheetTransitionJob === nextJob) sheetTransitionJob = null + } + previousJob?.cancel() + nextJob.start() } - fun hideSheet() { + fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) + + private fun hideSheet(shouldFlushDeferredScan: Boolean) { scanResultHandler = null + sheetTransitionJob?.cancel() + sheetTransitionJob = null + clearActiveContactPaymentContext() when { currentSheet.value is Sheet.TimedSheet -> { // Only dismiss if manager still has a sheet (user initiated) @@ -2847,8 +2995,8 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } - clearActiveContactPaymentContext() showQueuedPairingCodeSheet() + if (shouldFlushDeferredScan) flushDeferredScan() } // endregion @@ -2910,6 +3058,7 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() val needsAuth = settings.isPinEnabled _isAuthenticated.value = !needsAuth + if (!needsAuth) flushDeferredScan() } fun resetIsAuthenticatedState() { @@ -3260,14 +3409,6 @@ class AppViewModel @Inject constructor( } } - private enum class ScanSource(val label: String) { - PASTE("paste"), - SCAN_RESULT("scan result"), - SCANNER_SHEET("scanner sheet"), - ADDRESS_CONTINUE("address continue"), - DEEPLINK("deeplink"), - } - companion object { private const val TAG = "AppViewModel" private val LIGHTNING_SCHEME_PATTERNS = listOf("lightning", "lnurl", "lnurlw", "lnurlc", "lnurlp") @@ -3290,6 +3431,13 @@ class AppViewModel @Inject constructor( private const val BITKIT_SCHEME = "bitkit" private const val PUBKYAUTH_SCHEME = "pubkyauth" private const val RECOVERY_MODE_DEEPLINK = "recovery-mode" + + /** Max characters kept in a scan log id before truncating. */ + private const val SCAN_LOG_ID_MAX_LENGTH = 24 + + /** Characters kept on each side of a truncated scan log id. */ + private const val SCAN_LOG_ID_AFFIX_LENGTH = 11 + private val LNURL_WITHDRAW_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() /** Intent actions carrying a deeplink URI: browsers and apps send VIEW, NFC tag taps send NDEF_DISCOVERED. */ @@ -3297,6 +3445,29 @@ class AppViewModel @Inject constructor( } } +private enum class ScanSource(val label: String) { + PASTE("paste"), + SCAN_RESULT("scan result"), + SCANNER_SHEET("scanner sheet"), + ADDRESS_CONTINUE("address continue"), + DEEPLINK("deeplink"), +} + +private data class ScheduledScan( + val job: Job, + val normalizedInput: String, + val contactPaymentContext: ContactPaymentContext?, + val mustComplete: Boolean, +) + +private data class DeferredScan( + val source: ScanSource, + val data: String, + val startDelay: Duration, + val routePubkyKeys: Boolean, + val contactPaymentContext: ContactPaymentContext?, +) + // region send contract @Stable data class SendUiState( diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index aae350dc6d..f702edebec 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -253,6 +253,10 @@ class WalletViewModel @Inject constructor( fun setInitNodeLifecycleState() = lightningRepo.setInitNodeLifecycleState() fun start(walletIndex: Int = 0) { + // Cancel before the guards: a start that short-circuits never reaches LightningRepo.start, + // so a stop deferred by an earlier background would fire while the app is foregrounded. + lightningRepo.cancelPendingStop() + if (!walletExists || isStarting) return viewModelScope.launch(bgDispatcher) { @@ -334,13 +338,7 @@ class WalletViewModel @Inject constructor( fun stop() { if (!walletExists) return - viewModelScope.launch(bgDispatcher) { - lightningRepo.stop() - .onFailure { - Logger.error("Node stop error", it) - ToastEventBus.send(it) - } - } + lightningRepo.stopDebounced() } fun refreshState() = viewModelScope.launch { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98d83f888e..2ddce03d25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -543,6 +543,7 @@ Shop Book your ₿ holiday Travel + This link can’t be opened from the shop. Get your life on the Bitcoin standard. Spend your Bitcoin on digital gift cards, eSIMs, phone refills, and more. Shop Swipe To Confirm diff --git a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt new file mode 100644 index 0000000000..f67dfc878f --- /dev/null +++ b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt @@ -0,0 +1,49 @@ +package to.bitkit.build + +import org.w3c.dom.Element +import java.nio.file.Path +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PubkyAuthManifestTest { + private val repoRoot = generateSequence( + Path(requireNotNull(System.getProperty("user.dir")) { "user.dir is required" }), + ) { it.parent } + .first { it.resolve("gradle/libs.versions.toml").exists() } + + private val manifest by lazy { parseManifest(repoRoot.resolve("app/src/main/AndroidManifest.xml")) } + + @Test + fun `main activity does not handle pubkyauth`() { + val mainActivity = manifest.getElementsByTagName("activity").elements() + .single { it.getAttribute("android:name") == ".ui.MainActivity" } + + assertFalse(mainActivity.handlesScheme("pubkyauth")) + } + + @Test + fun `pubkyauth alias is disabled by default`() { + val alias = manifest.getElementsByTagName("activity-alias").elements() + .single { it.getAttribute("android:name") == ".ui.MainActivityPubkyAuth" } + + assertEquals(".ui.MainActivity", alias.getAttribute("android:targetActivity")) + assertEquals("false", alias.getAttribute("android:enabled")) + assertEquals("true", alias.getAttribute("android:exported")) + assertTrue(alias.handlesScheme("pubkyauth")) + } + + private fun parseManifest(path: Path) = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(path.toFile()) + + private fun org.w3c.dom.NodeList.elements(): List = + (0 until length).map { item(it) as Element } + + private fun Element.handlesScheme(scheme: String): Boolean = + getElementsByTagName("data").elements().any { it.getAttribute("android:scheme") == scheme } +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 84bafb8348..0eaecc6cb2 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -40,6 +40,7 @@ import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import javax.inject.Provider +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -111,6 +112,46 @@ class BackupRepoTest : BaseUnitTest() { verify(settingsStore, never()).update(any()) } + @Test + fun `automatic wallet backup marks failure when Paykit snapshot fails`() = test { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) + whenever { privatePaykitRepo.backupSnapshot() } + .thenReturn(Result.failure(BackupRepoTestError("paykit session missing capabilities"))) + val backupStatuses = MutableStateFlow( + mapOf( + BackupCategory.WALLET to BackupItemStatus( + synced = 1_000, + required = 2_000, + ), + ) + ) + val allowWalletClear = CompletableDeferred().apply { complete(Unit) } + stubBackupStatuses(backupStatuses, allowWalletClear) {} + stubBackupObservers() + + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + + verify(privatePaykitRepo).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + val status = backupStatuses.value.getValue(BackupCategory.WALLET) + assertTrue(status.isRequired) + assertFalse(status.running) + + advanceTimeBy(5_000) + runCurrent() + + // still one attempt: the failed timestamp suppresses a retry until data changes again + verify(privatePaykitRepo, times(1)).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `start observing backs up stale required status after clearing running flag`() = test { val backupStatuses = MutableStateFlow( diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5d721ce3fa..6790db418a 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -11,12 +11,15 @@ import com.synonym.bitkitcore.LnurlException import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test @@ -32,6 +35,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.isNull @@ -50,11 +54,14 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails import to.bitkit.ext.of import to.bitkit.models.CoinSelectionPreference +import to.bitkit.models.ElectrumServer import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService +import to.bitkit.services.ElectrumProbeError +import to.bitkit.services.ElectrumProbeService import to.bitkit.services.LightningService import to.bitkit.services.LnurlService import to.bitkit.services.LspNotificationsService @@ -75,6 +82,7 @@ import kotlin.time.Duration.Companion.seconds class LightningRepoTest : BaseUnitTest() { companion object { private const val NO_USABLE_CHANNELS_FEEDBACK_DELAY_MS = 2_500L + private const val BACKGROUND_STOP_DELAY_MS = 5_000L } private lateinit var sut: LightningRepo @@ -90,6 +98,7 @@ class LightningRepoTest : BaseUnitTest() { private val lnurlService = mock() private val connectivityRepo = mock() private val vssBackupClientLdk = mock() + private val electrumProbeService = mock() private val urlValidator = UrlValidator { Result.success(Unit) } private val probePaymentA = "probe-payment-a" private val probePaymentB = "probe-payment-b" @@ -99,8 +108,10 @@ class LightningRepoTest : BaseUnitTest() { @Before fun setUp() = runBlocking { - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever { electrumProbeService.probe(any(), any()) }.thenReturn(Result.success(Unit)) whenever(coreService.isGeoBlocked()).thenReturn(false) whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) @@ -119,6 +130,7 @@ class LightningRepoTest : BaseUnitTest() { connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, + electrumProbeService = electrumProbeService, ) } @@ -205,6 +217,79 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `stopDebounced does not stop the node before the delay elapses`() = test { + startNodeForTesting() + + sut.stopDebounced() + testScheduler.advanceTimeBy(BACKGROUND_STOP_DELAY_MS - 1) + + verify(lightningService, never()).stop() + } + + @Test + fun `stopDebounced stops the node after the delay elapses`() = test { + startNodeForTesting() + + sut.stopDebounced() + testScheduler.advanceTimeBy(BACKGROUND_STOP_DELAY_MS) + testScheduler.advanceUntilIdle() + + verify(lightningService).stop() + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + } + + @Test + fun `stopDebounced called twice only stops once`() = test { + startNodeForTesting() + + sut.stopDebounced() + sut.stopDebounced() + testScheduler.advanceUntilIdle() + + verify(lightningService, times(1)).stop() + } + + // Regression: a brief background and foreground cycle must not tear the node down and rebuild it + @Test + fun `a background and foreground cycle within the debounce window never stops the node`() = test { + startNodeForTesting() + + sut.stopDebounced() + testScheduler.advanceTimeBy(BACKGROUND_STOP_DELAY_MS - 1) + sut.start() + testScheduler.advanceUntilIdle() + + verify(lightningService, never()).stop() + assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) + } + + // Regression: node teardown must complete before the next start rebuilds, never overlap it + @Test + fun `stop tears down the node before a subsequent start rebuilds it`() = test { + startNodeForTesting() + whenever(lightningService.node).thenReturn(null) + + sut.stop() + sut.start() + + val inOrder = inOrder(lightningService) + inOrder.verify(lightningService).stop() + inOrder.verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).start(anyOrNull(), any()) + } + + // Regression: a cancelled caller must not strand lifecycle state at Stopping + @Test + fun `stop leaves lifecycle state Stopped when the caller is cancelled`() = test { + startNodeForTesting() + + val job = launch { sut.stop() } + job.cancelAndJoin() + + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + } + @Test fun `resetNetworkGraph clears local cache and VSS copy`() = test { whenever(vssBackupClientLdk.setup(any())).thenReturn(Result.success(Unit)) @@ -687,7 +772,8 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService) + .setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -703,6 +789,109 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + // Regression: recovery must not block the caller. A wedged node's release can gate the rebuild + // for tens of seconds; the failure has to surface immediately while recovery runs in background. + @Test + fun `restartWithElectrumServer surfaces failure before background recovery completes`() = test { + startNodeForTesting() + val badUrl = "ssl://10.0.2.2:60001" + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.stop()).thenReturn(Unit) + // The switch to the new server fails. + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + .thenThrow(RuntimeException("start failed")) + // The background recovery (previous config) blocks until released. + val recoveryStarted = CompletableDeferred() + val releaseRecovery = CompletableDeferred() + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { + recoveryStarted.complete(Unit) + releaseRecovery.await() + } + + val result = sut.restartWithElectrumServer(badUrl) + + assertTrue(result.isFailure) // surfaced without awaiting recovery + assertTrue(recoveryStarted.isCompleted) // recovery was launched in the background + assertFalse(releaseRecovery.isCompleted) // ... and is still draining + + releaseRecovery.complete(Unit) + testScheduler.advanceUntilIdle() + } + + // Regression: a server that cannot work is rejected by the probe before the node is touched. + // Tearing the node down for one is what leaves its electrum tasks wedged, so that a later + // free_node blocks for tens of seconds instead of milliseconds. + @Test + fun `restartWithElectrumServer rejects a bad server without stopping the node`() = test { + startNodeForTesting() + val badUrl = "ssl://10.0.2.2:60001" + val probeError = ElectrumProbeError.NotElectrum(ElectrumServer.parse(badUrl)) + whenever { electrumProbeService.probe(any(), any()) }.thenReturn(Result.failure(probeError)) + + val result = sut.restartWithElectrumServer(badUrl) + + assertEquals(probeError, result.exceptionOrNull()) + verify(lightningService, never()).stop() + verify(lightningService, never()) + .setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull()) + assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) + } + + // Regression: a start carrying an explicit config must never be satisfied by an already-running + // node, which was not built with it. Returning success here let the caller persist a server that + // never started, e.g. when a detached recovery restarted the previous config mid-change. + @Test + fun `start with a custom server url fails when the node is already running`() = test { + startNodeForTesting() + val newServerUrl = "ssl://next.example.com:50002" + + val result = sut.start(customServerUrl = newServerUrl) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is NodeConfigNotAppliedError) + verify(lightningService, never()) + .setup(any(), eq(newServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + } + + // Two-request barrier: a change issued while the background recovery of a previous failed + // change is in flight waits for it, then genuinely rebuilds with the requested server rather + // than riding the node recovery just brought up. Pins the outcome, not the interleaving: under + // a single-threaded test dispatcher recovery holds the lifecycle mutex for its whole rebuild, + // so the window the transaction lock closes only opens on a truly concurrent dispatcher. + @Test + fun `restartWithElectrumServer serializes a second change behind an in-flight recovery`() = test { + startNodeForTesting() + val badUrl = "ssl://10.0.2.2:60001" + val nextUrl = "ssl://next.example.com:50002" + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.stop()).thenReturn(Unit) + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + .thenThrow(RuntimeException("start failed")) + // Hold the background recovery mid-flight so the second change is issued while it runs. + val recoveryStarted = CompletableDeferred() + val releaseRecovery = CompletableDeferred() + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { + recoveryStarted.complete(Unit) + releaseRecovery.await() + } + + assertTrue(sut.restartWithElectrumServer(badUrl).isFailure) + recoveryStarted.await() + + val secondChange = async { sut.restartWithElectrumServer(nextUrl) } + testScheduler.advanceUntilIdle() + assertFalse(secondChange.isCompleted) // serialized behind the in-flight recovery + + releaseRecovery.complete(Unit) + val result = secondChange.await() + + // Success must mean the node was actually rebuilt with the requested server. + assertTrue(result.isSuccess) + verify(lightningService).setup(any(), eq(nextUrl), anyOrNull(), anyOrNull(), anyOrNull()) + } + @Test fun `restartWithRgsServer should setup with new rgs server`() = test { startNodeForTesting() @@ -735,8 +924,9 @@ class LightningRepoTest : BaseUnitTest() { startNodeForTesting() whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) - whenever(lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull(), anyOrNull())) - .thenThrow(RuntimeException("Failed to start node")) + whenever( + lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull(), anyOrNull()), + ).thenThrow(RuntimeException("Failed to start node")) val result = sut.restartWithRgsServer("https://bad.rgs/snapshot") @@ -760,6 +950,7 @@ class LightningRepoTest : BaseUnitTest() { connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = failingValidator, + electrumProbeService = electrumProbeService, ) sutWithFailingValidator.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(mock()) diff --git a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt new file mode 100644 index 0000000000..5afd2b3596 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt @@ -0,0 +1,242 @@ +package to.bitkit.services + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.After +import org.junit.Test +import org.lightningdevkit.ldknode.Network +import to.bitkit.models.ElectrumProtocol +import to.bitkit.models.ElectrumServer +import to.bitkit.test.BaseUnitTest +import java.io.BufferedReader +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import kotlin.concurrent.thread +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +private const val REGTEST_GENESIS = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" +private const val MAINNET_GENESIS = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + +@OptIn(ExperimentalCoroutinesApi::class) +class ElectrumProbeServiceTest : BaseUnitTest() { + private val sut = ElectrumProbeService(ioDispatcher = Dispatchers.IO) + + private var server: ServerSocket? = null + + @After + fun tearDown() { + server?.runCatching { close() } + server = null + } + + @Test + fun `probe succeeds against an electrum server on the expected network`() = test { + val port = startFakeElectrum(genesisHash = REGTEST_GENESIS) + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertTrue(result.isSuccess) + } + + @Test + fun `probe rejects a server on a different network`() = test { + val port = startFakeElectrum(genesisHash = MAINNET_GENESIS) + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `probe accepts a server that does not report a genesis hash`() = test { + val port = startFakeElectrum(genesisHash = null) + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertTrue(result.isSuccess) // server.features is optional, so this must not reject + } + + // Regression: the version reply must be validated as a JSON-RPC envelope, not merely parsed. + // A server that errors on version negotiation is one the real LDK client rejects at startup, + // and letting it through here recreates the failed-start wedge the probe exists to prevent. + @Test + fun `probe rejects a server that errors on version negotiation`() = test { + val port = startFakeElectrum(versionReply = """{"id":0,"error":{"code":1,"message":"unsupported"}}""") + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `probe rejects a version reply with no result`() = test { + val port = startFakeElectrum(versionReply = """{"id":0,"jsonrpc":"2.0"}""") + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `probe rejects a version reply answering a different id`() = test { + val port = startFakeElectrum(versionReply = """{"id":99,"result":["fake-electrs","1.4"]}""") + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + // The rejection reason has to survive as the cause, otherwise the log says only "no electrum + // response" and a wrong protocol version looks the same as a dropped connection. + @Test + fun `probe keeps why the version reply was rejected as the cause`() = test { + val port = startFakeElectrum(versionReply = """{"id":0,"error":{"code":1,"message":"unsupported"}}""") + + val error = sut.probe(serverAt(port), network = Network.REGTEST).exceptionOrNull() + + assertIs(error) + val cause = error.cause?.message.orEmpty() + assertTrue("server.version" in cause, "cause should name the request, was '$cause'") + assertTrue("error" in cause, "cause should report the rejection reason, was '$cause'") + } + + // Regression: a features reply that fails validation must not be read as "no genesis hash" on a + // server whose version negotiation never succeeded — that combination used to probe clean. + @Test + fun `probe rejects a server that errors on both version and features`() = test { + val port = startFakeElectrum( + versionReply = """{"id":0,"error":{"code":1,"message":"unsupported"}}""", + featuresReply = """{"id":1,"error":{"code":-32601,"message":"unknown method"}}""", + ) + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `probe rejects a host that never answers the electrum handshake`() = test { + val port = startSilentServer() + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `probe rejects an unreachable host`() = test { + val port = ServerSocket(0).use { it.localPort } // closed immediately, nothing listens + + val result = sut.probe(serverAt(port), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + // The @settings_10 wedge condition: TLS pointed at a plain-TCP electrum server. Left to + // node.start() this hangs and wedges the node's release; the probe must refuse it instead. + @Test + fun `probe rejects TLS against a plain tcp server`() = test { + val port = startFakeElectrum(genesisHash = REGTEST_GENESIS) + + val result = sut.probe(serverAt(port, ElectrumProtocol.SSL), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `probe reports the requested server in its error`() = test { + val port = startSilentServer() + + val error = sut.probe(serverAt(port), network = Network.REGTEST).exceptionOrNull() + + assertEquals(true, error?.message?.contains("$port")) + } + + // The requests are serialized rather than hand-built, so pin the envelope actually put on the + // wire: a real electrum server has to accept it, and no fake-server assertion covers that. + @Test + fun `probe sends well formed json rpc requests`() = test { + val port = startFakeElectrum(genesisHash = REGTEST_GENESIS) + + sut.probe(serverAt(port), network = Network.REGTEST) + + val sent = synchronized(received) { received.toList() } + assertEquals(2, sent.size) + + val version = Json.parseToJsonElement(sent[0]).jsonObject + assertEquals(0, version.getValue("id").jsonPrimitive.int) + assertEquals("2.0", version.getValue("jsonrpc").jsonPrimitive.content) + assertEquals("server.version", version.getValue("method").jsonPrimitive.content) + assertEquals( + listOf("bitkit", "1.4"), + version.getValue("params").jsonArray.map { it.jsonPrimitive.content }, + ) + + val features = Json.parseToJsonElement(sent[1]).jsonObject + assertEquals(1, features.getValue("id").jsonPrimitive.int) + assertEquals("server.features", features.getValue("method").jsonPrimitive.content) + assertTrue(features.getValue("params").jsonArray.isEmpty()) + } + + private fun serverAt(port: Int, protocol: ElectrumProtocol = ElectrumProtocol.TCP) = ElectrumServer( + host = "127.0.0.1", + tcp = port, + ssl = port, + protocol = protocol, + ) + + /** + * Answers server.version then server.features. Defaults are a well-formed pair; either reply can + * be overridden to exercise a malformed envelope. + */ + private fun startFakeElectrum( + genesisHash: String? = null, + versionReply: String = """{"id":0,"jsonrpc":"2.0","result":["fake-electrs","1.4"]}""", + featuresReply: String = genesisHash + ?.let { """{"id":1,"jsonrpc":"2.0","result":{"genesis_hash":"$it"}}""" } + ?: """{"id":1,"error":{"code":-32601,"message":"unknown method"}}""", + ): Int { + val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + thread(isDaemon = true) { + runCatching { + socket.accept().use { client -> serveElectrum(client, versionReply, featuresReply) } + } + } + return socket.localPort + } + + private fun serveElectrum(client: Socket, versionReply: String, featuresReply: String) { + val reader = client.getInputStream().bufferedReader() + val writer = client.getOutputStream().bufferedWriter() + + readAndRespond(reader, writer) { versionReply } + readAndRespond(reader, writer) { featuresReply } + } + + /** Requests the fake server received, in order, so the encoded envelope can be asserted. */ + private val received = mutableListOf() + + private fun readAndRespond(reader: BufferedReader, writer: java.io.Writer, response: () -> String) { + val request = reader.readLine() ?: return + synchronized(received) { received += request } + writer.write(response() + "\n") + writer.flush() + } + + /** Accepts the connection but never speaks electrum, like a non-electrum service on the port. */ + private fun startSilentServer(): Int { + val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + thread(isDaemon = true) { + runCatching { socket.accept().use { it.getInputStream().read() } } + } + return socket.localPort + } +} diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index e77bf24ae2..41d95d0250 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -1,18 +1,50 @@ package to.bitkit.services +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.NodeException +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.timeout +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.async.newSingleThreadDispatcher import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env import to.bitkit.ext.createChannelDetails import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk +import to.bitkit.utils.ServiceError +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +private const val VERIFY_TIMEOUT_MS = 2_000L +private const val RELEASE_GATE_PROBE_MS = 200L +private const val STOP_BOUND_MS = 5_000L +private val SHORT_GATE_TIMEOUT = 200.milliseconds class LightningServiceTest : BaseUnitTest() { private val keychain = mock() @@ -21,12 +53,16 @@ class LightningServiceTest : BaseUnitTest() { private val loggerLdk = mock() private val node = mock() + @get:Rule + val tempFolder = TemporaryFolder() + private lateinit var sut: LightningService @Before fun setUp() { sut = LightningService( bgDispatcher = testDispatcher, + ioDispatcher = testDispatcher, keychain = keychain, vssStoreIdProvider = vssStoreIdProvider, settingsStore = settingsStore, @@ -56,4 +92,307 @@ class LightningServiceTest : BaseUnitTest() { assertTrue(sut.canReceive()) } + + @Test + fun `stop destroys the node handle and clears it`() = test { + sut.stop() + + verify(node).stop() + verify(node).destroy() + assertNull(sut.node) + } + + @Test + fun `stop destroys the node handle when it is already not running`() = test { + whenever(node.stop()).thenThrow(NodeException.NotRunning("not running")) + + sut.stop() + + verify(node).destroy() + assertNull(sut.node) + } + + @Test + fun `stop is a no-op when no node is set`() = test { + sut.node = null + + sut.stop() + + verify(node, never()).destroy() + } + + // Regression: a cancelled caller must not abandon teardown, leaving the rust node to the GC finalizer + @Test + fun `stop completes teardown when the caller is cancelled`() = test { + val job = launch { sut.stop() } + + job.cancelAndJoin() + + verify(node).destroy() + assertNull(sut.node) + } + + // Regression: cancelling while the listener is still winding down must not skip native teardown + @Test + fun `stop completes teardown when cancelled during listener cleanup`() = test { + val listenerEntered = CompletableDeferred() + val listenerCleanup = CompletableDeferred() + whenever(node.nextEventAsync()).doSuspendableAnswer { + listenerEntered.complete(Unit) + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { listenerCleanup.await() } + } + } + sut.startEventListener() + listenerEntered.await() + + val stopJob = launch { sut.stop() } + stopJob.cancel() + listenerCleanup.complete(Unit) + stopJob.join() + testScheduler.advanceUntilIdle() + + verify(node).stop() + verify(node).destroy() + assertNull(sut.node) + } + + // Regression: an external stop (not from inside a handler) must cancel AND join the listener, so + // the loop has fully exited before teardown — the counterpart to the handler-triggered skip. + @Test + fun `external stop cancels and joins the running listener`() = test { + val listening = CompletableDeferred() + val listenerExited = CompletableDeferred() + whenever(node.nextEventAsync()).doSuspendableAnswer { + listening.complete(Unit) + try { + awaitCancellation() + } finally { + listenerExited.complete(Unit) + } + } + sut.startEventListener() + listening.await() // listener is parked inside nextEventAsync + + sut.stop() + + // stop() returned, so cancelAndJoin completed: the listener has exited, not been left running. + assertTrue(listenerExited.isCompleted) + verify(node).destroy() + } + + // Regression: a stop requested from inside an event handler runs on the listener job, so joining + // that job would deadlock; teardown must still complete + @Test + fun `stop from within an event handler completes teardown without deadlock`() = test { + val event = Event.PaymentSuccessful(null, "hash", null, null) + // Gate the first event so startEventListener returns and assigns listenerJob before the + // handler runs; otherwise the eager test dispatcher would hide the self-join. + val releaseEvent = CompletableDeferred() + var delivered = false + whenever(node.nextEventAsync()).doSuspendableAnswer { + // A second poll would be on the node destroy() already freed; the loop must exit instead. + check(!delivered) { "polled the node after teardown freed it" } + delivered = true + releaseEvent.await() + } + // The handler stops the node from inside the listener job; the loop then exits on the flag. + val handler: NodeEventHandler = { sut.stop() } + + sut.startEventListener(handler) + releaseEvent.complete(event) + testScheduler.advanceUntilIdle() + + // timeout() polls in real time: teardown finishes on the LDK/IO threads, not virtual time. + // Without the self-join guard, stop() would deadlock and node.stop() would never run. + verify(node, timeout(VERIFY_TIMEOUT_MS)).stop() + verify(node, timeout(VERIFY_TIMEOUT_MS)).destroy() + // The loop re-checks its guard after the handler returns instead of polling the freed node. + verify(node, times(1)).nextEventAsync() + } + + // Regression: startEventListener() also joins listenerJob, so a re-arm requested from inside a + // handler (e.g. LightningRepo.start on an already-running node) would self-cancel the listener + // it runs on — its CancellationException swallowed by runCatching — silently killing it. The + // listener must survive the re-arm and keep delivering events. + @Test + fun `startEventListener from within a handler keeps the listener running`() = test { + // Gate the first event so the outer startEventListener returns and assigns listenerJob before + // the handler re-arms; otherwise the eager test dispatcher leaves listenerJob null and hides it. + val firstEvent = CompletableDeferred() + val secondEvent = CompletableDeferred() + var polls = 0 + whenever(node.nextEventAsync()).doSuspendableAnswer { + if (++polls == 1) firstEvent.await() else secondEvent.await() + } + var handlerCalls = 0 + val handler: NodeEventHandler = { + when (++handlerCalls) { + 1 -> sut.startEventListener { } // re-arm from inside the listener; must not kill it + 2 -> sut.stop() // reached only if the listener survived; also terminates the loop + } + } + + sut.startEventListener(handler) + firstEvent.complete(Event.PaymentSuccessful(null, "h1", null, null)) + secondEvent.complete(Event.PaymentSuccessful(null, "h2", null, null)) + testScheduler.advanceUntilIdle() + + // Reached only if the second event was delivered, i.e. the re-arm did not kill the listener. + verify(node, timeout(VERIFY_TIMEOUT_MS)).stop() + verify(node, timeout(VERIFY_TIMEOUT_MS)).destroy() + } + + // Regression: stop() nulls listenerJob while the old loop is still unwinding, so a racing start() + // can install a new node and re-arm shouldListenForEvents before the old loop returns. The loop + // must key on node identity and exit, not poll the node the previous teardown freed. + @Test + fun `listener stops polling the old node once a new node is swapped in`() = test { + val newNode = mock() + val releaseEvent = CompletableDeferred() + var delivered = false + whenever(node.nextEventAsync()).doSuspendableAnswer { + check(!delivered) { "polled the stale node after it was swapped out" } + delivered = true + releaseEvent.await() + } + // Simulate a concurrent start(): swap in a new node with the listener flag still on. + val handler: NodeEventHandler = { sut.node = newNode } + + sut.startEventListener(handler) + releaseEvent.complete(Event.PaymentSuccessful(null, "hash", null, null)) + testScheduler.advanceUntilIdle() + + verify(node, times(1)).nextEventAsync() + verify(newNode, never()).nextEventAsync() + } + + private class GateFixture( + scope: CoroutineScope, + val gated: LightningService, + val destroyGate: CountDownLatch, + val destroyed: AtomicBoolean, + ) : CoroutineScope by scope + + // Runs [block] against a LightningService on real dispatchers whose node.destroy() blocks on + // destroyGate, so the release gate can be observed. Real dispatchers + a controllably delayed + // destroy() are exactly the setup the gate needs to be tested independently of virtual time. + private fun runGateTest(block: suspend GateFixture.() -> Unit) = runBlocking { + Env.initAppStoragePath(tempFolder.root.absolutePath) + val io = newSingleThreadDispatcher("test-gate-io") + val bg = newSingleThreadDispatcher("test-gate-bg") + val gated = LightningService(bg, io, keychain, vssStoreIdProvider, settingsStore, loggerLdk) + gated.node = node + val destroyGate = CountDownLatch(1) + val destroyed = AtomicBoolean(false) + whenever(node.destroy()).thenAnswer { + destroyGate.await() + destroyed.set(true) + } + try { + GateFixture(this, gated, destroyGate, destroyed).block() + } finally { + destroyGate.countDown() // release any wedged destroy so the io thread can exit + io.close() + bg.close() + } + } + + // Regression: destructive storage work must wait for the previous node's release (free_node) so + // native lifetimes never overlap. Also pins (d): stop() returns within its bound despite the wedge. + @Test + fun `resetNetworkGraph waits for the previous node release to finish`() = runGateTest { + withTimeout(STOP_BOUND_MS) { gated.stop() } // stop returns within its bound though destroy() is wedged + + val resetDone = CompletableDeferred() + launch(Dispatchers.Default) { + gated.resetNetworkGraph(walletIndex = 0) + resetDone.complete(Unit) + } + + delay(RELEASE_GATE_PROBE_MS) + assertFalse(resetDone.isCompleted) // gate holds while the release is still draining + assertFalse(destroyed.get()) + + destroyGate.countDown() + resetDone.await() + assertTrue(destroyed.get()) // release finished before resetNetworkGraph proceeded + } + + // Regression (b): wipeStorage deletes storage and must wait for the release just like the rebuild. + @Test + fun `wipeStorage waits for the previous node release to finish`() = runGateTest { + withTimeout(STOP_BOUND_MS) { gated.stop() } + + val wipeDone = CompletableDeferred() + launch(Dispatchers.Default) { + gated.wipeStorage(walletIndex = 0) + wipeDone.complete(Unit) + } + + delay(RELEASE_GATE_PROBE_MS) + assertFalse(wipeDone.isCompleted) + assertFalse(destroyed.get()) + + destroyGate.countDown() + wipeDone.await() + assertTrue(destroyed.get()) + } + + // Regression (a): the rebuild path (setup) must wait for the release before building a new node. + @Test + fun `setup waits for the previous node release before rebuilding`() = runGateTest { + withTimeout(STOP_BOUND_MS) { gated.stop() } + + val setupDone = CompletableDeferred>() + launch(Dispatchers.Default) { + setupDone.complete(runCatching { gated.setup(walletIndex = 0) }) + } + + delay(RELEASE_GATE_PROBE_MS) + assertFalse(setupDone.isCompleted) // gate blocks the rebuild while the release drains + + destroyGate.countDown() + setupDone.await() // proceeds once released (build then fails on the mocked deps, which is fine) + } + + // Regression (c): with no pending release the gate must add no latency. + @Test + fun `wipeStorage proceeds immediately when no release is pending`() = runGateTest { + gated.node = null // no stop() has run, so releaseJob is null + + withTimeout(RELEASE_GATE_PROBE_MS) { gated.wipeStorage(walletIndex = 0) } // completes without waiting + } + + // Regression: a stuck free_node must not block rebuilds forever; the gate throws once bounded out. + @Test + fun `awaitNodeRelease throws when the release never finishes`() = runGateTest { + withTimeout(STOP_BOUND_MS) { gated.stop() } // release launched; destroy() stays wedged (never counted down) + + val error = runCatching { gated.awaitNodeRelease(timeout = SHORT_GATE_TIMEOUT) }.exceptionOrNull() + + assertTrue(error is ServiceError.NodeReleaseTimeout) + } + + // Regression: a failing node stop must still release the handle instead of rethrowing and leaking it + @Test + fun `stop destroys the node handle when node stop throws`() = test { + whenever(node.stop()).thenThrow(NodeException.ConnectionFailed("boom")) + + sut.stop() + + verify(node).destroy() + assertNull(sut.node) + } + + // Regression: destroying twice would surface later as IllegalStateException from callWithPointer + @Test + fun `consecutive stop calls destroy the handle only once`() = test { + sut.stop() + sut.stop() + + verify(node, times(1)).destroy() + } } diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt new file mode 100644 index 0000000000..a663b8bac7 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -0,0 +1,173 @@ +package to.bitkit.services + +import android.content.Context +import android.content.pm.PackageManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { + private val context: Context = mock() + private val packageManager: PackageManager = mock() + private val pubkyRepo: PubkyRepo = mock() + private val settingsStore: SettingsStore = mock() + private val isPaykitEnabled = MutableStateFlow(false) + private val publicKey = MutableStateFlow(null) + + @Before + fun setUp() { + whenever(context.packageName).thenReturn(PACKAGE_NAME) + whenever(context.packageManager).thenReturn(packageManager) + whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever(pubkyRepo.publicKey).thenReturn(publicKey) + } + + @Test + fun `handler is enabled for an available locally managed identity`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + + createSut().start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_ENABLED) + assertTrue( + canHandlePubkyAuth( + isPaykitUiEnabled = true, + hasIdentity = true, + hasSecretKey = true, + ), + ) + } + + @Test + fun `handler is disabled with an unavailable Paykit UI`() { + assertFalse( + canHandlePubkyAuth( + isPaykitUiEnabled = false, + hasIdentity = true, + hasSecretKey = true, + ), + ) + } + + @Test + fun `handler is disabled without an identity`() = test { + isPaykitEnabled.value = true + + createSut().start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `handler is disabled for a Ring managed identity`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkyring" + whenever(pubkyRepo.hasSecretKey()).thenReturn(false) + + createSut().start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler is disabled when the local identity is removed`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + createSut().start(backgroundScope) + runCurrent() + clearInvocations(packageManager) + + publicKey.value = null + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler is disabled when the Paykit UI is turned off`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + createSut().start(backgroundScope) + runCurrent() + clearInvocations(packageManager) + + isPaykitEnabled.value = false + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler collection starts once`() = test { + val sut = createSut() + + sut.start(backgroundScope) + sut.start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler keeps observing state after a package manager failure`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + doThrow(IllegalStateException("component update failed")) + .doNothing() + .whenever(packageManager) + .setComponentEnabledSetting(any(), any(), any()) + + createSut().start(backgroundScope) + runCurrent() + + isPaykitEnabled.value = false + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + private fun createSut() = PubkyAuthHandlerRegistrar( + context = context, + pubkyRepo = pubkyRepo, + settingsStore = settingsStore, + ioDispatcher = testDispatcher, + ) + + private fun verifyComponentState(state: Int) { + verify(packageManager).setComponentEnabledSetting( + any(), + eq(state), + eq(PackageManager.DONT_KILL_APP), + ) + } + + private companion object { + const val PACKAGE_NAME = "to.bitkit" + } +} diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 29347acced..26f35d884e 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.ui import android.content.Context +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -10,8 +11,10 @@ import org.junit.Test import org.lightningdevkit.ldknode.PeerDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever @@ -308,6 +311,64 @@ class WalletViewModelTest : BaseUnitTest() { verify(testWalletRepo).refreshBip21() } + // Regression: a start that short-circuits on the isStarting guard never reaches + // LightningRepo.start, so it must cancel a deferred stop itself or the node stops while foregrounded + @Test + fun `foreground start cancels deferred stop while startup is active`() = test { + val testWalletRepo: WalletRepo = mock() + val testLightningRepo: LightningRepo = mock() + val testWalletState = MutableStateFlow(WalletState(walletExists = true)) + + whenever(testWalletRepo.walletState).thenReturn(testWalletState) + whenever(testWalletRepo.balanceState).thenReturn(balanceState) + whenever(testWalletRepo.walletExists()).thenReturn(true) + whenever(testLightningRepo.lightningState).thenReturn(lightningState) + whenever(testLightningRepo.isRecoveryMode).thenReturn(isRecoveryMode) + + val startEntered = CompletableDeferred() + val finishStart = CompletableDeferred() + whenever( + testLightningRepo.start( + any(), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + any(), + ), + ).doSuspendableAnswer { + startEntered.complete(Unit) + finishStart.await() + Result.success(Unit) + } + + val testSut = WalletViewModel( + context = context, + bgDispatcher = testDispatcher, + walletRepo = testWalletRepo, + lightningRepo = testLightningRepo, + settingsStore = settingsStore, + backupRepo = backupRepo, + blocktankRepo = blocktankRepo, + pubkyRepo = pubkyRepo, + migrationService = migrationService, + connectivityRepo = connectivityRepo, + ) + + testSut.start() + startEntered.await() + testSut.stop() + testSut.start() + + verify(testLightningRepo).stopDebounced() + verify(testLightningRepo, times(2)).cancelPendingStop() + + finishStart.complete(Unit) + advanceUntilIdle() + } + @Test fun `start should skip refreshBip21 when restore is in progress`() = test { // Create fresh mocks for this test diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt new file mode 100644 index 0000000000..0ad40a9994 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -0,0 +1,69 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ShopOriginTest { + + @Test + fun `bridge script accepts only the Bitrefill embed origin`() { + val script = shopMessageBridgeScript() + + assertTrue("addEventListener('message'" in script) + assertTrue("__bitkitShopBridgeInstalled" in script) + assertFalse("window.postMessage =" in script) + assertTrue("event.origin !== 'https://embed.bitrefill.com'" in script) + assertFalse("endsWith('.bitrefill.com')" in script) + } + + @Test + fun `https Bitrefill hosts are allowed`() { + assertTrue(isAllowedShopOrigin("https://embed.bitrefill.com")) + assertTrue(isAllowedShopOrigin("https://embed.bitrefill.com/gift-cards")) + assertTrue(isAllowedShopOrigin("https://bitrefill.com")) + assertTrue(isAllowedShopOrigin("https://www.bitrefill.com/esims")) + assertTrue(isAllowedShopHost("embed.bitrefill.com")) + assertTrue(isAllowedShopHost("BITREFILL.COM")) + } + + @Test + fun `payment messages accept only the Bitrefill embed origin`() { + assertTrue(isAllowedShopPaymentOrigin("https://embed.bitrefill.com")) + assertTrue(isAllowedShopPaymentOrigin("HTTPS://EMBED.BITREFILL.COM")) + assertFalse(isAllowedShopPaymentOrigin("https://bitrefill.com")) + assertFalse(isAllowedShopPaymentOrigin("https://checkout.bitrefill.com")) + assertFalse(isAllowedShopPaymentOrigin("https://embed.bitrefill.com/gift-cards")) + assertFalse(isAllowedShopPaymentOrigin("https://embed.bitrefill.com.evil.example")) + assertFalse(isAllowedShopPaymentOrigin("http://embed.bitrefill.com")) + assertFalse(isAllowedShopPaymentOrigin("https://embed.bitrefill.com:444")) + assertEquals(setOf("https://embed.bitrefill.com"), shopPaymentOriginRules()) + } + + @Test + fun `payment bridge pages use only the Bitrefill embed origin`() { + assertTrue(isAllowedShopPaymentPage("https://embed.bitrefill.com/gift-cards?region=us")) + assertTrue(isAllowedShopPaymentPage("https://embed.bitrefill.com:443/gift-cards")) + assertFalse(isAllowedShopPaymentPage("https://www.bitrefill.com/esims")) + assertFalse(isAllowedShopPaymentPage("https://embed.bitrefill.com.evil.example")) + assertFalse(isAllowedShopPaymentPage("http://embed.bitrefill.com")) + assertFalse(isAllowedShopPaymentPage("https://embed.bitrefill.com:444/gift-cards")) + assertFalse(isAllowedShopPaymentPage("https://user@embed.bitrefill.com/gift-cards")) + } + + @Test + fun `non-Bitrefill and non-https origins are rejected`() { + assertFalse(isAllowedShopOrigin(null)) + assertFalse(isAllowedShopOrigin("")) + assertFalse(isAllowedShopOrigin("embed.bitrefill.com")) + assertFalse(isAllowedShopOrigin("https://evil.example")) + assertFalse(isAllowedShopOrigin("https://bitrefill.com.evil.example")) + assertFalse(isAllowedShopOrigin("https://notbitrefill.com")) + assertFalse(isAllowedShopOrigin("http://embed.bitrefill.com")) + assertFalse(isAllowedShopOrigin("javascript:alert(1)")) + assertFalse(isAllowedShopOrigin("https://127.0.0.1")) + assertFalse(isAllowedShopHost("evil.example")) + assertFalse(isAllowedShopHost(null)) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt new file mode 100644 index 0000000000..913a4c7536 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt @@ -0,0 +1,105 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.core.net.toUri +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ShopWebViewClientTest : BaseUnitTest() { + + private val sut = ShopWebViewClient( + onLoadingStateChanged = {}, + onError = {}, + onBlockedNavigation = {}, + isPaymentBridgeSupported = { true }, + ) + + @Test + fun `main-frame Bitrefill https navigation is allowed`() { + val request = request(url = "https://embed.bitrefill.com/gift-cards", isForMainFrame = true) + + assertFalse(sut.shouldOverrideUrlLoading(null, request)) + } + + @Test + fun `main-frame Bitrefill sibling navigation remains allowed`() { + val request = request(url = "https://www.bitrefill.com/esims", isForMainFrame = true) + + assertFalse(sut.shouldOverrideUrlLoading(null, request)) + } + + @Test + fun `main-frame navigation off Bitrefill is blocked`() { + var wasReported = false + val sut = ShopWebViewClient( + onLoadingStateChanged = {}, + onError = {}, + onBlockedNavigation = { wasReported = true }, + isPaymentBridgeSupported = { true }, + ) + val request = request(url = "https://evil.example/pay", isForMainFrame = true) + + assertTrue(sut.shouldOverrideUrlLoading(null, request)) + assertTrue(wasReported) + } + + @Test + fun `subframe requests are not blocked`() { + val request = request(url = "https://cdn.example/script.js", isForMainFrame = false) + + assertFalse(sut.shouldOverrideUrlLoading(null, request)) + } + + @Test + fun `bridge script is not injected when the payment bridge is unsupported`() { + val webView = mock() + val sut = ShopWebViewClient( + onLoadingStateChanged = {}, + onError = {}, + onBlockedNavigation = {}, + isPaymentBridgeSupported = { false }, + ) + + sut.onPageFinished(webView, "https://embed.bitrefill.com") + + verify(webView, never()).evaluateJavascript(any(), any()) + } + + @Test + fun `bridge script is injected when the payment bridge is supported`() { + val webView = mock() + + sut.onPageFinished(webView, "https://embed.bitrefill.com") + + verify(webView).evaluateJavascript(shopMessageBridgeScript(), null) + } + + @Test + fun `bridge script is not injected on a Bitrefill sibling origin`() { + val webView = mock() + + sut.onPageFinished(webView, "https://www.bitrefill.com/esims") + + verify(webView, never()).evaluateJavascript(any(), any()) + } + + private fun request(url: String, isForMainFrame: Boolean): WebResourceRequest { + val request = mock() + whenever(request.isForMainFrame).thenReturn(isForMainFrame) + whenever(request.url).thenReturn(url.toUri()) + return request + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt new file mode 100644 index 0000000000..bf5689172f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -0,0 +1,189 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ShopWebViewInterfaceTest : BaseUnitTest() { + + @Test + fun `payment_intent from an allowed origin is forwarded`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com", + ) + + assertEquals("lightning:lnbcrt1shop", received) + } + + @Test + fun `payment_intent from a disallowed origin is ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://evil.example", + ) + + assertNull(received) + } + + @Test + fun `payment_intent with a blank URI is ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":" "}"""), + "https://embed.bitrefill.com", + ) + + assertNull(received) + } + + @Test + fun `unknown events are ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"invoice","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com", + ) + + assertNull(received) + } + + @Test + fun `messages without an event are ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"type":"navigation"}"""), + "https://embed.bitrefill.com", + ) + + assertNull(received) + } + + @Test + fun `payment_intent from a Bitrefill sibling origin is ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://checkout.bitrefill.com", + ) + + assertNull(received) + } + + @Test + fun `array buffer messages are ignored`() { + var received: String? = null + val sut = interfaceOf(onPaymentIntent = { received = it }) + + sut.onWebMessage( + WebMessageCompat(byteArrayOf(1, 2, 3)), + "https://embed.bitrefill.com", + ) + + assertNull(received) + } + + @Test + fun `supported WebMessageListener is registered and forwards messages`() { + val webView = mock() + var received: String? = null + var registeredObjectName: String? = null + var registeredOriginRules: Set? = null + var registeredListener: WebViewCompat.WebMessageListener? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + addWebMessageListener = { registeredWebView, jsObjectName, allowedOriginRules, listener -> + assertSame(webView, registeredWebView) + registeredObjectName = jsObjectName + registeredOriginRules = allowedOriginRules + registeredListener = listener + }, + ) + + sut.attachTo(webView) + requireNotNull(registeredListener).onPostMessage( + webView, + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com".toUri(), + true, + mock(), + ) + + assertEquals("Android", registeredObjectName) + assertEquals(shopPaymentOriginRules(), registeredOriginRules) + assertEquals("lightning:lnbcrt1shop", received) + } + + @Test + fun `unsupported WebMessageListener does not register a JavaScript interface`() { + val webView = mock() + var webMessageListenerRegistered = false + val sut = interfaceOf( + onPaymentIntent = {}, + isWebMessageListenerSupported = { false }, + addWebMessageListener = { _, _, _, _ -> webMessageListenerRegistered = true }, + ) + + sut.attachTo(webView) + + verify(webView, never()).addJavascriptInterface(any(), any()) + assertFalse(webMessageListenerRegistered) + } + + private fun interfaceOf( + onPaymentIntent: (String) -> Unit, + isWebMessageListenerSupported: () -> Boolean = { true }, + addWebMessageListener: ( + WebView, + String, + Set, + WebViewCompat.WebMessageListener, + ) -> Unit = { _, _, _, _ -> }, + ) = ShopWebViewInterface( + onPaymentIntent = onPaymentIntent, + isWebMessageListenerSupported = isWebMessageListenerSupported, + addWebMessageListener = addWebMessageListener, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08a8bcf6f6..5a5c717175 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version = "1.1.1" } core-ktx = { module = "androidx.core:core-ktx", version = "1.17.0" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.2.0" } -datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "1.2.0" } +datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "1.2.1" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-compose-rules = { module = "io.nlopez.compose.rules:detekt", version = "0.5.3" } firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.8.0" } @@ -64,7 +64,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.52" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.63" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } @@ -88,7 +88,8 @@ test-junit-ext = { module = "androidx.test.ext:junit", version = "1.3.0" } test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.2.2" } test-robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } test-turbine = { group = "app.cash.turbine", name = "turbine", version = "1.2.1" } -vss-client = { module = "com.synonym:vss-client-android", version = "0.5.20" } +vss-client = { module = "com.synonym:vss-client-android", version = "0.5.23" } +webkit = { module = "androidx.webkit:webkit", version = "1.16.0" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.0" } zxing = { module = "com.google.zxing:core", version = "3.5.4" } lottie = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" }