diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index 371894157a..d605abad4b 100644 --- a/app/src/main/java/to/bitkit/ext/Context.kt +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -20,6 +20,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import to.bitkit.R +import to.bitkit.androidServices.LightningNodeService import java.io.InputStream // System Services @@ -89,3 +90,15 @@ fun Context.startActivityAppSettings() { startActivity(Intent(Settings.ACTION_SETTINGS)) } } + +fun Context.relaunchApp() { + // Stop the foreground node service (its onDestroy stops the LDK node) before relaunching. + runCatching { stopService(Intent(this, LightningNodeService::class.java)) } + + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + ?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } + if (launchIntent != null) { + startActivity(launchIntent) + } + Runtime.getRuntime().exit(0) +} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index e329e2b715..dc4f99a7e4 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -361,15 +361,7 @@ class LightningRepo @Inject constructor( Logger.warn("Network graph is stale, resetting and restarting...", context = TAG) lightningService.stop() - lightningService.resetNetworkGraph(walletIndex) - - runCatching { - vssBackupClientLdk.setup(walletIndex).getOrThrow() - vssBackupClientLdk.deleteObject("network_graph").getOrThrow() - Logger.info("Cleared stale network graph from VSS (first delete)", context = TAG) - }.onFailure { - Logger.warn("Failed to clear graph from VSS (first delete)", it, context = TAG) - } + clearNetworkGraph(walletIndex) _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } shouldRestartForGraphReset = true @@ -485,6 +477,28 @@ class LightningRepo @Inject constructor( } } + suspend fun resetNetworkGraph(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + Logger.warn("Resetting network graph (manual)", context = TAG) + runCatching { + if (lightningService.node != null) { + lightningService.stop() + } + // Propagate VSS failures: a manual reset that leaves the graph in VSS is ineffective. + clearNetworkGraph(walletIndex).getOrThrow() + } + } + + private suspend fun clearNetworkGraph(walletIndex: Int): Result { + lightningService.resetNetworkGraph(walletIndex) + return runCatching { + vssBackupClientLdk.setup(walletIndex).getOrThrow() + vssBackupClientLdk.deleteObject("network_graph").getOrThrow() + Logger.info("Cleared network graph from VSS", context = TAG) + }.onFailure { + Logger.warn("Failed to clear network graph from VSS", it, context = TAG) + } + } + @Suppress("TooGenericExceptionCaught") suspend fun sync(): Result = executeWhenNodeRunning("sync") { // If sync is in progress, mark pending and skip diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryModeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryModeScreen.kt index 4051a6fdc1..16005bc1ac 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryModeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryModeScreen.kt @@ -73,6 +73,9 @@ fun RecoveryModeScreen( recoveryViewModel.wipeWallet() }, onWipeCancel = recoveryViewModel::hideWipeConfirmation, + onResetGraph = recoveryViewModel::showGraphResetConfirmation, + onResetGraphConfirm = recoveryViewModel::resetNetworkGraph, + onResetGraphCancel = recoveryViewModel::hideGraphResetConfirmation, ) AnimatedVisibility( @@ -107,6 +110,9 @@ private fun Content( onWipeApp: () -> Unit, onWipeConfirm: () -> Unit, onWipeCancel: () -> Unit, + onResetGraph: () -> Unit, + onResetGraphConfirm: () -> Unit, + onResetGraphCancel: () -> Unit, ) { Column( modifier = Modifier @@ -147,6 +153,13 @@ private fun Content( onClick = onContactSupport, ) + SecondaryButton( + text = stringResource(R.string.security__reset_graph_button), + isLoading = uiState.isResettingGraph, + onClick = onResetGraph, + enabled = walletExists, + ) + SecondaryButton( text = stringResource(R.string.security__wipe_app), enabled = walletExists, @@ -166,6 +179,17 @@ private fun Content( onDismiss = onWipeCancel, ) } + + if (uiState.showGraphResetConfirmation) { + AppAlertDialog( + onDismissRequest = onResetGraphCancel, + title = stringResource(R.string.security__reset_graph_dialog_title), + text = stringResource(R.string.security__reset_graph_dialog_desc), + confirmText = stringResource(R.string.security__reset_graph_confirm), + onConfirm = onResetGraphConfirm, + onDismiss = onResetGraphCancel, + ) + } } @Preview(showSystemUi = true) @@ -181,6 +205,9 @@ private fun Preview() { onWipeApp = {}, onWipeConfirm = {}, onWipeCancel = {}, + onResetGraph = {}, + onResetGraphConfirm = {}, + onResetGraphCancel = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt index c0f19f3782..29c2124253 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt @@ -3,11 +3,13 @@ package to.bitkit.ui.screens.recovery import android.content.Context import android.content.Intent import android.net.Uri +import androidx.compose.runtime.Immutable import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,6 +19,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.ext.relaunchApp import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo @@ -24,6 +27,7 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class RecoveryViewModel @Inject constructor( @@ -116,6 +120,42 @@ class RecoveryViewModel @Inject constructor( _uiState.update { it.copy(showWipeConfirmation = false) } } + fun showGraphResetConfirmation() { + _uiState.update { it.copy(showGraphResetConfirmation = true) } + } + + fun hideGraphResetConfirmation() { + _uiState.update { it.copy(showGraphResetConfirmation = false) } + } + + fun resetNetworkGraph() { + viewModelScope.launch { + _uiState.update { it.copy(isResettingGraph = true, showGraphResetConfirmation = false) } + + lightningRepo.resetNetworkGraph().fold( + onSuccess = { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.security__reset_graph_success_title), + description = context.getString(R.string.security__reset_graph_success_description), + ) + // Keep the loading state and restart so the graph is re-downloaded on next launch. + delay(RESTART_DELAY) + context.relaunchApp() + }, + onFailure = { error -> + Logger.error("Failed to reset network graph", error, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.security__reset_graph_error), + ) + _uiState.update { it.copy(isResettingGraph = false) } + }, + ) + } + } + fun wipeWallet() { viewModelScope.launch { walletRepo.wipeWallet().onFailure { error -> @@ -181,12 +221,16 @@ class RecoveryViewModel @Inject constructor( private companion object { const val TAG = "RecoveryViewModel" private const val SUBJECT = "Bitkit Support" + private val RESTART_DELAY = 5.seconds } } +@Immutable data class RecoveryUiState( val isExportingLogs: Boolean = false, val showWipeConfirmation: Boolean = false, + val showGraphResetConfirmation: Boolean = false, + val isResettingGraph: Boolean = false, val errorMessage: String? = null, val authAction: PendingAuthAction = PendingAuthAction.None, val isPinEnabled: Boolean = false, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b5d018958e..2479a03f0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -678,6 +678,13 @@ Yes, Reset Are you sure you want to reset your Bitkit Wallet? Do you have a backup of your recovery phrase and wallet data? Reset Bitkit? + Reset Network Graph + Yes, Reset + This clears the cached Lightning network graph so it is downloaded again from scratch. It can fix \"route not found\" errors. Bitkit will restart automatically. + Reset Network Graph? + Could not reset the network graph. Please try again. + Bitkit will restart in a few seconds to download a fresh network graph. + Network Graph Reset Back up your wallet first to avoid loss of your funds and wallet data. Resetting will overwrite your current Bitkit setup. Reset And Restore You have successfully set up a PIN code and {biometricsName} to improve wallet security. diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index c04e983a71..8b40195825 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -175,6 +175,31 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `resetNetworkGraph clears local cache and VSS copy`() = test { + whenever(vssBackupClientLdk.setup(any())).thenReturn(Result.success(Unit)) + whenever(vssBackupClientLdk.deleteObject(any(), any())).thenReturn(Result.success(true)) + + val result = sut.resetNetworkGraph() + + assertTrue(result.isSuccess) + verify(lightningService).resetNetworkGraph(0) + verify(vssBackupClientLdk).setup(0) + verify(vssBackupClientLdk).deleteObject(eq("network_graph"), any()) + } + + @Test + fun `resetNetworkGraph fails when VSS delete fails`() = test { + whenever(vssBackupClientLdk.setup(any())).thenReturn(Result.success(Unit)) + whenever(vssBackupClientLdk.deleteObject(any(), any())) + .thenReturn(Result.failure(RuntimeException("vss unavailable"))) + + val result = sut.resetNetworkGraph() + + assertTrue(result.isFailure) + verify(lightningService).resetNetworkGraph(0) + } + @Test fun `newAddress should fail when node is not running`() = test { val result = sut.newAddress() diff --git a/changelog.d/next/1020.fixed.md b/changelog.d/next/1020.fixed.md new file mode 100644 index 0000000000..a7fd9a71cf --- /dev/null +++ b/changelog.d/next/1020.fixed.md @@ -0,0 +1 @@ +Recovery mode now has a Reset Network Graph option that re-downloads the Lightning network graph to fix "route not found" errors.