Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ class VssBackupClient @Inject constructor(
private val vssStoreIdProvider: VssStoreIdProvider,
private val keychain: Keychain,
) {
@Volatile
private var isSetup = CompletableDeferred<Unit>()
private val setupMutex = Mutex()

suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
setupMutex.withLock {
val gate = isSetup
runCatching {
if (isSetup.isCompleted && !isSetup.isCancelled) {
runCatching { isSetup.await() }.onSuccess { return@runCatching }
}
if (gate.isCompleted && !gate.isCancelled) return@runCatching

Comment thread
ovitrif marked this conversation as resolved.
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: throw MnemonicNotAvailableException()
Expand All @@ -63,11 +63,12 @@ class VssBackupClient @Inject constructor(
passphrase = passphrase,
lnurlAuthServerUrl = lnurlAuthServerUrl,
)
isSetup.complete(Unit)
gate.complete(Unit)
Logger.info("VSS client setup with server: '$vssUrl'", context = TAG)
}
}.onFailure {
isSetup.completeExceptionally(it)
gate.completeExceptionally(it)
if (isSetup === gate) isSetup = CompletableDeferred()
Logger.error("VSS client setup error", it, context = TAG)
}
}
Expand Down
11 changes: 6 additions & 5 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ class VssBackupClientLdk @Inject constructor(
)
}

@Volatile
private var isSetup = CompletableDeferred<Unit>()
private val setupMutex = Mutex()

suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
setupMutex.withLock {
val gate = isSetup
runCatching {
if (isSetup.isCompleted && !isSetup.isCancelled) {
runCatching { isSetup.await() }.onSuccess { return@runCatching }
}
if (gate.isCompleted && !gate.isCancelled) return@runCatching

val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: throw MnemonicNotAvailableException()
Expand All @@ -64,11 +64,12 @@ class VssBackupClientLdk @Inject constructor(
passphrase = passphrase,
lnurlAuthServerUrl = Env.lnurlAuthServerUrl,
)
isSetup.complete(Unit)
gate.complete(Unit)
Logger.info("VSS LDK client setup", context = TAG)
}
}.onFailure {
isSetup.completeExceptionally(it)
gate.completeExceptionally(it)
if (isSetup === gate) isSetup = CompletableDeferred()
Logger.error("VSS LDK client setup error", it, context = TAG)
}
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ class BackupRepo @Inject constructor(
val isRestoring: StateFlow<Boolean> = _isRestoring.asStateFlow()

private val _isWiping = MutableStateFlow(false)
val isWiping: StateFlow<Boolean> = _isWiping.asStateFlow()

fun reset() {
stopObservingBackups()
Expand All @@ -140,6 +141,10 @@ class BackupRepo @Inject constructor(

fun startObservingBackups() {
if (isObserving) return
if (_isWiping.value) {
Logger.debug("Skipped observing backups while wiping", context = TAG)
return
}

isObserving = true
Logger.debug("Start observing backup statuses and data store changes", context = TAG)
Expand Down
68 changes: 36 additions & 32 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -614,30 +614,32 @@ class LightningRepo @Inject constructor(
fun cancelPendingStop() = synchronized(pendingStopLock) { pendingStopJob.getAndSet(null)?.cancel() }

suspend fun stop(): Result<Unit> = withContext(bgDispatcher) {
lifecycleMutex.withLock {
if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) {
lifecycleMutex.withLock { stopLocked() }
}

private suspend fun stopLocked(): Result<Unit> {
if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping() && lightningService.node == null) {
clearProbeOutcomes()
return Result.success(Unit)
}

return runCatching {
withContext(NonCancellable) {
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) }
lightningService.stop()
clearProbeOutcomes()
return@withLock Result.success(Unit)
_lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) }
}

runCatching {
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
// If node is still running, revert to Running state to allow retry
if (lightningService.node != null && lightningService.status?.isRunning == true) {
Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG)
_lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) }
} else {
// Node appears stopped, update state
_lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) }
}
}.onFailure {
Logger.error("Node stop error", it, context = TAG)
// On failure, check actual node state and update accordingly
// If node is still running, revert to Running state to allow retry
if (lightningService.node != null && lightningService.status?.isRunning == true) {
Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG)
_lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) }
} else {
// Node appears stopped, update state
_lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) }
}
}
}
Expand Down Expand Up @@ -810,17 +812,19 @@ class LightningRepo @Inject constructor(

suspend fun wipeStorage(walletIndex: Int): Result<Unit> = withContext(bgDispatcher) {
Logger.debug("wipeStorage called, stopping node first", context = TAG)
stop().mapCatching {
Logger.debug("node stopped, calling wipeStorage", context = TAG)
lightningService.wipeStorage(walletIndex)
clearProbeOutcomes()
_lightningState.update {
LightningState(
nodeStatus = it.nodeStatus,
nodeLifecycleState = it.nodeLifecycleState,
)
lifecycleMutex.withLock {
stopLocked().mapCatching {
Logger.debug("node stopped, calling wipeStorage", context = TAG)
lightningService.wipeStorage(walletIndex)
clearProbeOutcomes()
_lightningState.update {
LightningState(
nodeStatus = it.nodeStatus,
nodeLifecycleState = it.nodeLifecycleState,
)
}
setRecoveryMode(false)
}
setRecoveryMode(false)
}.onFailure {
Logger.error("wipeStorage error", it, context = TAG)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package to.bitkit.ui.settings.backups

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand All @@ -23,6 +24,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import to.bitkit.R
import to.bitkit.ui.appViewModel
Expand All @@ -45,12 +47,17 @@ fun ResetAndRestoreScreen(
val app = appViewModel ?: return
val wallet = walletViewModel ?: return
var showDialog by remember { mutableStateOf(false) }
val isWiping by wallet.isWiping.collectAsStateWithLifecycle()

Content(
showConfirmDialog = showDialog,
isWiping = isWiping,
onClickBackup = { app.showSheet(Sheet.Backup()) },
onClickReset = { showDialog = true },
onResetConfirm = { wallet.wipeWallet() },
onResetConfirm = {
showDialog = false
wallet.wipeWallet()
},
onResetDismiss = { showDialog = false },
onBack = { navController.popBackStack() },
)
Expand All @@ -59,16 +66,19 @@ fun ResetAndRestoreScreen(
@Composable
private fun Content(
showConfirmDialog: Boolean,
isWiping: Boolean,
onClickBackup: () -> Unit,
onClickReset: () -> Unit,
onResetConfirm: () -> Unit,
onResetDismiss: () -> Unit,
onBack: () -> Unit,
) {
BackHandler(enabled = isWiping) {}

ScreenColumn {
AppTopBar(
titleText = stringResource(R.string.security__reset_title),
onBackClick = onBack,
onBackClick = if (isWiping) null else onBack,
actions = { DrawerNavIcon() },
)
Spacer(Modifier.height(32.dp))
Expand Down Expand Up @@ -101,13 +111,15 @@ private fun Content(
SecondaryButton(
text = stringResource(R.string.security__reset_button_backup),
onClick = onClickBackup,
enabled = !isWiping,
modifier = Modifier
.weight(1f)
.testTag(ResetAndRestoreTestTags.BACKUP_BUTTON)
)
PrimaryButton(
text = stringResource(R.string.security__reset_button_reset),
onClick = onClickReset,
isLoading = isWiping,
modifier = Modifier
.weight(1f)
.testTag(ResetAndRestoreTestTags.RESET_BUTTON)
Expand Down Expand Up @@ -143,6 +155,7 @@ private fun Preview() {
AppThemeSurface {
Content(
showConfirmDialog = false,
isWiping = false,
onClickBackup = {},
onClickReset = {},
onResetConfirm = {},
Expand All @@ -158,6 +171,7 @@ private fun PreviewDialog() {
AppThemeSurface {
Content(
showConfirmDialog = true,
isWiping = false,
onClickBackup = {},
onClickReset = {},
onResetConfirm = {},
Expand Down
88 changes: 56 additions & 32 deletions app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package to.bitkit.usecases

import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.sync.Mutex
import to.bitkit.data.AppDb
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
Expand All @@ -18,6 +19,7 @@ import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.WatchOnlyAccountRepo
import to.bitkit.services.CoreService
import to.bitkit.services.MigrationService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Provider
Expand All @@ -44,52 +46,74 @@ class WipeWalletUseCase @Inject constructor(
private val firebaseMessaging: FirebaseMessaging,
private val migrationService: MigrationService,
) {
private val wipeMutex = Mutex()

suspend operator fun invoke(
walletIndex: Int = 0,
resetWalletState: () -> Unit,
onSuccess: () -> Unit,
): Result<Unit> {
if (!wipeMutex.tryLock()) return Result.failure(WipeAlreadyInProgress())
backupRepo.setWiping(true)
return try {
val result = try {
runSuspendCatching {
backupRepo.reset()

privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG)
pubkyRepo.removeBitkitPaymentEndpoints()
.onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) }
privatePaykitRepo.get().closeAndClear()
privatePaykitAddressReservationRepo.clear()
pubkyRepo.wipeLocalState()
keychain.wipe()
firebaseMessaging.deleteToken()

coreService.wipeData()
db.clearAllTables()

settingsStore.reset()
cacheStore.reset()
watchOnlyAccountRepo.clear()
widgetsStore.reset()

blocktankRepo.resetState()
activityRepo.resetState()
hwWalletRepo.resetState()
resetWalletState()

migrationService.markMigrationChecked()

lightningRepo.wipeStorage(walletIndex)
.onSuccess { onSuccess() }
.getOrThrow()
}.onFailure {
Logger.error("Failed to wipe wallet", it, context = TAG)
stopNode().getOrThrow()
cleanupRemote()
wipeLocal(walletIndex, resetWalletState).getOrThrow()
onSuccess()
}
} finally {
backupRepo.setWiping(false)
wipeMutex.unlock()
}
return result.onFailure {
Logger.error("Failed to wipe wallet", it, context = TAG)
if (lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) {
backupRepo.startObservingBackups()
}
}
}

private suspend fun stopNode(): Result<Unit> {
backupRepo.reset()
return lightningRepo.stop()
}

private suspend fun cleanupRemote() {
step("remove Paykit published endpoints") { privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) }
step("remove Bitkit payment endpoints") { pubkyRepo.removeBitkitPaymentEndpoints() }
step("close Paykit SDK") { privatePaykitRepo.get().closeAndClear() }
}

private suspend fun wipeLocal(walletIndex: Int, resetWalletState: () -> Unit): Result<Unit> {
lightningRepo.wipeStorage(walletIndex).onFailure { return Result.failure(it) }
step("clear Paykit address reservations") { privatePaykitAddressReservationRepo.clear() }
step("wipe Pubky local state") { pubkyRepo.wipeLocalState() }
step("wipe keychain") { keychain.wipe() }
step("delete FCM token") { firebaseMessaging.deleteToken() }
step("wipe core data") { coreService.wipeData() }
step("clear database") { db.clearAllTables() }
step("reset settings") { settingsStore.reset() }
step("reset cache") { cacheStore.reset() }
step("clear watch-only accounts") { watchOnlyAccountRepo.clear() }
step("reset widgets") { widgetsStore.reset() }
blocktankRepo.resetState()
activityRepo.resetState()
hwWalletRepo.resetState()
resetWalletState()
step("mark migration checked") { migrationService.markMigrationChecked() }
return Result.success(Unit)
}

private suspend fun step(name: String, block: suspend () -> Any?) {
runSuspendCatching { block() }
.mapCatching { if (it is Result<*>) it.getOrThrow() }
.onFailure { Logger.warn("Failed wipe step '$name'", it, context = TAG) }
}

companion object {
private const val TAG = "WipeWalletUseCase"
}
}

class WipeAlreadyInProgress : AppError("Wallet wipe already in progress")
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class WalletViewModel @Inject constructor(

val isShowingMigrationLoading: StateFlow<Boolean> = migrationService.isShowingMigrationLoading
val isRestoringFromRNRemoteBackup: StateFlow<Boolean> = migrationService.isRestoringFromRNRemoteBackup
val isWiping: StateFlow<Boolean> = backupRepo.isWiping

private val _restoreState = MutableStateFlow<RestoreState>(RestoreState.Initial)
val restoreState: StateFlow<RestoreState> = _restoreState.asStateFlow()
Expand Down
Loading
Loading