diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 7c8ff75cd..265cc26c1 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject @@ -84,6 +85,12 @@ class MigrationService @Inject constructor( private const val TAG = "Migration" const val RN_MIGRATION_COMPLETED_KEY = "rnMigrationCompleted" const val RN_MIGRATION_CHECKED_KEY = "rnMigrationChecked" + private const val RN_NEEDS_POST_MIGRATION_SYNC_KEY = "rnNeedsPostMigrationSync" + private const val RN_PENDING_BLOCKTANK_ORDER_IDS_KEY = "rnPendingBlocktankOrderIds" + private const val RN_PENDING_PAID_ORDERS_KEY = "rnPendingPaidOrders" + private const val RN_PENDING_METADATA_KEY = "rnPendingMetadata" + private const val RN_PENDING_TRANSFERS_KEY = "rnPendingTransfers" + private const val RN_PENDING_BOOSTS_KEY = "rnPendingBoosts" private const val OPENING_CURLY_BRACE = "{" private const val MMKV_ROOT = "persist:root" private const val RN_WALLET_NAME = "wallet0" @@ -136,6 +143,181 @@ class MigrationService @Inject constructor( @Volatile private var pendingRemotePaidOrders: Map? = null + @Volatile + private var pendingBlocktankOrderIds: List? = null + + suspend fun needsPostMigrationSync(): Boolean { + val key = stringPreferencesKey(RN_NEEDS_POST_MIGRATION_SYNC_KEY) + return rnMigrationStore.data.first()[key] == "true" + } + + suspend fun setNeedsPostMigrationSync(value: Boolean) { + val key = stringPreferencesKey(RN_NEEDS_POST_MIGRATION_SYNC_KEY) + rnMigrationStore.edit { + if (value) { + it[key] = "true" + } else { + it.remove(key) + } + } + } + + private suspend fun loadPersistedMigrationData() { + val prefs = rnMigrationStore.data.first() + + prefs[stringPreferencesKey(RN_PENDING_BLOCKTANK_ORDER_IDS_KEY)]?.let { data -> + runCatching { + pendingBlocktankOrderIds = json.decodeFromString>(data) + Logger.debug("Loaded ${pendingBlocktankOrderIds?.size} pending Blocktank order IDs", context = TAG) + }.onFailure { + Logger.warn("Failed to load pending Blocktank order IDs", it, context = TAG) + } + } + + prefs[stringPreferencesKey(RN_PENDING_PAID_ORDERS_KEY)]?.let { data -> + runCatching { + pendingRemotePaidOrders = json.decodeFromString>(data) + Logger.debug("Loaded ${pendingRemotePaidOrders?.size} pending paid orders", context = TAG) + }.onFailure { + Logger.warn("Failed to load pending paid orders", it, context = TAG) + } + } + + prefs[stringPreferencesKey(RN_PENDING_METADATA_KEY)]?.let { data -> + runCatching { + pendingRemoteMetadata = json.decodeFromString(data) + Logger.debug("Loaded pending metadata (tags: ${pendingRemoteMetadata?.tags?.size})", context = TAG) + }.onFailure { + Logger.warn("Failed to load pending metadata", it, context = TAG) + } + } + + prefs[stringPreferencesKey(RN_PENDING_TRANSFERS_KEY)]?.let { data -> + runCatching { + pendingRemoteTransfers = json.decodeFromString>(data) + Logger.debug("Loaded ${pendingRemoteTransfers?.size} pending transfers", context = TAG) + }.onFailure { + Logger.warn("Failed to load pending transfers", it, context = TAG) + } + } + + prefs[stringPreferencesKey(RN_PENDING_BOOSTS_KEY)]?.let { data -> + runCatching { + pendingRemoteBoosts = json.decodeFromString>(data) + Logger.debug("Loaded ${pendingRemoteBoosts?.size} pending boosts", context = TAG) + }.onFailure { + Logger.warn("Failed to load pending boosts", it, context = TAG) + } + } + } + + private suspend fun persistBlocktankOrderIds(orderIds: List) { + val key = stringPreferencesKey(RN_PENDING_BLOCKTANK_ORDER_IDS_KEY) + rnMigrationStore.edit { + it[key] = json.encodeToString(orderIds) + } + pendingBlocktankOrderIds = orderIds + Logger.info("Persisted ${orderIds.size} Blocktank order IDs for retry", context = TAG) + } + + private suspend fun persistPaidOrders(paidOrders: Map) { + val key = stringPreferencesKey(RN_PENDING_PAID_ORDERS_KEY) + rnMigrationStore.edit { + it[key] = json.encodeToString(paidOrders) + } + pendingRemotePaidOrders = paidOrders + } + + private suspend fun persistMetadata(metadata: RNMetadata) { + val key = stringPreferencesKey(RN_PENDING_METADATA_KEY) + rnMigrationStore.edit { + it[key] = json.encodeToString(metadata) + } + pendingRemoteMetadata = metadata + Logger.debug("Persisted pending metadata for retry", context = TAG) + } + + private suspend fun persistTransfers(transfers: Map) { + val key = stringPreferencesKey(RN_PENDING_TRANSFERS_KEY) + rnMigrationStore.edit { + it[key] = json.encodeToString(transfers) + } + pendingRemoteTransfers = transfers + Logger.debug("Persisted ${transfers.size} transfers for retry", context = TAG) + } + + private suspend fun persistBoosts(boosts: Map) { + val key = stringPreferencesKey(RN_PENDING_BOOSTS_KEY) + rnMigrationStore.edit { + it[key] = json.encodeToString(boosts) + } + pendingRemoteBoosts = boosts + Logger.debug("Persisted ${boosts.size} boosts for retry", context = TAG) + } + + private suspend fun clearPersistedBlocktankData() { + rnMigrationStore.edit { + it.remove(stringPreferencesKey(RN_PENDING_BLOCKTANK_ORDER_IDS_KEY)) + it.remove(stringPreferencesKey(RN_PENDING_PAID_ORDERS_KEY)) + } + pendingBlocktankOrderIds = null + pendingRemotePaidOrders = null + Logger.debug("Cleared persisted Blocktank data", context = TAG) + } + + private suspend fun clearPersistedTransfers() { + rnMigrationStore.edit { + it.remove(stringPreferencesKey(RN_PENDING_TRANSFERS_KEY)) + } + pendingRemoteTransfers = null + Logger.debug("Cleared persisted transfers", context = TAG) + } + + private suspend fun clearPersistedBoosts() { + rnMigrationStore.edit { + it.remove(stringPreferencesKey(RN_PENDING_BOOSTS_KEY)) + } + pendingRemoteBoosts = null + Logger.debug("Cleared persisted boosts", context = TAG) + } + + private suspend fun clearPersistedMetadata() { + rnMigrationStore.edit { + it.remove(stringPreferencesKey(RN_PENDING_METADATA_KEY)) + } + pendingRemoteMetadata = null + Logger.debug("Cleared persisted metadata", context = TAG) + } + + private suspend fun clearPersistedMigrationData() { + rnMigrationStore.edit { + it.remove(stringPreferencesKey(RN_PENDING_BLOCKTANK_ORDER_IDS_KEY)) + it.remove(stringPreferencesKey(RN_PENDING_PAID_ORDERS_KEY)) + it.remove(stringPreferencesKey(RN_PENDING_METADATA_KEY)) + it.remove(stringPreferencesKey(RN_PENDING_TRANSFERS_KEY)) + it.remove(stringPreferencesKey(RN_PENDING_BOOSTS_KEY)) + } + pendingBlocktankOrderIds = null + pendingRemotePaidOrders = null + pendingRemoteMetadata = null + pendingRemoteTransfers = null + pendingRemoteBoosts = null + Logger.debug("Cleared all persisted migration data", context = TAG) + } + + val canCleanupAfterMigration: Boolean + get() { + if (pendingBlocktankOrderIds != null || pendingRemotePaidOrders != null) { + Logger.debug("Cannot cleanup: pending Blocktank data exists", context = TAG) + return false + } + if (pendingRemoteMetadata != null || pendingRemoteTransfers != null || pendingRemoteBoosts != null) { + Logger.debug("Cannot cleanup: pending metadata/transfers/boosts exists", context = TAG) + return false + } + return true + } + private fun buildRnLdkAccountPath(): File = run { val rnNetworkString = when (Env.network) { Network.BITCOIN -> "bitcoin" @@ -286,6 +468,8 @@ class MigrationService @Inject constructor( it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" } + setNeedsPostMigrationSync(true) + Logger.info("RN local migration completed, marked for post-migration sync", context = TAG) } else { markMigrationChecked() setShowingMigrationLoading(false) @@ -661,29 +845,38 @@ class MigrationService @Inject constructor( } private suspend fun applyRNMetadata(metadata: RNMetadata) { - val allTags = metadata.tags?.mapNotNull { (txId, tagList) -> - runCatching { - var activityId = txId - activityRepo.getOnchainActivityByTxId(txId)?.let { - activityId = it.id + val tags = metadata.tags + if (tags.isNullOrEmpty()) { + Logger.debug("No tags to apply in metadata", context = TAG) + return + } + + var applied = 0 + val allTags = tags.mapNotNull { (activityId, tagList) -> + val onchain = activityRepo.getOnchainActivityByTxId(activityId) + if (onchain != null) { + applied++ + ActivityTags(activityId = onchain.id, tags = tagList) + } else { + val activity = activityRepo.getActivity(activityId).getOrNull() + if (activity != null) { + applied++ + ActivityTags(activityId = activityId, tags = tagList) + } else { + Logger.warn("Activity not found for tags: id=$activityId", context = TAG) + null } - ActivityTags(activityId = activityId, tags = tagList) - }.onFailure { - Logger.error("Failed to get activity ID for $txId: $it", it, context = TAG) - }.getOrNull() - } ?: emptyList() + } + } if (allTags.isNotEmpty()) { runCatching { coreService.activity.upsertTags(allTags) + Logger.info("Applied $applied/${tags.size} pending tags", context = TAG) }.onFailure { - Logger.error("Failed to migrate tags: $it", it, context = TAG) + Logger.error("Failed to upsert tags: $it", it, context = TAG) } } - - metadata.lastUsedTags?.forEach { - settingsStore.addLastUsedTag(it) - } } private suspend fun applyRNTodos(todos: RNTodos) { @@ -960,7 +1153,9 @@ class MigrationService @Inject constructor( } extractRNMetadata(mmkvData)?.let { metadata -> - applyRNMetadata(metadata) + Logger.info("Storing metadata for application after sync", context = TAG) + persistMetadata(metadata) + metadata.lastUsedTags?.forEach { settingsStore.addLastUsedTag(it) } } extractRNWidgets(mmkvData)?.let { widgets -> @@ -996,7 +1191,12 @@ class MigrationService @Inject constructor( } } }.onFailure { e -> - Logger.warn("Failed to fetch and upsert local Blocktank orders: $e", context = TAG) + Logger.warn("Failed to fetch and upsert local Blocktank orders", e, context = TAG) + persistBlocktankOrderIds(orderIds) + if (paidOrders.isNotEmpty()) { + persistPaidOrders(paidOrders) + } + Logger.info("Stored ${orderIds.size} Blocktank order IDs for retry", context = TAG) } } @@ -1046,6 +1246,14 @@ class MigrationService @Inject constructor( it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" } + setNeedsPostMigrationSync(true) + Logger.info("RN migration completed, marked for post-migration sync", context = TAG) + } + + suspend fun cleanupAfterMigration() { + clearPersistedMigrationData() + setNeedsPostMigrationSync(false) + Logger.info("Post-migration cleanup completed", context = TAG) } private suspend fun fetchRNRemoteLdkData() { @@ -1129,19 +1337,20 @@ class MigrationService @Inject constructor( pendingRemoteActivityData = items applyRNActivities(items) }.onFailure { e -> - Logger.warn("Failed to decode RN remote activity backup: $e", context = TAG) + Logger.warn("Failed to decode RN remote activity backup", e, context = TAG) } } - private fun applyRNRemoteMetadata(data: ByteArray) { + private suspend fun applyRNRemoteMetadata(data: ByteArray) { runCatching { - pendingRemoteMetadata = decodeBackupData(data) + val metadata = decodeBackupData(data) + persistMetadata(metadata) }.onFailure { e -> - Logger.warn("Failed to decode RN remote metadata backup: $e", context = TAG) + Logger.warn("Failed to decode RN remote metadata backup", e, context = TAG) } } - private fun applyRNRemoteWallet(data: ByteArray) { + private suspend fun applyRNRemoteWallet(data: ByteArray) { runCatching { val backup = decodeBackupData(data) @@ -1155,7 +1364,7 @@ class MigrationService @Inject constructor( } } if (transferMap.isNotEmpty()) { - pendingRemoteTransfers = transferMap + persistTransfers(transferMap) } } @@ -1171,7 +1380,7 @@ class MigrationService @Inject constructor( } if (boostMap.isNotEmpty()) { Logger.info("Found ${boostMap.size} boosted transactions in remote backup", context = TAG) - pendingRemoteBoosts = boostMap + persistBoosts(boostMap) } else { Logger.debug("No boosted transactions found in RN remote wallet backup", context = TAG) } @@ -1235,53 +1444,99 @@ class MigrationService @Inject constructor( } } + @Suppress("LongMethod", "CyclomaticComplexMethod") suspend fun reapplyMetadataAfterSync() { - if (hasRNMmkvData()) { - val mmkvData = loadRNMmkvData() ?: return + loadPersistedMigrationData() - extractRNActivities(mmkvData)?.let { activities -> - applyOnchainMetadata(activities) - } - - extractRNWalletBackup(mmkvData)?.let { (transfers, boosts) -> - if (transfers.isNotEmpty()) { - Logger.info("Applying ${transfers.size} local transfer markers", context = TAG) - applyRemoteTransfers(transfers) + // Handle MMKV (local) migration data - apply activities FIRST, then metadata + if (hasRNMmkvData()) { + loadRNMmkvData()?.let { mmkvData -> + extractRNActivities(mmkvData)?.let { activities -> + Logger.info("Applying ${activities.size} MMKV activities", context = TAG) + applyOnchainMetadata(activities) } - if (boosts.isNotEmpty()) { - Logger.info("Applying ${boosts.size} local boost markers", context = TAG) - applyBoostTransactions(boosts) + + extractRNWalletBackup(mmkvData)?.let { (transfers, boosts) -> + if (transfers.isNotEmpty()) { + Logger.info("Applying ${transfers.size} local transfer markers", context = TAG) + applyRemoteTransfers(transfers) + } + if (boosts.isNotEmpty()) { + Logger.info("Applying ${boosts.size} local boost markers", context = TAG) + applyBoostTransactions(boosts) + } } - } - extractRNMetadata(mmkvData)?.let { metadata -> - applyRNMetadata(metadata) + // Apply MMKV metadata (tags) AFTER activities are created + extractRNMetadata(mmkvData)?.let { metadata -> + Logger.info("Applying MMKV metadata (tags: ${metadata.tags?.size})", context = TAG) + applyRNMetadata(metadata) + } } } + // Handle remote backup data - apply activities FIRST pendingRemoteActivityData?.let { remoteActivities -> + Logger.info("Applying ${remoteActivities.size} remote activities", context = TAG) applyOnchainMetadata(remoteActivities) pendingRemoteActivityData = null } pendingRemoteTransfers?.let { transfers -> + Logger.info("Applying ${transfers.size} remote transfer markers", context = TAG) applyRemoteTransfers(transfers) - pendingRemoteTransfers = null + clearPersistedTransfers() } pendingRemoteBoosts?.let { boosts -> + Logger.info("Applying ${boosts.size} remote boost markers", context = TAG) applyBoostTransactions(boosts) - pendingRemoteBoosts = null + clearPersistedBoosts() } + // Apply remote metadata (tags) AFTER activities are created pendingRemoteMetadata?.let { metadata -> + Logger.info("Applying remote metadata (tags: ${metadata.tags?.size})", context = TAG) applyRNMetadata(metadata) - pendingRemoteMetadata = null + clearPersistedMetadata() } - pendingRemotePaidOrders?.let { paidOrders -> - applyRemotePaidOrders(paidOrders) - pendingRemotePaidOrders = null + var blocktankFetchFailed = false + pendingBlocktankOrderIds?.let { orderIds -> + if (orderIds.isNotEmpty()) { + Logger.info("Retrying ${orderIds.size} pending Blocktank orders", context = TAG) + runCatching { + val fetchedOrders = coreService.blocktank.orders( + orderIds = orderIds, + filter = null, + refresh = true, + ) + if (fetchedOrders.isNotEmpty()) { + coreService.blocktank.upsertOrderList(fetchedOrders) + Logger.info("Upserted ${fetchedOrders.size} Blocktank orders after retry", context = TAG) + + pendingRemotePaidOrders?.let { paidOrders -> + if (paidOrders.isNotEmpty()) { + Logger.info("Creating transfers for ${paidOrders.size} paid orders", context = TAG) + createTransfersForPaidOrders(paidOrders, fetchedOrders) + } + } + } + pendingBlocktankOrderIds = null + pendingRemotePaidOrders = null + clearPersistedBlocktankData() + }.onFailure { e -> + Logger.warn("Still unable to fetch Blocktank orders", e, context = TAG) + blocktankFetchFailed = true + } + } + } + + if (!blocktankFetchFailed) { + pendingRemotePaidOrders?.let { paidOrders -> + applyRemotePaidOrders(paidOrders) + pendingRemotePaidOrders = null + } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cab834d9d..814bee504 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -285,13 +285,17 @@ class AppViewModel @Inject constructor( migrationService.isShowingMigrationLoading.first { !it } } } catch (e: TimeoutCancellationException) { - if (!isCompletingMigration) { - Logger.warn( - "Migration loading screen timeout, completing migration anyway", - context = TAG - ) - completeMigration() - } + val timeoutSecs = MIGRATION_LOADING_TIMEOUT_MS / 1000 + Logger.warn("Migration loading timeout (${timeoutSecs}s), dismissing", context = TAG) + migrationService.setShowingMigrationLoading(false) + } + } else { + if (migrationService.needsPostMigrationSync()) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.migration__network_required_title), + description = context.getString(R.string.migration__network_required_msg), + ) } } } @@ -359,11 +363,12 @@ class AppViewModel @Inject constructor( private suspend fun handleSyncCompleted() { val isShowingLoading = migrationService.isShowingMigrationLoading.value val isRestoringRemote = migrationService.isRestoringFromRNRemoteBackup.value + val needsPostMigrationSync = migrationService.needsPostMigrationSync() when { - isShowingLoading && !isCompletingMigration -> completeMigration() + (isShowingLoading || needsPostMigrationSync) && !isCompletingMigration -> completeMigration() isRestoringRemote -> completeRNRemoteBackupRestore() - !isShowingLoading && !isCompletingMigration -> walletRepo.debounceSyncByEvent() + !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> walletRepo.debounceSyncByEvent() else -> Unit } } @@ -391,9 +396,16 @@ class AppViewModel @Inject constructor( migrationService.reapplyMetadataAfterSync() activityRepo.syncActivities() walletRepo.syncBalances() - migrationService.setRestoringFromRNRemoteBackup(false) - migrationService.setShowingMigrationLoading(false) - checkForSweepableFunds() + + if (migrationService.canCleanupAfterMigration) { + migrationService.cleanupAfterMigration() + migrationService.setRestoringFromRNRemoteBackup(false) + migrationService.setShowingMigrationLoading(false) + checkForSweepableFunds() + } else { + Logger.info("Post-migration sync incomplete (remote restore), will retry on next sync", context = TAG) + migrationService.setShowingMigrationLoading(false) + } } private fun buildChannelMigrationIfAvailable(): ChannelDataMigration? { @@ -439,10 +451,16 @@ class AppViewModel @Inject constructor( transferRepo.syncTransferStates() migrationService.reapplyMetadataAfterSync() - migrationService.setShowingMigrationLoading(false) - delay(MIGRATION_AUTH_RESET_DELAY_MS) - resetIsAuthenticatedStateInternal() - checkForSweepableFunds() + if (migrationService.canCleanupAfterMigration) { + migrationService.cleanupAfterMigration() + migrationService.setShowingMigrationLoading(false) + delay(MIGRATION_AUTH_RESET_DELAY_MS) + resetIsAuthenticatedStateInternal() + checkForSweepableFunds() + } else { + Logger.info("Post-migration sync incomplete, will retry on next sync", context = TAG) + migrationService.setShowingMigrationLoading(false) + } } private suspend fun finishMigrationWithFallbackSync() { @@ -453,10 +471,16 @@ class AppViewModel @Inject constructor( transferRepo.syncTransferStates() migrationService.reapplyMetadataAfterSync() - migrationService.setShowingMigrationLoading(false) - delay(MIGRATION_AUTH_RESET_DELAY_MS) - resetIsAuthenticatedStateInternal() - checkForSweepableFunds() + if (migrationService.canCleanupAfterMigration) { + migrationService.cleanupAfterMigration() + migrationService.setShowingMigrationLoading(false) + delay(MIGRATION_AUTH_RESET_DELAY_MS) + resetIsAuthenticatedStateInternal() + checkForSweepableFunds() + } else { + Logger.info("Post-migration sync incomplete (fallback), will retry on next sync", context = TAG) + migrationService.setShowingMigrationLoading(false) + } } private suspend fun finishMigrationWithError() { @@ -566,6 +590,9 @@ class AppViewModel @Inject constructor( } private suspend fun notifyPaymentReceived(event: Event) { + if (migrationService.isShowingMigrationLoading.value || migrationService.needsPostMigrationSync()) { + return + } val command = NotifyPaymentReceived.Command.from(event) ?: return val result = notifyPaymentReceivedHandler(command).getOrNull() if (result !is NotifyPaymentReceived.Result.ShowSheet) return @@ -2242,7 +2269,7 @@ class AppViewModel @Inject constructor( private const val MAX_BALANCE_FRACTION = 0.5 private const val MAX_FEE_AMOUNT_RATIO = 0.5 private const val SCREEN_TRANSITION_DELAY_MS = 300L - private const val MIGRATION_LOADING_TIMEOUT_MS = 300_000L + private const val MIGRATION_LOADING_TIMEOUT_MS = 120_000L private const val MIGRATION_AUTH_RESET_DELAY_MS = 500L private const val REMOTE_RESTORE_NODE_RESTART_DELAY_MS = 500L private const val AUTH_CHECK_INITIAL_DELAY_MS = 1000L diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 471fce9d5..cd2b5ee43 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -29,6 +29,8 @@ import to.bitkit.di.BgDispatcher import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.ConnectivityRepo +import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.RecoveryModeError import to.bitkit.repositories.SyncSource @@ -54,6 +56,7 @@ class WalletViewModel @Inject constructor( private val backupRepo: BackupRepo, private val blocktankRepo: BlocktankRepo, private val migrationService: MigrationService, + private val connectivityRepo: ConnectivityRepo, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -82,10 +85,33 @@ class WalletViewModel @Inject constructor( val isRefreshing = _isRefreshing.asStateFlow() private var syncJob: Job? = null + private var pendingWalletStart = false init { checkAndPerformRNMigration() collectStates() + observeNetworkState() + } + + private fun observeNetworkState() = viewModelScope.launch(bgDispatcher) { + connectivityRepo.isOnline.collect { state -> + if (state == ConnectivityState.CONNECTED && pendingWalletStart) { + pendingWalletStart = false + val isChecked = migrationService.isMigrationChecked() + if (!isChecked && migrationService.hasRNWalletData()) { + Logger.info("Network restored, retrying RN migration...", context = TAG) + checkAndPerformRNMigration() + } else { + Logger.info("Network restored, retrying wallet start...", context = TAG) + start() + } + } + } + } + + private suspend fun isNetworkConnected(): Boolean { + val state = connectivityRepo.isOnline.first() + return state == ConnectivityState.CONNECTED } private fun checkAndPerformRNMigration() = viewModelScope.launch(bgDispatcher) { @@ -110,6 +136,7 @@ class WalletViewModel @Inject constructor( } migrationService.setShowingMigrationLoading(true) + Logger.info("RN wallet data found, starting migration...", context = TAG) runCatching { migrationService.migrateFromReactNative() @@ -117,6 +144,13 @@ class WalletViewModel @Inject constructor( walletExists = walletRepo.walletExists() loadCacheIfWalletExists() if (walletExists) { + // Re-check network before starting wallet (like iOS) + if (!isNetworkConnected()) { + Logger.warn("Network offline, dismissing loader and skipping wallet start", context = TAG) + migrationService.setShowingMigrationLoading(false) + pendingWalletStart = true + return@launch + } val channelMigration = buildChannelMigrationIfAvailable() startNode(0, channelMigration) } else { @@ -212,6 +246,16 @@ class WalletViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { isStarting = true try { + if (!isNetworkConnected()) { + Logger.warn("Network offline, skipping wallet start", context = TAG) + pendingWalletStart = true + + if (migrationService.isShowingMigrationLoading.value) { + migrationService.setShowingMigrationLoading(false) + } + return@launch + } + waitForRestoreIfNeeded() val channelMigration = buildChannelMigrationIfAvailable() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d3c32aea..ae59c3971 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -257,6 +257,8 @@ Wallet Balances Please wait while your old wallet data migrates to this new Bitkit version. This usually takes less than a minute. Wallet Migration + Please ensure you have a stable internet connection. Data may show incorrectly while trying to connect. + Network Issues Detected MIGRATING\n<accent>WALLET</accent> Balance moved from spending to savings Reason: %s diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index e80b3f74b..579fc2287 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -20,6 +20,8 @@ import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.ConnectivityRepo +import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.SyncSource @@ -41,11 +43,13 @@ class WalletViewModelTest : BaseUnitTest() { private val backupRepo = mock() private val blocktankRepo = mock() private val migrationService = mock() + private val connectivityRepo = mock() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) private val balanceState = MutableStateFlow(BalanceState()) private val isRecoveryMode = MutableStateFlow(false) + private val isOnline = MutableStateFlow(ConnectivityState.CONNECTED) @Before fun setUp() = runBlocking { @@ -53,6 +57,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(walletRepo.walletState).thenReturn(walletState) whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(migrationService.isMigrationChecked()).thenReturn(true) + whenever(connectivityRepo.isOnline).thenReturn(isOnline) sut = WalletViewModel( context = context, @@ -63,6 +68,7 @@ class WalletViewModelTest : BaseUnitTest() { backupRepo = backupRepo, blocktankRepo = blocktankRepo, migrationService = migrationService, + connectivityRepo = connectivityRepo, ) } @@ -247,6 +253,7 @@ class WalletViewModelTest : BaseUnitTest() { backupRepo = backupRepo, blocktankRepo = blocktankRepo, migrationService = migrationService, + connectivityRepo = connectivityRepo, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -287,6 +294,7 @@ class WalletViewModelTest : BaseUnitTest() { backupRepo = backupRepo, blocktankRepo = blocktankRepo, migrationService = migrationService, + connectivityRepo = connectivityRepo, ) // Trigger restore to put state in non-idle