From 432745115a600a22bb3007a2fd59ee9ff4494901 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 19:20:28 +0100 Subject: [PATCH 01/43] fix: gate sheets and rotate address on restore --- .../java/to/bitkit/repositories/BackupRepo.kt | 35 +++++++++++-------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2f40d7116..55e65512e 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -7,10 +7,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -63,7 +67,8 @@ class BackupRepo @Inject constructor( private val dataListenerJobs = mutableListOf() private var periodicCheckJob: Job? = null private var isObserving = false - private var isRestoring = false + private val _isRestoring = MutableStateFlow(false) + val isRestoring: StateFlow = _isRestoring.asStateFlow() private var lastNotificationTime = 0L @@ -119,7 +124,7 @@ class BackupRepo @Inject constructor( old.synced == new.synced && old.required == new.required } .collect { status -> - if (status.isRequired && !status.running && !isRestoring) { + if (status.isRequired && !status.running && !isRestoring.value) { scheduleBackup(category) } } @@ -137,7 +142,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring) return@collect + if (isRestoring.value) return@collect markBackupRequired(BackupCategory.SETTINGS) } } @@ -148,7 +153,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring) return@collect + if (isRestoring.value) return@collect markBackupRequired(BackupCategory.WIDGETS) } } @@ -160,7 +165,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring) return@collect + if (isRestoring.value) return@collect markBackupRequired(BackupCategory.WALLET) } } @@ -172,7 +177,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring) return@collect + if (isRestoring.value) return@collect markBackupRequired(BackupCategory.METADATA) } } @@ -185,7 +190,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring) return@collect + if (isRestoring.value) return@collect markBackupRequired(BackupCategory.METADATA) } } @@ -196,7 +201,7 @@ class BackupRepo @Inject constructor( blocktankRepo.blocktankState .drop(1) .collect { - if (isRestoring) return@collect + if (isRestoring.value) return@collect markBackupRequired(BackupCategory.BLOCKTANK) } } @@ -207,7 +212,7 @@ class BackupRepo @Inject constructor( activityRepo.activitiesChanged .drop(1) .collect { - if (isRestoring) return@collect + if (isRestoring.value) return@collect markBackupRequired(BackupCategory.ACTIVITY) } } @@ -220,7 +225,7 @@ class BackupRepo @Inject constructor( val lastSync = lightningService.status?.latestLightningWalletSyncTimestamp?.toLong() ?.let { it * 1000 } // Convert seconds to millis ?: return@collect - if (isRestoring) return@collect + if (isRestoring.value) return@collect cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { it.copy(required = lastSync, synced = lastSync, running = false) } @@ -265,7 +270,7 @@ class BackupRepo @Inject constructor( // Double-check if backup is still needed val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.isRequired && !isRestoring) { + if (status.isRequired && !isRestoring.value) { triggerBackup(category) } else { // Backup no longer needed, reset running flag @@ -407,12 +412,14 @@ class BackupRepo @Inject constructor( ): Result = withContext(ioDispatcher) { Logger.debug("Full restore starting", context = TAG) - isRestoring = true + _isRestoring.update { true } return@withContext try { performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - cacheStore.update { parsed.cache } + cacheStore.update { + parsed.cache.copy(onchainAddress = "") // Fore onchain address rotation + } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() db.tagMetadataDao().upsert(parsed.tagMetadata) @@ -454,7 +461,7 @@ class BackupRepo @Inject constructor( Logger.warn("Full restore error", e = e, context = TAG) Result.failure(e) } finally { - isRestoring = false + _isRestoring.update { true } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d31234905..67e29d10d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -79,6 +79,7 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.models.toActivityFilter import to.bitkit.models.toTxType import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState @@ -106,6 +107,7 @@ class AppViewModel @Inject constructor( private val keychain: Keychain, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, + private val backupRepo: BackupRepo, private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, private val currencyRepo: CurrencyRepo, @@ -1578,6 +1580,8 @@ class AppViewModel @Inject constructor( return } + if (backupRepo.isRestoring.value) return + timedSheetsScope?.cancel() timedSheetsScope = CoroutineScope(bgDispatcher + SupervisorJob()) timedSheetsScope?.launch { From ba1e164baac1c76615dddd088794d2e55cb587f4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 19:58:20 +0100 Subject: [PATCH 02/43] fix: gate new transaction sheet on restore --- app/src/main/java/to/bitkit/ui/MainActivity.kt | 3 ++- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 6b2b389ec..e63335bb5 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -151,7 +151,8 @@ class MainActivity : FragmentActivity() { } ) - if (appViewModel.showNewTransaction) { + val showNewTransaction by appViewModel.showNewTransaction.collectAsStateWithLifecycle() + if (showNewTransaction) { NewTransactionSheet( appViewModel = appViewModel, currencyViewModel = currencyViewModel, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 67e29d10d..1c2e6dcec 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first @@ -1303,8 +1304,8 @@ class AppViewModel @Inject constructor( var isNewTransactionSheetEnabled = true private set - var showNewTransaction by mutableStateOf(false) - private set + private val _showNewTransaction = MutableStateFlow(false) + val showNewTransaction: StateFlow = _showNewTransaction.asStateFlow() private val _newTransaction = MutableStateFlow( NewTransactionSheetDetails( @@ -1336,6 +1337,7 @@ class AppViewModel @Inject constructor( details: NewTransactionSheetDetails, event: Event?, ) = viewModelScope.launch { + if (backupRepo.isRestoring.value) return@launch if (!isNewTransactionSheetEnabled) { Logger.debug("NewTransactionSheet display blocked by isNewTransactionSheetEnabled=false", context = TAG) return@launch @@ -1359,11 +1361,11 @@ class AppViewModel @Inject constructor( hideSheet() _newTransaction.update { details } - showNewTransaction = true + _showNewTransaction.value = true } fun hideNewTransactionSheet() { - showNewTransaction = false + _showNewTransaction.value = false } // endregion From e146b1ce322e20f7e31816378078306c4129c388 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 20:08:47 +0100 Subject: [PATCH 03/43] refactor: unify should backup check --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 55e65512e..5aa047eea 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -124,7 +124,7 @@ class BackupRepo @Inject constructor( old.synced == new.synced && old.required == new.required } .collect { status -> - if (status.isRequired && !status.running && !isRestoring.value) { + if (status.shouldBackup()) { scheduleBackup(category) } } @@ -270,7 +270,7 @@ class BackupRepo @Inject constructor( // Double-check if backup is still needed val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.isRequired && !isRestoring.value) { + if (status.shouldBackup()) { triggerBackup(category) } else { // Backup no longer needed, reset running flag @@ -499,6 +499,8 @@ class BackupRepo @Inject constructor( private fun currentTimeMillis(): Long = nowMillis(clock) + private fun BackupItemStatus.shouldBackup() = this.isRequired && !this.running && !isRestoring.value + companion object { private const val TAG = "BackupRepo" From 9823b7f6276a28fc4e2453819da2492fa092ddef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 20:13:14 +0100 Subject: [PATCH 04/43] chore: Cleanup and add todo --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1c2e6dcec..579bcb236 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1338,6 +1338,7 @@ class AppViewModel @Inject constructor( event: Event?, ) = viewModelScope.launch { if (backupRepo.isRestoring.value) return@launch + if (!isNewTransactionSheetEnabled) { Logger.debug("NewTransactionSheet display blocked by isNewTransactionSheetEnabled=false", context = TAG) return@launch @@ -1348,9 +1349,10 @@ class AppViewModel @Inject constructor( paymentHashOrTxId = event.paymentHash, type = ActivityFilter.ALL, txType = PaymentType.RECEIVED, - retry = false + retry = false, ).getOrNull() + // TODO check if this is still needed now that we're disabling the sheet during restore // TODO Temporary fix while ldk-node bug is not fixed https://github.com/synonymdev/bitkit-android/pull/297 if (activity != null) { Logger.warn("Activity ${activity.rawId()} already exists, skipping sheet", context = TAG) @@ -1577,13 +1579,13 @@ class AppViewModel @Inject constructor( } fun checkTimedSheets() { + if (backupRepo.isRestoring.value) return + if (currentTimedSheet != null || timedSheetQueue.isNotEmpty()) { Logger.debug("Timed sheet already active, skipping check") return } - if (backupRepo.isRestoring.value) return - timedSheetsScope?.cancel() timedSheetsScope = CoroutineScope(bgDispatcher + SupervisorJob()) timedSheetsScope?.launch { From bc53574d96c79570ae4841dbaebb3b3bf89d86d0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 21:10:55 +0100 Subject: [PATCH 05/43] feat: dismiss keyboard on valid words paste --- .../java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 3360aa486..449c5ec90 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -86,6 +87,7 @@ fun RestoreWalletView( val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current val inputFieldPositions = remember { mutableMapOf() } val wordsPerColumn = if (is24Words) 12 else 6 @@ -160,10 +162,11 @@ fun RestoreWalletView( words, onWordCountChanged = { is24Words = it }, onFirstWordChanged = { firstFieldText = it }, + onValidWords = { keyboardController?.hide() }, onInvalidWords = { invalidIndices -> invalidWordIndices.clear() invalidWordIndices.addAll(invalidIndices) - } + }, ) } else { updateWordValidity( @@ -455,6 +458,7 @@ private fun handlePastedWords( onWordCountChanged: (Boolean) -> Unit, onFirstWordChanged: (String) -> Unit, onInvalidWords: (List) -> Unit, + onValidWords: () -> Unit, ) { val pastedWords = pastedText.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } if (pastedWords.size == 12 || pastedWords.size == 24) { @@ -474,6 +478,7 @@ private fun handlePastedWords( words[index] = "" } onFirstWordChanged(pastedWords.first()) + onValidWords() } } From 918f111f1b8b2667a2db7340fb4ac0de3bf649ac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 21:23:55 +0100 Subject: [PATCH 06/43] refactor: encapsulate wallet init logic --- .../main/java/to/bitkit/ui/MainActivity.kt | 3 - .../to/bitkit/viewmodels/WalletViewModel.kt | 13 ++-- .../java/to/bitkit/ui/WalletViewModelTest.kt | 59 ++++++++++--------- 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index e63335bb5..bd9ae8bf3 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -242,8 +242,6 @@ private fun OnboardingNav( scope.launch { runCatching { appViewModel.resetIsAuthenticatedState() - walletViewModel.setInitNodeLifecycleState() - walletViewModel.setRestoringWalletState() walletViewModel.restoreWallet(mnemonic, passphrase) }.onFailure { appViewModel.toast(it) @@ -259,7 +257,6 @@ private fun OnboardingNav( scope.launch { runCatching { appViewModel.resetIsAuthenticatedState() - walletViewModel.setInitNodeLifecycleState() walletViewModel.createWallet(bip39Passphrase = passphrase) }.onFailure { appViewModel.toast(it) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 564f47d73..1fe0ab99b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -118,9 +118,6 @@ class WalletViewModel @Inject constructor( restoreState = RestoreState.BackupRestoreCompleted } - fun setRestoringWalletState() { - restoreState = RestoreState.RestoringWallet - } fun onRestoreContinue() { restoreState = RestoreState.NotRestoring @@ -136,9 +133,7 @@ class WalletViewModel @Inject constructor( } } - fun setInitNodeLifecycleState() { - lightningRepo.setInitNodeLifecycleState() - } + fun setInitNodeLifecycleState() = lightningRepo.setInitNodeLifecycleState() fun start(walletIndex: Int = 0) { if (!walletExists) return @@ -245,6 +240,7 @@ class WalletViewModel @Inject constructor( } suspend fun createWallet(bip39Passphrase: String?) { + setInitNodeLifecycleState() walletRepo.createWallet(bip39Passphrase) .onSuccess { backupRepo.scheduleFullBackup() @@ -255,9 +251,12 @@ class WalletViewModel @Inject constructor( } suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { + setInitNodeLifecycleState() + restoreState = RestoreState.RestoringWallet + walletRepo.restoreWallet( mnemonic = mnemonic, - bip39Passphrase = bip39Passphrase + bip39Passphrase = bip39Passphrase, ).onFailure { error -> ToastEventBus.send(error) } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 0b5e30c14..068f3bb3d 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -82,57 +82,58 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `disconnectPeer should call lightningRepo disconnectPeer and send success toast`() = test { + fun `disconnectPeer should call lightningRepo disconnectPeer`() = test { val testPeer = PeerDetails.from("nodeId", "host", "9735") - whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.success(Unit)) + val testError = Exception("Test error") + whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.failure(testError)) sut.disconnectPeer(testPeer) verify(lightningRepo).disconnectPeer(testPeer) - // Add verification for ToastEventBus.send if you have a way to capture those events } @Test - fun `disconnectPeer should call lightningRepo disconnectPeer and send failure toast`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") - val testError = Exception("Test error") - whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.failure(testError)) - - sut.disconnectPeer(testPeer) + fun `wipeWallet should call walletRepo wipeWallet`() = test { + whenever(walletRepo.wipeWallet(walletIndex = 0)).thenReturn(Result.success(Unit)) + sut.wipeWallet() - verify(lightningRepo).disconnectPeer(testPeer) - // Add verification for ToastEventBus.send if you have a way to capture those events + verify(walletRepo).wipeWallet(walletIndex = 0) } @Test - fun `wipeWallet should call walletRepo wipeWallet`() = - test { - whenever(walletRepo.wipeWallet(walletIndex = 0)).thenReturn(Result.success(Unit)) - sut.wipeWallet() + fun `createWallet should call walletRepo createWallet`() = test { + whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.success(Unit)) + + sut.createWallet(null) - verify(walletRepo).wipeWallet(walletIndex = 0) - } + verify(walletRepo).createWallet(anyOrNull()) + } @Test - fun `createWallet should call walletRepo createWallet and send failure toast`() = test { - val testError = Exception("Test error") - whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.failure(testError)) + fun `createWallet should call setInitNodeLifecycleState`() = test { + whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.success(Unit)) sut.createWallet(null) - verify(walletRepo).createWallet(anyOrNull()) - // Add verification for ToastEventBus.send + verify(lightningRepo).setInitNodeLifecycleState() } @Test - fun `restoreWallet should call walletRepo restoreWallet and send failure toast`() = test { - val testError = Exception("Test error") - whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.failure(testError)) + fun `restoreWallet should call walletRepo restoreWallet`() = test { + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) sut.restoreWallet("test_mnemonic", null) verify(walletRepo).restoreWallet(any(), anyOrNull()) - // Add verification for ToastEventBus.send + } + + @Test + fun `restoreWallet should call setInitNodeLifecycleState`() = test { + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) + + sut.restoreWallet("test_mnemonic", null) + + verify(lightningRepo).setInitNodeLifecycleState() } @Test @@ -169,7 +170,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `onBackupRestoreSuccess should reset restoreState`() = test { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) mockWalletState.value = mockWalletState.value.copy(walletExists = true) - sut.setRestoringWalletState() + sut.restoreWallet("mnemonic", "passphrase") assertEquals(RestoreState.RestoringWallet, sut.restoreState) sut.onRestoreContinue() @@ -182,7 +183,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `proceedWithoutRestore should exit restore flow`() = test { val testError = Exception("Test error") whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.failure(testError)) - sut.setRestoringWalletState() + sut.restoreWallet("mnemonic", "passphrase") mockWalletState.value = mockWalletState.value.copy(walletExists = true) assertEquals(RestoreState.BackupRestoreCompleted, sut.restoreState) @@ -196,7 +197,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) assertEquals(RestoreState.NotRestoring, sut.restoreState) - sut.setRestoringWalletState() + sut.restoreWallet("mnemonic", "passphrase") assertEquals(RestoreState.RestoringWallet, sut.restoreState) mockWalletState.value = mockWalletState.value.copy(walletExists = true) From 4aaa73b459862320e0158a281cdfcfb48623b2a8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 22:01:52 +0100 Subject: [PATCH 07/43] chore: update bitkit-core to 0.1.24 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4158f5461..b9b9032b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.23" } +bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.24" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 3fb3b069309d71c5502097f8644cb55eeed5b551 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 22:02:26 +0100 Subject: [PATCH 08/43] refactor: use core tag metadata model --- .../main/java/to/bitkit/ext/TagMetadata.kt | 24 +++++++++++++++++++ .../java/to/bitkit/models/BackupPayloads.kt | 4 ++-- .../java/to/bitkit/repositories/BackupRepo.kt | 9 ++++--- 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ext/TagMetadata.kt diff --git a/app/src/main/java/to/bitkit/ext/TagMetadata.kt b/app/src/main/java/to/bitkit/ext/TagMetadata.kt new file mode 100644 index 000000000..87c11d7a1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/TagMetadata.kt @@ -0,0 +1,24 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.ActivityTagsMetadata +import to.bitkit.data.entities.TagMetadataEntity + +fun TagMetadataEntity.toActivityTagsMetadata() = ActivityTagsMetadata( + id, + paymentHash, + txId, + address, + isReceive, + tags, + createdAt.toULong(), +) + +fun ActivityTagsMetadata.TagMetadataEntity() = TagMetadataEntity( + id, + paymentHash, + txId, + address, + isReceive, + tags, + createdAt.toLong(), +) diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 16b48d9c4..ba8f96c0d 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -1,13 +1,13 @@ package to.bitkit.models import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityTagsMetadata import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData -import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.entities.TransferEntity @Serializable @@ -21,7 +21,7 @@ data class WalletBackupV1( data class MetadataBackupV1( val version: Int = 1, val createdAt: Long, - val tagMetadata: List, + val tagMetadata: List, val cache: AppCacheData, ) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 5aa047eea..aba8e9867 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -31,6 +31,8 @@ import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis +import to.bitkit.ext.toActivityTagsMetadata +import to.bitkit.ext.TagMetadataEntity import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -366,7 +368,7 @@ class BackupRepo @Inject constructor( } BackupCategory.METADATA -> { - val tagMetadata = db.tagMetadataDao().getAll() + val tagMetadata = db.tagMetadataDao().getAll().map { it.toActivityTagsMetadata() } val cacheData = cacheStore.data.first() val payload = MetadataBackupV1( @@ -422,8 +424,9 @@ class BackupRepo @Inject constructor( } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() - db.tagMetadataDao().upsert(parsed.tagMetadata) - Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) + val tagMetadata = parsed.tagMetadata.map { it.TagMetadataEntity() } + db.tagMetadataDao().upsert(tagMetadata) + Logger.debug("Restored caches and ${tagMetadata.size} tags metadata records", TAG) } performRestore(BackupCategory.SETTINGS) { dataBytes -> From ab5bacf8c18c8775ad2effcaf2cd631db3dd51ec Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 22:44:51 +0100 Subject: [PATCH 09/43] feat: backup & restore activity tags --- .../main/java/to/bitkit/ext/TagMetadata.kt | 2 +- .../java/to/bitkit/models/BackupPayloads.kt | 2 ++ .../to/bitkit/repositories/ActivityRepo.kt | 13 ++++++++++ .../java/to/bitkit/repositories/BackupRepo.kt | 9 ++++--- .../java/to/bitkit/services/CoreService.kt | 24 ++++++++++++------- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/TagMetadata.kt b/app/src/main/java/to/bitkit/ext/TagMetadata.kt index 87c11d7a1..53707024b 100644 --- a/app/src/main/java/to/bitkit/ext/TagMetadata.kt +++ b/app/src/main/java/to/bitkit/ext/TagMetadata.kt @@ -13,7 +13,7 @@ fun TagMetadataEntity.toActivityTagsMetadata() = ActivityTagsMetadata( createdAt.toULong(), ) -fun ActivityTagsMetadata.TagMetadataEntity() = TagMetadataEntity( +fun ActivityTagsMetadata.toTagMetadataEntity() = TagMetadataEntity( id, paymentHash, txId, diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index ba8f96c0d..b80a2fc54 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -1,6 +1,7 @@ package to.bitkit.models import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.ActivityTagsMetadata import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtInfo @@ -39,5 +40,6 @@ data class ActivityBackupV1( val version: Int = 1, val createdAt: Long, val activities: List, + val activityTags: List, val closedChannels: List, ) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 0d476281c..19549d368 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity @@ -622,6 +623,17 @@ class ActivityRepo @Inject constructor( } } + /** + * Get all [ActivityTags] for backup + */ + suspend fun getAllActivityTags(): Result> = withContext(bgDispatcher) { + return@withContext runCatching { + coreService.activity.getAllActivityTags() + }.onFailure { e -> + Logger.error("getAllActivityTags error", e, context = TAG) + } + } + suspend fun saveTagsMetadata( id: String, paymentHash: String? = null, @@ -652,6 +664,7 @@ class ActivityRepo @Inject constructor( suspend fun restoreFromBackup(backup: ActivityBackupV1): Result = withContext(bgDispatcher) { return@withContext runCatching { coreService.activity.upsertList(backup.activities) + coreService.activity.upsertTags(backup.activityTags) coreService.activity.upsertClosedChannelList(backup.closedChannels) } } diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index aba8e9867..a49a47564 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -32,7 +32,7 @@ import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis import to.bitkit.ext.toActivityTagsMetadata -import to.bitkit.ext.TagMetadataEntity +import to.bitkit.ext.toTagMetadataEntity import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -396,10 +396,12 @@ class BackupRepo @Inject constructor( BackupCategory.ACTIVITY -> { val activities = activityRepo.getActivities().getOrDefault(emptyList()) val closedChannels = activityRepo.getClosedChannels().getOrDefault(emptyList()) + val activityTags = activityRepo.getAllActivityTags().getOrDefault(emptyList()) val payload = ActivityBackupV1( createdAt = currentTimeMillis(), activities = activities, + activityTags = activityTags, closedChannels = closedChannels, ) @@ -424,7 +426,7 @@ class BackupRepo @Inject constructor( } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() - val tagMetadata = parsed.tagMetadata.map { it.TagMetadataEntity() } + val tagMetadata = parsed.tagMetadata.map { it.toTagMetadataEntity() } db.tagMetadataDao().upsert(tagMetadata) Logger.debug("Restored caches and ${tagMetadata.size} tags metadata records", TAG) } @@ -452,7 +454,8 @@ class BackupRepo @Inject constructor( val parsed = json.decodeFromString(String(dataBytes)) activityRepo.restoreFromBackup(parsed).onSuccess { Logger.debug( - "Restored ${parsed.activities.size} activities, ${parsed.closedChannels.size} closed channels", + "Restored ${parsed.activities.size} activities, ${parsed.activityTags.size} activity tags, " + + "${parsed.closedChannels.size} closed channels", context = TAG, ) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 62c7c7153..1161184af 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -2,6 +2,8 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.ActivityTags +import com.synonym.bitkitcore.ActivityTagsMetadata import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.ClosedChannelDetails @@ -28,6 +30,7 @@ import com.synonym.bitkitcore.estimateOrderFeeFull import com.synonym.bitkitcore.getActivities import com.synonym.bitkitcore.getActivityById import com.synonym.bitkitcore.getAllClosedChannels +import com.synonym.bitkitcore.getAllTagMetadata import com.synonym.bitkitcore.getAllUniqueTags import com.synonym.bitkitcore.getCjitEntries import com.synonym.bitkitcore.getInfo @@ -44,7 +47,6 @@ import com.synonym.bitkitcore.updateBlocktankUrl import com.synonym.bitkitcore.upsertActivities import com.synonym.bitkitcore.upsertActivity import com.synonym.bitkitcore.upsertCjitEntries -import com.synonym.bitkitcore.upsertClosedChannel import com.synonym.bitkitcore.upsertClosedChannels import com.synonym.bitkitcore.upsertInfo import com.synonym.bitkitcore.upsertOrders @@ -215,14 +217,6 @@ class ActivityService( upsertActivities(activities) } - suspend fun upsertClosedChannelItem(closedChannel: ClosedChannelDetails) = ServiceQueue.CORE.background { - upsertClosedChannel(closedChannel) - } - - suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { - upsertClosedChannels(closedChannels) - } - suspend fun getActivity(id: String): Activity? { return ServiceQueue.CORE.background { getActivityById(id) @@ -285,6 +279,18 @@ class ActivityService( } } + suspend fun upsertTags(activityTags: List) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.upsertTags(activityTags) + } + + suspend fun getAllActivityTags(): List = ServiceQueue.CORE.background { + getAllTagMetadata().map { ActivityTags(it.id, tags = it.tags) } + } + + suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { + upsertClosedChannels(closedChannels) + } + suspend fun closedChannels( sortDirection: SortDirection, ): List = ServiceQueue.CORE.background { From 0ea7ab950381906e5590938ccc2bf715e3c4020b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 23:29:13 +0100 Subject: [PATCH 10/43] refactor: migrate RestoreWalletScreen to MVVM --- .../ui/onboarding/RestoreWalletScreen.kt | 263 +++--------------- .../viewmodels/RestoreWalletViewModel.kt | 174 ++++++++++++ 2 files changed, 217 insertions(+), 220 deletions(-) create mode 100644 app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 449c5ec90..2896201a9 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -24,19 +24,17 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -58,60 +56,35 @@ import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.utils.bip39Words -import to.bitkit.utils.isBip39 -import to.bitkit.utils.validBip39Checksum +import to.bitkit.viewmodels.RestoreWalletViewModel @Composable fun RestoreWalletView( + viewModel: RestoreWalletViewModel = hiltViewModel(), onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, ) { - val words = remember { mutableStateListOf(*Array(24) { "" }) } - val invalidWordIndices = remember { mutableStateListOf() } - val suggestions = remember { mutableStateListOf() } - var focusedIndex by remember { mutableStateOf(null) } - var bip39Passphrase by remember { mutableStateOf("") } - var showingPassphrase by remember { mutableStateOf(false) } - var firstFieldText by remember { mutableStateOf("") } - var is24Words by remember { mutableStateOf(false) } - val checksumErrorVisible by remember { - derivedStateOf { - val wordCount = if (is24Words) 24 else 12 - words.subList(0, wordCount).none { it.isBlank() } && invalidWordIndices.isEmpty() && !words.subList( - 0, - wordCount - ).validBip39Checksum() - } - } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scrollState = rememberScrollState() - val coroutineScope = rememberCoroutineScope() - val keyboardController = LocalSoftwareKeyboardController.current val inputFieldPositions = remember { mutableMapOf() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current - val wordsPerColumn = if (is24Words) 12 else 6 - - val bip39Mnemonic by remember { - derivedStateOf { - val wordCount = if (is24Words) 24 else 12 - words.subList(0, wordCount) - .joinToString(separator = " ") - .trim() + LaunchedEffect(uiState.shouldDismissKeyboard) { + if (uiState.shouldDismissKeyboard) { + focusManager.clearFocus() + keyboardController?.hide() + viewModel.onKeyboardDismissed() } } - fun updateSuggestions(input: String, index: Int?) { - if (index == null || input.length < 2) { - suggestions.clear() - return - } - - suggestions.clear() - if (input.isNotEmpty()) { - val filtered = bip39Words.filter { it.startsWith(input.lowercase()) }.take(3) - if (filtered.size == 1 && filtered.firstOrNull() == input) return - suggestions.addAll(filtered) + LaunchedEffect(uiState.scrollToFieldIndex) { + uiState.scrollToFieldIndex?.let { index -> + inputFieldPositions[index]?.let { position -> + scrollState.animateScrollTo(position) + } + viewModel.onScrollCompleted() } } @@ -149,60 +122,14 @@ fun RestoreWalletView( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f) ) { - for (index in 0 until wordsPerColumn) { + for (index in 0 until uiState.wordsPerColumn) { MnemonicInputField( label = "${index + 1}.", - value = if (index == 0) firstFieldText else words[index], - isError = index in invalidWordIndices, - onValueChanged = { newValue -> - if (index == 0) { - if (newValue.contains(" ")) { - handlePastedWords( - newValue, - words, - onWordCountChanged = { is24Words = it }, - onFirstWordChanged = { firstFieldText = it }, - onValidWords = { keyboardController?.hide() }, - onInvalidWords = { invalidIndices -> - invalidWordIndices.clear() - invalidWordIndices.addAll(invalidIndices) - }, - ) - } else { - updateWordValidity( - newValue, - index, - words, - invalidWordIndices, - onWordUpdate = { firstFieldText = it } - ) - updateSuggestions(newValue, focusedIndex) - } - } else { - updateWordValidity( - newValue, - index, - words, - invalidWordIndices, - ) - updateSuggestions(newValue, focusedIndex) - } - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - }, + value = uiState.words[index], + isError = index in uiState.invalidWordIndices, + onValueChanged = { viewModel.onWordChanged(index, it) }, onFocusChanged = { focused -> - if (focused) { - focusedIndex = index - updateSuggestions(if (index == 0) firstFieldText else words[index], index) - - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - } else if (focusedIndex == index) { - focusedIndex = null - suggestions.clear() - } + viewModel.onWordFocusChanged(index, focused) }, onPositionChanged = { position -> inputFieldPositions[index] = position @@ -216,37 +143,14 @@ fun RestoreWalletView( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f) ) { - for (index in wordsPerColumn until (wordsPerColumn * 2)) { + for (index in uiState.wordsPerColumn until (uiState.wordsPerColumn * 2)) { MnemonicInputField( label = "${index + 1}.", - value = words[index], - isError = index in invalidWordIndices, - onValueChanged = { newValue -> - words[index] = newValue - - updateWordValidity( - newValue, - index, - words, - invalidWordIndices, - ) - updateSuggestions(newValue, focusedIndex) - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - }, + value = uiState.words[index], + isError = index in uiState.invalidWordIndices, + onValueChanged = { viewModel.onWordChanged(index, it) }, onFocusChanged = { focused -> - if (focused) { - focusedIndex = index - updateSuggestions(words[index], index) - - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - } else if (focusedIndex == index) { - focusedIndex = null - suggestions.clear() - } + viewModel.onWordFocusChanged(index, focused) }, onPositionChanged = { position -> inputFieldPositions[index] = position @@ -257,10 +161,10 @@ fun RestoreWalletView( } } // Passphrase - if (showingPassphrase) { + if (uiState.showingPassphrase) { OutlinedTextField( - value = bip39Passphrase, - onValueChange = { bip39Passphrase = it }, + value = uiState.bip39Passphrase, + onValueChange = { viewModel.onPassphraseChanged(it) }, placeholder = { Text( text = stringResource(R.string.onboarding__restore_passphrase_placeholder) @@ -293,7 +197,7 @@ fun RestoreWalletView( .weight(1f) ) - AnimatedVisibility(visible = invalidWordIndices.isNotEmpty()) { + AnimatedVisibility(visible = uiState.invalidWordIndices.isNotEmpty()) { BodyS( text = stringResource( R.string.onboarding__restore_red_explain @@ -303,7 +207,7 @@ fun RestoreWalletView( ) } - AnimatedVisibility(visible = checksumErrorVisible) { + AnimatedVisibility(visible = uiState.checksumErrorVisible) { BodyS( text = stringResource(R.string.onboarding__restore_inv_checksum), color = Colors.Red, @@ -317,21 +221,11 @@ fun RestoreWalletView( .padding(vertical = 16.dp) .fillMaxWidth(), ) { - val areButtonsEnabled by remember { - derivedStateOf { - val wordCount = if (is24Words) 24 else 12 - words.subList(0, wordCount) - .none { it.isBlank() } && invalidWordIndices.isEmpty() && !checksumErrorVisible - } - } - AnimatedVisibility(visible = !showingPassphrase, modifier = Modifier.weight(1f)) { + AnimatedVisibility(visible = !uiState.showingPassphrase, modifier = Modifier.weight(1f)) { SecondaryButton( text = stringResource(R.string.onboarding__advanced), - onClick = { - showingPassphrase = !showingPassphrase - bip39Passphrase = "" - }, - enabled = areButtonsEnabled, + onClick = { viewModel.onAdvancedClick() }, + enabled = uiState.areButtonsEnabled, modifier = Modifier .weight(1f) .testTag("AdvancedButton") @@ -340,9 +234,9 @@ fun RestoreWalletView( PrimaryButton( text = stringResource(R.string.onboarding__restore), onClick = { - onRestoreClick(bip39Mnemonic, bip39Passphrase.takeIf { it.isNotEmpty() }) + onRestoreClick(uiState.bip39Mnemonic, uiState.bip39Passphrase.takeIf { it.isNotEmpty() }) }, - enabled = areButtonsEnabled, + enabled = uiState.areButtonsEnabled, modifier = Modifier .weight(1f) .testTag("RestoreButton") @@ -352,7 +246,7 @@ fun RestoreWalletView( // Suggestions row AnimatedVisibility( - visible = suggestions.isNotEmpty(), + visible = uiState.suggestions.isNotEmpty(), enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), modifier = Modifier @@ -376,31 +270,10 @@ fun RestoreWalletView( .fillMaxWidth() .padding(top = 12.dp) ) { - suggestions.forEach { suggestion -> + uiState.suggestions.forEach { suggestion -> PrimaryButton( text = suggestion, - onClick = { - focusedIndex?.let { index -> - if (index == 0) { - firstFieldText = suggestion - updateWordValidity( - suggestion, - index, - words, - invalidWordIndices, - onWordUpdate = { firstFieldText = it } - ) - } else { - updateWordValidity( - suggestion, - index, - words, - invalidWordIndices, - ) - } - suggestions.clear() - } - }, + onClick = { viewModel.onSuggestionSelected(suggestion) }, size = ButtonSize.Small, fullWidth = false ) @@ -452,56 +325,6 @@ fun MnemonicInputField( ) } -private fun handlePastedWords( - pastedText: String, - words: SnapshotStateList, - onWordCountChanged: (Boolean) -> Unit, - onFirstWordChanged: (String) -> Unit, - onInvalidWords: (List) -> Unit, - onValidWords: () -> Unit, -) { - val pastedWords = pastedText.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } - if (pastedWords.size == 12 || pastedWords.size == 24) { - val invalidWordIndices = pastedWords.withIndex() - .filter { !it.value.isBip39() } - .map { it.index } - - if (invalidWordIndices.isNotEmpty()) { - onInvalidWords(invalidWordIndices) - } - - onWordCountChanged(pastedWords.size == 24) - for (index in pastedWords.indices) { - words[index] = pastedWords[index] - } - for (index in pastedWords.size until words.size) { - words[index] = "" - } - onFirstWordChanged(pastedWords.first()) - onValidWords() - } -} - -private fun updateWordValidity( - newValue: String, - index: Int, - words: SnapshotStateList, - invalidWordIndices: SnapshotStateList, - onWordUpdate: ((String) -> Unit)? = null, -) { - words[index] = newValue - onWordUpdate?.invoke(newValue) - - val isValid = newValue.isBip39() - if (!isValid && newValue.isNotEmpty()) { - if (!invalidWordIndices.contains(index)) { - invalidWordIndices.add(index) - } - } else { - invalidWordIndices.remove(index) - } -} - @Preview(showSystemUi = true) @Composable fun RestoreWalletViewPreview() { diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt new file mode 100644 index 000000000..ebe3f9c89 --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -0,0 +1,174 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import to.bitkit.utils.bip39Words +import to.bitkit.utils.isBip39 +import to.bitkit.utils.validBip39Checksum +import javax.inject.Inject + +@HiltViewModel +class RestoreWalletViewModel @Inject constructor() : ViewModel() { + private val _uiState = MutableStateFlow(RestoreWalletUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onWordChanged(index: Int, value: String) { + if (value.contains(" ")) { + handlePastedWords(value) + } else { + updateWordValidity(index, value) + updateSuggestions(value, _uiState.value.focusedIndex) + _uiState.update { it.copy(scrollToFieldIndex = index) } + } + } + + fun onWordFocusChanged(index: Int, focused: Boolean) { + if (focused) { + _uiState.update { + it.copy( + focusedIndex = index, + scrollToFieldIndex = index + ) + } + updateSuggestions(_uiState.value.words[index], index) + } else if (_uiState.value.focusedIndex == index) { + _uiState.update { + it.copy( + focusedIndex = null, + suggestions = emptyList() + ) + } + } + } + + fun onSuggestionSelected(suggestion: String) { + _uiState.value.focusedIndex?.let { index -> + updateWordValidity(index, suggestion) + _uiState.update { it.copy(suggestions = emptyList()) } + } + } + + fun onAdvancedClick() { + _uiState.update { + it.copy( + showingPassphrase = !it.showingPassphrase, + bip39Passphrase = "" + ) + } + } + + fun onPassphraseChanged(passphrase: String) { + _uiState.update { it.copy(bip39Passphrase = passphrase) } + } + + fun onKeyboardDismissed() { + _uiState.update { it.copy(shouldDismissKeyboard = false) } + } + + fun onScrollCompleted() { + _uiState.update { it.copy(scrollToFieldIndex = null) } + } + + private fun handlePastedWords(pastedText: String) { + // Splits on one or more whitespace characters (spaces, tabs, newlines) in case user pastes from pass managers + val pastedWords = pastedText.split(Regex("\\s+")).filter(String::isNotBlank) + if (pastedWords.size == 12 || pastedWords.size == 24) { + val invalidIndices = pastedWords.withIndex() + .filter { !it.value.isBip39() } + .map { it.index } + .toSet() + + val newWords = _uiState.value.words.toMutableList().apply { + pastedWords.forEachIndexed { index, word -> this[index] = word } + for (index in pastedWords.size until 24) { + this[index] = "" + } + } + + _uiState.update { + it.copy( + words = newWords, + invalidWordIndices = invalidIndices, + is24Words = pastedWords.size == 24, + shouldDismissKeyboard = invalidIndices.isEmpty(), + focusedIndex = null, + suggestions = emptyList() + ) + } + } + } + + private fun updateWordValidity(index: Int, value: String) { + val newWords = _uiState.value.words.toMutableList().apply { + this[index] = value + } + + val newInvalidIndices = _uiState.value.invalidWordIndices.toMutableSet() + if (!value.isBip39() && value.isNotEmpty()) { + newInvalidIndices.add(index) + } else { + newInvalidIndices.remove(index) + } + + _uiState.update { + it.copy( + words = newWords, + invalidWordIndices = newInvalidIndices + ) + } + } + + private fun updateSuggestions(input: String, index: Int?) { + if (index == null || input.length < 2) { + _uiState.update { it.copy(suggestions = emptyList()) } + return + } + + val filtered = bip39Words.filter { it.startsWith(input.lowercase()) }.take(3) + val suggestions = if (filtered.size == 1 && filtered.firstOrNull() == input) { + emptyList() + } else { + filtered + } + + _uiState.update { it.copy(suggestions = suggestions) } + } +} + +data class RestoreWalletUiState( + val words: List = List(24) { "" }, + val invalidWordIndices: Set = emptySet(), + val suggestions: List = emptyList(), + val focusedIndex: Int? = null, + val bip39Passphrase: String = "", + val showingPassphrase: Boolean = false, + val is24Words: Boolean = false, + val shouldDismissKeyboard: Boolean = false, + val scrollToFieldIndex: Int? = null, +) { + val wordCount: Int get() = if (is24Words) 24 else 12 + val wordsPerColumn: Int get() = if (is24Words) 12 else 6 + + val checksumErrorVisible: Boolean + get() { + val activeWords = words.subList(0, wordCount) + return activeWords.none { it.isBlank() } && + invalidWordIndices.isEmpty() && + !activeWords.validBip39Checksum() + } + + val bip39Mnemonic: String + get() = words.subList(0, wordCount).joinToString(" ").trim() + + val areButtonsEnabled: Boolean + get() { + val activeWords = words.subList(0, wordCount) + return activeWords.none { it.isBlank() } && + invalidWordIndices.isEmpty() && + !checksumErrorVisible + } +} From 970a6b373b93aea49dc16b140657338048489c83 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 8 Nov 2025 02:06:47 +0100 Subject: [PATCH 11/43] feat: nav to previous input on backspace if empty --- .../java/to/bitkit/services/CoreService.kt | 1 - .../ui/onboarding/RestoreWalletScreen.kt | 139 ++++++++++++------ .../viewmodels/RestoreWalletViewModel.kt | 45 +++--- 3 files changed, 123 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 1161184af..0c1b4a45b 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -3,7 +3,6 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.ActivityTags -import com.synonym.bitkitcore.ActivityTagsMetadata import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.ClosedChannelDetails diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 2896201a9..a3689272b 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -27,11 +28,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalFocusManager @@ -41,9 +47,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.TextRange import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS @@ -51,6 +60,7 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface @@ -68,6 +78,7 @@ fun RestoreWalletView( val scrollState = rememberScrollState() val inputFieldPositions = remember { mutableMapOf() } + val focusRequesters = remember { List(24) { FocusRequester() } } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -88,6 +99,12 @@ fun RestoreWalletView( } } + LaunchedEffect(uiState.focusedIndex) { + uiState.focusedIndex?.let { index -> + focusRequesters[index].requestFocus() + } + } + Scaffold( topBar = { AppTopBar( @@ -109,14 +126,15 @@ fun RestoreWalletView( .verticalScroll(scrollState) ) { Display(stringResource(R.string.onboarding__restore_header).withAccent(accentColor = Colors.Blue)) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) BodyM( text = stringResource(R.string.onboarding__restore_phrase), color = Colors.White80, ) - Spacer(modifier = Modifier.height(32.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + VerticalSpacer(32.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { // First column (1-6 or 1-12) Column( verticalArrangement = Arrangement.spacedBy(4.dp), @@ -134,6 +152,10 @@ fun RestoreWalletView( onPositionChanged = { position -> inputFieldPositions[index] = position }, + onBackspaceInEmpty = { + viewModel.onBackspaceInEmpty(index) + }, + focusRequester = focusRequesters[index], index = index, ) } @@ -155,11 +177,16 @@ fun RestoreWalletView( onPositionChanged = { position -> inputFieldPositions[index] = position }, + onBackspaceInEmpty = { + viewModel.onBackspaceInEmpty(index) + }, + focusRequester = focusRequesters[index], index = index, ) } } } + // Passphrase if (uiState.showingPassphrase) { OutlinedTextField( @@ -183,7 +210,7 @@ fun RestoreWalletView( .padding(top = 4.dp) .testTag("PassphraseInput") ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyS( text = stringResource(R.string.onboarding__restore_passphrase_meaning), color = Colors.White64, @@ -244,41 +271,51 @@ fun RestoreWalletView( } } - // Suggestions row - AnimatedVisibility( - visible = uiState.suggestions.isNotEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), + SuggestionsRow( + suggestions = uiState.suggestions, + onSelect = { viewModel.onSuggestionSelected(it) } + ) + } + } +} + +@Composable +private fun BoxScope.SuggestionsRow( + suggestions: List, + onSelect: (String) -> Unit, +) { + AnimatedVisibility( + visible = suggestions.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Colors.Black) + .padding(horizontal = 32.dp, vertical = 8.dp) + ) { + BodyS( + text = stringResource(R.string.onboarding__restore_suggestions), + color = Colors.White64, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .align(Alignment.BottomCenter) - .imePadding() + .fillMaxWidth() + .padding(top = 12.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(Colors.Black) - .padding(horizontal = 32.dp, vertical = 8.dp) - ) { - BodyS( - text = stringResource(R.string.onboarding__restore_suggestions), - color = Colors.White64, + suggestions.forEach { suggestion -> + PrimaryButton( + text = suggestion, + onClick = { onSelect(suggestion) }, + size = ButtonSize.Small, + fullWidth = false ) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - ) { - uiState.suggestions.forEach { suggestion -> - PrimaryButton( - text = suggestion, - onClick = { viewModel.onSuggestionSelected(suggestion) }, - size = ButtonSize.Small, - fullWidth = false - ) - } - } } } } @@ -293,11 +330,15 @@ fun MnemonicInputField( onValueChanged: (String) -> Unit, onFocusChanged: (Boolean) -> Unit, onPositionChanged: (Int) -> Unit, + onBackspaceInEmpty: () -> Unit, + focusRequester: FocusRequester, index: Int, ) { + val textFieldValue = TextFieldValue(text = value, selection = TextRange(value.length)) + OutlinedTextField( - value = value, - onValueChange = onValueChanged, + value = textFieldValue, + onValueChange = { onValueChanged(it.text) }, prefix = { Text( text = label, @@ -316,6 +357,18 @@ fun MnemonicInputField( capitalization = KeyboardCapitalization.None, ), modifier = Modifier + .focusRequester(focusRequester) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.key == Key.Backspace && + keyEvent.type == KeyEventType.KeyDown && + value.isEmpty() + ) { + onBackspaceInEmpty() + true + } else { + false + } + } .testTag("Word-$index") .onFocusChanged { onFocusChanged(it.isFocused) } .onGloballyPositioned { coordinates -> @@ -327,7 +380,7 @@ fun MnemonicInputField( @Preview(showSystemUi = true) @Composable -fun RestoreWalletViewPreview() { +private fun Preview() { AppThemeSurface { RestoreWalletView( onBackClick = {}, diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index ebe3f9c89..81cc7aeab 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -11,11 +11,18 @@ import to.bitkit.utils.isBip39 import to.bitkit.utils.validBip39Checksum import javax.inject.Inject +private const val WORDS_MIN = 12 +private const val WORS_MAX = 24 + @HiltViewModel class RestoreWalletViewModel @Inject constructor() : ViewModel() { private val _uiState = MutableStateFlow(RestoreWalletUiState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + _uiState.update { it.copy(focusedIndex = 0) } + } + fun onWordChanged(index: Int, value: String) { if (value.contains(" ")) { handlePastedWords(value) @@ -61,22 +68,24 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } } - fun onPassphraseChanged(passphrase: String) { - _uiState.update { it.copy(bip39Passphrase = passphrase) } - } + fun onPassphraseChanged(passphrase: String) = _uiState.update { it.copy(bip39Passphrase = passphrase) } - fun onKeyboardDismissed() { - _uiState.update { it.copy(shouldDismissKeyboard = false) } + fun onBackspaceInEmpty(index: Int) { + if (index > 0) { + _uiState.update { it.copy(focusedIndex = index - 1) } + } } - fun onScrollCompleted() { - _uiState.update { it.copy(scrollToFieldIndex = null) } - } + fun onKeyboardDismissed() = _uiState.update { it.copy(shouldDismissKeyboard = false) } + + fun onScrollCompleted() = _uiState.update { it.copy(scrollToFieldIndex = null) } private fun handlePastedWords(pastedText: String) { - // Splits on one or more whitespace characters (spaces, tabs, newlines) in case user pastes from pass managers - val pastedWords = pastedText.split(Regex("\\s+")).filter(String::isNotBlank) - if (pastedWords.size == 12 || pastedWords.size == 24) { + val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers + val pastedWords = pastedText + .split(separators) + .filter { it.isNotBlank() } + if (pastedWords.size == WORDS_MIN || pastedWords.size == WORS_MAX) { val invalidIndices = pastedWords.withIndex() .filter { !it.value.isBip39() } .map { it.index } @@ -84,7 +93,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { val newWords = _uiState.value.words.toMutableList().apply { pastedWords.forEachIndexed { index, word -> this[index] = word } - for (index in pastedWords.size until 24) { + for (index in pastedWords.size until WORS_MAX) { this[index] = "" } } @@ -93,10 +102,10 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { it.copy( words = newWords, invalidWordIndices = invalidIndices, - is24Words = pastedWords.size == 24, + is24Words = pastedWords.size == WORS_MAX, shouldDismissKeyboard = invalidIndices.isEmpty(), focusedIndex = null, - suggestions = emptyList() + suggestions = emptyList(), ) } } @@ -117,7 +126,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { _uiState.update { it.copy( words = newWords, - invalidWordIndices = newInvalidIndices + invalidWordIndices = newInvalidIndices, ) } } @@ -140,7 +149,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } data class RestoreWalletUiState( - val words: List = List(24) { "" }, + val words: List = List(WORS_MAX) { "" }, val invalidWordIndices: Set = emptySet(), val suggestions: List = emptyList(), val focusedIndex: Int? = null, @@ -150,8 +159,8 @@ data class RestoreWalletUiState( val shouldDismissKeyboard: Boolean = false, val scrollToFieldIndex: Int? = null, ) { - val wordCount: Int get() = if (is24Words) 24 else 12 - val wordsPerColumn: Int get() = if (is24Words) 12 else 6 + val wordCount: Int get() = if (is24Words) WORS_MAX else WORDS_MIN + val wordsPerColumn: Int get() = if (is24Words) WORDS_MIN else 6 val checksumErrorVisible: Boolean get() { From ce101880b913c653f3cab5654efb294843b04840 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 8 Nov 2025 02:56:38 +0100 Subject: [PATCH 12/43] feat: bold text in focused input --- .../to/bitkit/ui/onboarding/RestoreWalletScreen.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index a3689272b..ddfcaeb20 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -27,7 +27,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -63,6 +65,7 @@ import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.theme.AppTextFieldDefaults +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @@ -334,11 +337,13 @@ fun MnemonicInputField( focusRequester: FocusRequester, index: Int, ) { + var isFocused by remember { mutableStateOf(false) } val textFieldValue = TextFieldValue(text = value, selection = TextRange(value.length)) OutlinedTextField( value = textFieldValue, onValueChange = { onValueChanged(it.text) }, + textStyle = if (isFocused) AppTextStyles.BodySSB else AppTextStyles.BodyS, prefix = { Text( text = label, @@ -370,7 +375,10 @@ fun MnemonicInputField( } } .testTag("Word-$index") - .onFocusChanged { onFocusChanged(it.isFocused) } + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + onFocusChanged(focusState.isFocused) + } .onGloballyPositioned { coordinates -> val position = coordinates.positionInParent().y.toInt() * 2 // double the scroll to ensure enough space onPositionChanged(position) From 7e22f027faa5bd7d60f15bdc0520068cee54d19e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 8 Nov 2025 03:21:51 +0100 Subject: [PATCH 13/43] fix: avoid validation errors on focused input --- .../java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index ddfcaeb20..5bd9ab3fc 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -147,7 +147,7 @@ fun RestoreWalletView( MnemonicInputField( label = "${index + 1}.", value = uiState.words[index], - isError = index in uiState.invalidWordIndices, + isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, onValueChanged = { viewModel.onWordChanged(index, it) }, onFocusChanged = { focused -> viewModel.onWordFocusChanged(index, focused) @@ -172,7 +172,7 @@ fun RestoreWalletView( MnemonicInputField( label = "${index + 1}.", value = uiState.words[index], - isError = index in uiState.invalidWordIndices, + isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, onValueChanged = { viewModel.onWordChanged(index, it) }, onFocusChanged = { focused -> viewModel.onWordFocusChanged(index, focused) @@ -227,7 +227,7 @@ fun RestoreWalletView( .weight(1f) ) - AnimatedVisibility(visible = uiState.invalidWordIndices.isNotEmpty()) { + AnimatedVisibility(visible = uiState.invalidWordIndices.any { it != uiState.focusedIndex }) { BodyS( text = stringResource( R.string.onboarding__restore_red_explain From ac21d53135c3f46a770f37b4fc5ad95fb5d7f8f9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 8 Nov 2025 04:22:46 +0100 Subject: [PATCH 14/43] feat: use bitkit-core for bip39 & checksum --- .../bitkit/ui/components/MnemonicWordsGrid.kt | 7 +- .../settings/backups/ConfirmMnemonicScreen.kt | 9 +- .../ui/settings/backups/ShowMnemonicScreen.kt | 7 +- .../main/java/to/bitkit/utils/Bip39Utils.kt | 56 ------- .../viewmodels/RestoreWalletViewModel.kt | 25 ++- .../test/java/to/bitkit/utils/Bip39Test.kt | 154 ------------------ 6 files changed, 27 insertions(+), 231 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/utils/Bip39Utils.kt delete mode 100644 app/src/test/java/to/bitkit/utils/Bip39Test.kt diff --git a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt index 47d5d98bb..8ac9fd1b5 100644 --- a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt +++ b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.utils.bip39Words @Composable fun MnemonicWordsGrid( @@ -98,12 +97,14 @@ private fun WordItem( } } +private val previewWords = List(8) { "word${it + 1}" } + @Preview @Composable private fun Preview() { AppThemeSurface { MnemonicWordsGrid( - actualWords = bip39Words.take(n = 12), + actualWords = previewWords, showMnemonic = true, ) } @@ -114,7 +115,7 @@ private fun Preview() { private fun PreviewHidden() { AppThemeSurface { MnemonicWordsGrid( - actualWords = bip39Words.take(n = 12), + actualWords = previewWords, showMnemonic = false, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt index 1abf43da4..8b7166a0c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt @@ -37,7 +37,6 @@ import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.utils.bip39Words @Composable fun ConfirmMnemonicScreen( @@ -226,7 +225,7 @@ private fun SelectedWordItem( BodyMSB(text = "$number.", color = Colors.White64) Spacer(modifier = Modifier.width(4.dp)) BodyMSB( - text = if (word.isEmpty()) "" else word, + text = word.ifEmpty { "" }, color = if (word.isEmpty()) Colors.White64 else if (isCorrect) Colors.Green else Colors.Red ) } @@ -235,7 +234,7 @@ private fun SelectedWordItem( @Preview(showSystemUi = true) @Composable private fun Preview() { - val testWords = bip39Words.take(12) + val testWords = List(12) { "word${it + 1}" } AppThemeSurface { ConfirmMnemonicContent( originalSeed = testWords, @@ -253,7 +252,7 @@ private fun Preview() { @Preview(showSystemUi = true) @Composable private fun Preview2() { - val testWords = bip39Words.take(12) + val testWords = List(12) { "word${it + 1}" } val half = testWords.size / 2 AppThemeSurface { ConfirmMnemonicContent( @@ -272,7 +271,7 @@ private fun Preview2() { @Preview(showSystemUi = true) @Composable private fun Preview24Words() { - val testWords = bip39Words.take(24) + val testWords = List(24) { "word${it + 1}" } val half = testWords.size / 2 AppThemeSurface { ConfirmMnemonicContent( diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt index a6259df85..4da79b388 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt @@ -54,7 +54,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.withAccent -import to.bitkit.utils.bip39Words @Composable fun ShowMnemonicScreen( @@ -207,7 +206,7 @@ private fun Preview() { BottomSheetPreview { var showMnemonic by remember { mutableStateOf(false) } ShowMnemonicContent( - mnemonic = bip39Words.take(12).joinToString(" "), + mnemonic = List(12) { "word${it + 1}" }.joinToString(" "), showMnemonic = showMnemonic, onRevealClick = { showMnemonic = !showMnemonic }, onCopyClick = {}, @@ -224,7 +223,7 @@ private fun PreviewShown() { AppThemeSurface { BottomSheetPreview { ShowMnemonicContent( - mnemonic = bip39Words.take(12).joinToString(" "), + mnemonic = List(12) { "word${it + 1}" }.joinToString(" "), showMnemonic = true, onRevealClick = {}, onCopyClick = {}, @@ -241,7 +240,7 @@ private fun Preview24Words() { AppThemeSurface { BottomSheetPreview { ShowMnemonicContent( - mnemonic = bip39Words.take(24).joinToString(" "), + mnemonic = List(24) { "word${it + 1}" }.joinToString(" "), showMnemonic = true, onRevealClick = {}, onCopyClick = {}, diff --git a/app/src/main/java/to/bitkit/utils/Bip39Utils.kt b/app/src/main/java/to/bitkit/utils/Bip39Utils.kt deleted file mode 100644 index c00b45a61..000000000 --- a/app/src/main/java/to/bitkit/utils/Bip39Utils.kt +++ /dev/null @@ -1,56 +0,0 @@ -package to.bitkit.utils - -import java.security.MessageDigest - -val bip39Words = - setOf("abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance", "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become", "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog", "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk", "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify", "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", "coil", "coin", "collect", "color", "column", "combine", "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper", "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay", "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk", "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide", "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft", "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil", "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit", "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint", "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault", "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field", "figure", "file", "film", "filter", "final", "find", "fine", "finger", "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly", "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil", "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel", "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment", "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle", "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue", "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass", "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun", "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital", "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose", "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial", "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest", "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel", "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup", "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law", "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend", "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", "life", "lift", "light", "like", "limb", "limit", "link", "lion", "liquid", "list", "little", "live", "lizard", "load", "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop", "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet", "maid", "mail", "main", "major", "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin", "marine", "market", "marriage", "mask", "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure", "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message", "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake", "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning", "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music", "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative", "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", "noble", "noise", "nominee", "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey", "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", "often", "oil", "okay", "old", "olive", "olympic", "omit", "once", "one", "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit", "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over", "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper", "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut", "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical", "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet", "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare", "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", "prison", "private", "prize", "problem", "process", "produce", "profit", "program", "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil", "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz", "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid", "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle", "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove", "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire", "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid", "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room", "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness", "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say", "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea", "search", "season", "seat", "second", "secret", "section", "security", "seed", "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence", "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine", "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side", "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", "sister", "situate", "six", "size", "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan", "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social", "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject", "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim", "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", "tennis", "tent", "term", "test", "text", "thank", "that", "theme", "then", "theory", "there", "they", "thing", "this", "thought", "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger", "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token", "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist", "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree", "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try", "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical", "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown", "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", "usage", "use", "used", "useful", "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle", "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view", "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote", "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave", "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat", "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", "winner", "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo") - -fun String.isBip39() = bip39Words.contains(this.lowercase()) - -/** - * Validates a BIP39 mnemonic phrase by checking its checksum. This method only tests 12 or 24 words phrases - * - * @return True if the mnemonic is valid, false otherwise - */ -fun List.validBip39Checksum(): Boolean { - if (this.size != 12 && this.size != 24) { - return false - } - - if (this.any { !it.isBip39() }) return false - - val indices = this.map { word -> - val index = bip39Words.indexOf(word) - if (index == -1) { - return false - } - index - } - - // 12 words = 128 bits of entropy + 4 bits of checksum - // 24 words = 256 bits of entropy + 8 bits of checksum - val entropyBits = if (this.size == 12) 128 else 256 - val checksumBits = entropyBits / 32 - - val bits = indices.joinToString(separator = "") { index -> - index.toString(2).padStart(length = 11, padChar = '0') - } - - val entropyBitsString = bits.substring(0, entropyBits) - val checksumBitsString = bits.substring(startIndex = entropyBits, endIndex = entropyBits + checksumBits) - - val entropyBytes = ByteArray(entropyBits / 8) - for (i in 0 until entropyBits / 8) { - val byte = entropyBitsString.substring(startIndex = i * 8, endIndex = (i + 1) * 8).toInt(2) - entropyBytes[i] = byte.toByte() - } - - val sha256 = MessageDigest.getInstance("SHA-256") - val hash = sha256.digest(entropyBytes) - - val derivedChecksumBits = hash[0].toInt().and(0xFF).toString(2) - .padStart(8, '0') - .substring(0, checksumBits) - - return checksumBitsString == derivedChecksumBits -} diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index 81cc7aeab..2fa52bb47 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -1,14 +1,14 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel +import com.synonym.bitkitcore.getBip39Suggestions +import com.synonym.bitkitcore.isValidBip39Word +import com.synonym.bitkitcore.validateMnemonic import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import to.bitkit.utils.bip39Words -import to.bitkit.utils.isBip39 -import to.bitkit.utils.validBip39Checksum import javax.inject.Inject private const val WORDS_MIN = 12 @@ -87,7 +87,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { .filter { it.isNotBlank() } if (pastedWords.size == WORDS_MIN || pastedWords.size == WORS_MAX) { val invalidIndices = pastedWords.withIndex() - .filter { !it.value.isBip39() } + .filter { !isValidBip39Word(it.value) } .map { it.index } .toSet() @@ -117,7 +117,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } val newInvalidIndices = _uiState.value.invalidWordIndices.toMutableSet() - if (!value.isBip39() && value.isNotEmpty()) { + if (!isValidBip39Word(value) && value.isNotEmpty()) { newInvalidIndices.add(index) } else { newInvalidIndices.remove(index) @@ -137,14 +137,14 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { return } - val filtered = bip39Words.filter { it.startsWith(input.lowercase()) }.take(3) - val suggestions = if (filtered.size == 1 && filtered.firstOrNull() == input) { + val suggestions = getBip39Suggestions(input.lowercase(), 3u) + val filtered = if (suggestions.size == 1 && suggestions.firstOrNull() == input) { emptyList() } else { - filtered + suggestions } - _uiState.update { it.copy(suggestions = suggestions) } + _uiState.update { it.copy(suggestions = filtered) } } } @@ -181,3 +181,10 @@ data class RestoreWalletUiState( !checksumErrorVisible } } + +private fun List.validBip39Checksum(): Boolean { + if (this.size != 12 && this.size != 24) return false + if (this.any { !isValidBip39Word(it) }) return false + + return runCatching { validateMnemonic(this.joinToString(" ")) }.isSuccess +} diff --git a/app/src/test/java/to/bitkit/utils/Bip39Test.kt b/app/src/test/java/to/bitkit/utils/Bip39Test.kt deleted file mode 100644 index 44ece6274..000000000 --- a/app/src/test/java/to/bitkit/utils/Bip39Test.kt +++ /dev/null @@ -1,154 +0,0 @@ -package to.bitkit.utils - -import junit.framework.TestCase.assertFalse -import org.junit.Assert -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class Bip39Test { - private fun String.toWordList(): List = this.trim().lowercase().split(" ") - - @Test - fun `test valid mnemonic phrases`() { - // Test vectors based on Trezor's reference implementation - // From https://github.com/trezor/python-mnemonic/blob/master/vectors.json - val testVectors = listOf( - // 12 words (128 bits entropy) - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" to true, - "legal winner thank year wave sausage worth useful legal winner thank yellow" to true, - "letter advice cage absurd amount doctor acoustic avoid letter advice cage above" to true, - "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" to true, - - // 24 words (256 bits entropy) - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" to true, - "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title" to true, - "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless" to true, - "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote" to true, - "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge" to true, - "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic" to true, - - // Edge cases - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false, // Invalid checksum - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false, // Too few words - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false // Invalid length - ) - - for ((mnemonic, expectedResult) in testVectors) { - assertEquals(expectedResult, mnemonic.toWordList().validBip39Checksum(), "Failed for mnemonic: $mnemonic") - } - } - - @Test - fun `test invalid word count`() { - // 11 words (too few) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - - // 13 words (invalid length) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - - // 23 words (invalid length) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - } - - @Test - fun `test invalid words`() { - // Contains a word not in the wordlist - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon invalidword" - ).validBip39Checksum() - ) - } - - @Test - fun `test invalid checksum`() { - // Valid words but invalid checksum - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - } - - @Test - fun `test case sensitivity`() { - // Original valid mnemonic - val validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - - // Test with uppercase - assertTrue(validMnemonic.uppercase().toWordList().validBip39Checksum()) - - // Test with mixed case - assertTrue( - "AbAnDoN abandon ABANDON abandon abandon abandon abandon abandon abandon abandon abandon about".toWordList().validBip39Checksum() - ) - } - - @Test - fun `test invalid examples with correct word count`() { - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon zoo" - ).validBip39Checksum() - ) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon actor" - ).validBip39Checksum() - ) - } - - @Test - fun `isBip39 should return true for valid BIP39 word`() { - Assert.assertTrue("abandon".isBip39()) - Assert.assertTrue("zoo".isBip39()) - Assert.assertTrue("abandon".uppercase().isBip39()) - Assert.assertTrue("abandon".lowercase().isBip39()) - Assert.assertTrue("abandon".capitalize().isBip39()) - } - - @Test - fun `isBip39 should return false for invalid BIP39 word`() { - Assert.assertFalse("invalidword".isBip39()) - Assert.assertFalse("".isBip39()) - Assert.assertFalse("123".isBip39()) - Assert.assertFalse("abandon ".isBip39()) - Assert.assertFalse(" abandon".isBip39()) - Assert.assertFalse(" abandon ".isBip39()) - Assert.assertFalse("abandon1".isBip39()) - Assert.assertFalse("abandon-".isBip39()) - Assert.assertFalse("abandon_".isBip39()) - } - - @Test - fun `isBip39 should handle empty string`() { - Assert.assertFalse("".isBip39()) - } - - @Test - fun `isBip39 should handle non-alphabetic characters`() { - Assert.assertFalse("123".isBip39()) - Assert.assertFalse("!@#".isBip39()) - Assert.assertFalse("abandon1".isBip39()) - Assert.assertFalse("abandon-".isBip39()) - Assert.assertFalse("abandon_".isBip39()) - } -} From 48534efded190c6bd7ed7d71b2a08496796a2fb1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 8 Nov 2025 06:12:31 +0100 Subject: [PATCH 15/43] feat: wipe core db on wipe wallet --- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 4 +--- app/src/main/java/to/bitkit/services/CoreService.kt | 10 ++++++++++ .../test/java/to/bitkit/repositories/WalletRepoTest.kt | 3 --- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b8fb5db3b..fc8479754 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -18,7 +18,6 @@ import org.lightningdevkit.ldknode.Event import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore -import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher @@ -51,7 +50,6 @@ class WalletRepo @Inject constructor( private val lightningRepo: LightningRepo, private val cacheStore: CacheStore, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, - private val vssStoreIdProvider: VssStoreIdProvider, private val backupRepo: BackupRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -249,7 +247,7 @@ class WalletRepo @Inject constructor( settingsStore.reset() cacheStore.reset() // TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities - coreService.activity.removeAll() + coreService.wipeData() setWalletExistsState() return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 0c1b4a45b..3f84f99e0 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -49,6 +49,7 @@ import com.synonym.bitkitcore.upsertCjitEntries import com.synonym.bitkitcore.upsertClosedChannels import com.synonym.bitkitcore.upsertInfo import com.synonym.bitkitcore.upsertOrders +import com.synonym.bitkitcore.wipeAllDatabases import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.http.HttpStatusCode @@ -168,6 +169,15 @@ class CoreService @Inject constructor( return Pair(geoBlocked, shouldBlockLightningReceive) } + + suspend fun wipeData(): Result = ServiceQueue.CORE.background { + runCatching { + wipeAllDatabases() + Logger.info("Wiped bitkit-core databases", context = "CoreService") + }.onFailure { e -> + Logger.error("Error wiping bitkit-core databases", e, context = "CoreService") + } + } } // endregion diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index cf4375ddf..b1a8151e9 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -20,7 +20,6 @@ import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.models.BalanceState import to.bitkit.services.CoreService @@ -47,7 +46,6 @@ class WalletRepoTest : BaseUnitTest() { private val lightningRepo: LightningRepo = mock() private val cacheStore: CacheStore = mock() private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase = mock() - private val vssStoreIdProvider = mock() private val backupRepo = mock() @Before @@ -78,7 +76,6 @@ class WalletRepoTest : BaseUnitTest() { lightningRepo = lightningRepo, cacheStore = cacheStore, deriveBalanceStateUseCase = deriveBalanceStateUseCase, - vssStoreIdProvider = vssStoreIdProvider, backupRepo = backupRepo, ) From 6a40ee10bdbcadaf4e802a2c0ea9a258f82028da Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 11 Nov 2025 21:00:12 +0100 Subject: [PATCH 16/43] feat: reset logs on wipe wallet --- app/src/main/java/to/bitkit/env/Env.kt | 4 +- .../java/to/bitkit/repositories/LogsRepo.kt | 3 +- .../java/to/bitkit/repositories/WalletRepo.kt | 1 + .../java/to/bitkit/services/CoreService.kt | 10 ++-- app/src/main/java/to/bitkit/utils/Logger.kt | 50 ++++++++++++++----- .../to/bitkit/viewmodels/LogsViewModel.kt | 23 ++------- 6 files changed, 53 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 342e4eeba..6a61de412 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -116,10 +116,10 @@ internal object Env { Logger.info("App storage path: $path") } - val logDir: String + val logDir: File get() { require(::appStoragePath.isInitialized) - return File(appStoragePath).resolve("logs").ensureDir().path + return File(appStoragePath).resolve("logs").ensureDir() } fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") diff --git a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt index a2f735471..5ac0a51da 100644 --- a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt @@ -58,8 +58,7 @@ class LogsRepo @Inject constructor( /** Lists log files sorted by newest first */ suspend fun getLogs(): Result> = withContext(bgDispatcher) { try { - val logDir = runCatching { File(Env.logDir) }.getOrElse { return@withContext Result.failure(it) } - if (!logDir.exists()) return@withContext Result.failure(Exception("Logs dir not found")) + val logDir = Env.logDir val logFiles = logDir .listFiles { file -> file.extension == "log" } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index fc8479754..5ead4c285 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -248,6 +248,7 @@ class WalletRepo @Inject constructor( cacheStore.reset() // TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities coreService.wipeData() + Logger.resetSession() setWalletExistsState() return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 3f84f99e0..2e49cc462 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -172,12 +172,16 @@ class CoreService @Inject constructor( suspend fun wipeData(): Result = ServiceQueue.CORE.background { runCatching { - wipeAllDatabases() - Logger.info("Wiped bitkit-core databases", context = "CoreService") + val result = wipeAllDatabases() + Logger.info("Core DB wipe: $result", context = TAG) }.onFailure { e -> - Logger.error("Error wiping bitkit-core databases", e, context = "CoreService") + Logger.error("Core DB wipe error", e, context = TAG) } } + + companion object { + private const val TAG = "CoreService" + } } // endregion diff --git a/app/src/main/java/to/bitkit/utils/Logger.kt b/app/src/main/java/to/bitkit/utils/Logger.kt index 2fe2f143f..dfd3f59a2 100644 --- a/app/src/main/java/to/bitkit/utils/Logger.kt +++ b/app/src/main/java/to/bitkit/utils/Logger.kt @@ -28,8 +28,20 @@ private const val COMPACT = false enum class LogSource { Ldk, Bitkit, Unknown } enum class LogLevel { PERF, VERBOSE, GOSSIP, TRACE, DEBUG, INFO, WARN, ERROR; } -object Logger { - private val delegate by lazy { LoggerImpl(APP, saver = LogSaverImpl(buildSessionLogFilePath(LogSource.Bitkit))) } +val Logger = AppLogger() + +class AppLogger( + private val source: LogSource = LogSource.Bitkit, +) { + private var delegate: LoggerImpl = createDelegate() + + private fun createDelegate() = LoggerImpl(APP, LogSaverImpl(source, buildSessionLogFilePath(source))) + + fun resetSession() { + warn("Wiping entire logs directory...") + Env.logDir.deleteRecursively() + delegate = createDelegate() + } fun info( msg: String?, @@ -160,12 +172,17 @@ interface LogSaver { fun save(message: String) } -class LogSaverImpl(private val sessionFilePath: String) : LogSaver { +class LogSaverImpl( + source: LogSource, + private val sessionFilePath: String, +) : LogSaver { private val queue: CoroutineScope by lazy { CoroutineScope(newSingleThreadDispatcher(ServiceQueue.LOG.name) + SupervisorJob()) } init { + log("Log session for '${source.name}' initialized with file path: '$sessionFilePath'") + // Clean all old log files in background CoroutineScope(Dispatchers.IO).launch { cleanupOldLogFiles() @@ -186,10 +203,15 @@ class LogSaverImpl(private val sessionFilePath: String) : LogSaver { } } + private fun log(message: String, level: LogLevel = LogLevel.INFO) { + val formatted = formatLog(level, message, TAG, getCallerPath(), getCallerLine()) + Log.i(APP, formatted) + save(formatted) + } + private fun cleanupOldLogFiles(maxTotalSizeMB: Int = 20) { - Log.v(APP, "Deleting old log files…") - val logDir = runCatching { File(Env.logDir) }.getOrElse { return } - if (!logDir.exists()) return + log("Deleting old log files…", LogLevel.VERBOSE) + val logDir = Env.logDir val logFiles = logDir .listFiles { file -> file.extension == "log" } @@ -216,11 +238,16 @@ class LogSaverImpl(private val sessionFilePath: String) : LogSaver { } Log.v(APP, "Deleted all old log files.") } + + companion object { + private const val TAG = "LogSaver" + } } class LdkLogWriter( private val maxLogLevel: LdkLogLevel = Env.ldkLogLevel, - saver: LogSaver = LogSaverImpl(buildSessionLogFilePath(LogSource.Ldk)), + private val source: LogSource = LogSource.Ldk, + saver: LogSaver = LogSaverImpl(source, buildSessionLogFilePath(source)), ) : LogWriter { private val delegate: LoggerImpl = LoggerImpl(LDK, saver) @@ -243,14 +270,11 @@ class LdkLogWriter( } private fun buildSessionLogFilePath(source: LogSource): String { - val logDir = runCatching { File(Env.logDir) }.getOrElse { return "" } - if (!logDir.exists()) return "" - + val logDir = Env.logDir val sourceName = source.name.lowercase() val timestamp = utcDateFormatterOf(DatePattern.LOG_FILE).format(Date()) - val sessionLogFilePath = logDir.resolve("${sourceName}_$timestamp.log").path - Log.i(APP, "Log session for '$sourceName' initialized with file path: '$sessionLogFilePath'") - return sessionLogFilePath + val path = logDir.resolve("${sourceName}_$timestamp.log").path + return path } private fun formatLog(level: LogLevel, msg: String?, context: String, path: String, line: Int): String { diff --git a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt index bad26b050..3603af0d3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt @@ -23,7 +23,7 @@ import javax.inject.Inject @HiltViewModel class LogsViewModel @Inject constructor( private val application: Application, - private val logsRepo: LogsRepo + private val logsRepo: LogsRepo, ) : AndroidViewModel(application) { private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs.asStateFlow() @@ -33,12 +33,8 @@ class LogsViewModel @Inject constructor( fun loadLogs() { viewModelScope.launch { - logsRepo.getLogs() - .onSuccess { logList -> - _logs.update { logList } - }.onFailure { e -> - _logs.update { emptyList() } - } + val logFiles = logsRepo.getLogs().getOrDefault(emptyList()) + _logs.update { logFiles } } } @@ -83,17 +79,8 @@ class LogsViewModel @Inject constructor( fun deleteAllLogs() { viewModelScope.launch { - try { - val logDir = runCatching { File(Env.logDir) }.getOrElse { return@launch } - logDir.listFiles { file -> - file.extension == "log" - }?.forEach { file -> - file.delete() - } - loadLogs() - } catch (e: Exception) { - Logger.error("Failed to delete logs", e) - } + Logger.resetSession() + loadLogs() } } } From 1ac1eb9b0096abb8f38305e3da5433db55f98753 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 12 Nov 2025 00:31:38 +0100 Subject: [PATCH 17/43] feat: reset blocktank repo data on wipe --- app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt | 1 + app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 2 ++ app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt | 2 ++ 3 files changed, 5 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 2a1388aae..811ffdca3 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -373,6 +373,7 @@ class BlocktankRepo @Inject constructor( suspend fun resetState() = withContext(bgDispatcher) { _blocktankState.update { BlocktankState() } + Logger.debug("Blocktank state reset", context = TAG) } suspend fun restoreFromBackup(backup: BlocktankBackupV1): Result = withContext(bgDispatcher) { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 5ead4c285..a6fd8432b 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -51,6 +51,7 @@ class WalletRepo @Inject constructor( private val cacheStore: CacheStore, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, private val backupRepo: BackupRepo, + private val blocktankRepo: BlocktankRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -238,6 +239,7 @@ class WalletRepo @Inject constructor( suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { try { backupRepo.reset() + blocktankRepo.resetState() _walletState.update { WalletState() } _balanceState.update { BalanceState() } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index b1a8151e9..a8aee822b 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -47,6 +47,7 @@ class WalletRepoTest : BaseUnitTest() { private val cacheStore: CacheStore = mock() private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase = mock() private val backupRepo = mock() + private val blocktankRepo = mock() @Before fun setUp() { @@ -77,6 +78,7 @@ class WalletRepoTest : BaseUnitTest() { cacheStore = cacheStore, deriveBalanceStateUseCase = deriveBalanceStateUseCase, backupRepo = backupRepo, + blocktankRepo = blocktankRepo, ) @Test From 4e48d9583fec1152cbad66bf17f2bc514d6bbf56 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 12 Nov 2025 01:57:32 +0100 Subject: [PATCH 18/43] fix: logger crash in unit tests --- .../java/to/bitkit/repositories/WalletRepo.kt | 2 +- app/src/main/java/to/bitkit/utils/Logger.kt | 53 +++++++++++++------ .../to/bitkit/viewmodels/LogsViewModel.kt | 2 +- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index a6fd8432b..b286b3d98 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -250,7 +250,7 @@ class WalletRepo @Inject constructor( cacheStore.reset() // TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities coreService.wipeData() - Logger.resetSession() + Logger.reset() setWalletExistsState() return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) diff --git a/app/src/main/java/to/bitkit/utils/Logger.kt b/app/src/main/java/to/bitkit/utils/Logger.kt index dfd3f59a2..ade0299aa 100644 --- a/app/src/main/java/to/bitkit/utils/Logger.kt +++ b/app/src/main/java/to/bitkit/utils/Logger.kt @@ -33,14 +33,21 @@ val Logger = AppLogger() class AppLogger( private val source: LogSource = LogSource.Bitkit, ) { - private var delegate: LoggerImpl = createDelegate() + private var delegate: LoggerImpl? = null - private fun createDelegate() = LoggerImpl(APP, LogSaverImpl(source, buildSessionLogFilePath(source))) + init { + delegate = runCatching { createDelegate() }.getOrNull() + } - fun resetSession() { + private fun createDelegate(): LoggerImpl { + val sessionPath = runCatching { buildSessionLogFilePath(source) }.getOrElse { "" } + return LoggerImpl(APP, LogSaverImpl(source, sessionPath)) + } + + fun reset() { warn("Wiping entire logs directory...") - Env.logDir.deleteRecursively() - delegate = createDelegate() + runCatching { Env.logDir.deleteRecursively() } + delegate = runCatching { createDelegate() }.getOrNull() } fun info( @@ -48,14 +55,18 @@ class AppLogger( context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.info(msg, context, file, line) + ) { + delegate?.info(msg, context, file, line) + } fun debug( msg: String?, context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.debug(msg, context, file, line) + ) { + delegate?.debug(msg, context, file, line) + } fun warn( msg: String?, @@ -63,7 +74,9 @@ class AppLogger( context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.warn(msg, e, context, file, line) + ) { + delegate?.warn(msg, e, context, file, line) + } fun error( msg: String?, @@ -71,7 +84,9 @@ class AppLogger( context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.error(msg, e, context, file, line) + ) { + delegate?.error(msg, e, context, file, line) + } fun verbose( msg: String?, @@ -79,14 +94,18 @@ class AppLogger( context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.verbose(msg, e, context, file, line) + ) { + delegate?.verbose(msg, e, context, file, line) + } fun performance( msg: String?, context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.performance(msg, context, file, line) + ) { + delegate?.performance(msg, context, file, line) + } } class LoggerImpl( @@ -181,11 +200,13 @@ class LogSaverImpl( } init { - log("Log session for '${source.name}' initialized with file path: '$sessionFilePath'") + if (sessionFilePath.isNotEmpty()) { + log("Log session for '${source.name}' initialized with file path: '$sessionFilePath'") - // Clean all old log files in background - CoroutineScope(Dispatchers.IO).launch { - cleanupOldLogFiles() + // Clean all old log files in background + CoroutineScope(Dispatchers.IO).launch { + cleanupOldLogFiles() + } } } @@ -211,7 +232,7 @@ class LogSaverImpl( private fun cleanupOldLogFiles(maxTotalSizeMB: Int = 20) { log("Deleting old log files…", LogLevel.VERBOSE) - val logDir = Env.logDir + val logDir = runCatching { Env.logDir }.getOrNull() ?: return val logFiles = logDir .listFiles { file -> file.extension == "log" } diff --git a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt index 3603af0d3..17f07bad6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt @@ -79,7 +79,7 @@ class LogsViewModel @Inject constructor( fun deleteAllLogs() { viewModelScope.launch { - Logger.resetSession() + Logger.reset() loadLogs() } } From bfc497c619405854de991d0410f33ff15e0fd9a3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Nov 2025 19:10:26 +0100 Subject: [PATCH 19/43] refactor: add activity.txType extension --- app/src/main/java/to/bitkit/ext/Activities.kt | 5 +++++ .../ui/screens/wallets/activity/components/ActivityIcon.kt | 6 ++---- .../ui/screens/wallets/activity/components/ActivityRow.kt | 6 ++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 15338da9c..ae0e9c22a 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -9,6 +9,11 @@ fun Activity.rawId(): String = when (this) { is Activity.Onchain -> v1.id } +fun Activity.txType(): PaymentType = when (this) { + is Activity.Lightning -> v1.txType + is Activity.Onchain -> v1.txType +} + /** * Calculates the total value of an activity based on its type. * diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 540c02c2e..c97ea3da0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -27,6 +27,7 @@ import to.bitkit.R import to.bitkit.ext.isBoosted import to.bitkit.ext.isFinished import to.bitkit.ext.isTransfer +import to.bitkit.ext.txType import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -41,10 +42,7 @@ fun ActivityIcon( is Activity.Lightning -> activity.v1.status is Activity.Onchain -> null } - val txType: PaymentType = when (activity) { - is Activity.Lightning -> activity.v1.txType - is Activity.Onchain -> activity.v1.txType - } + val txType: PaymentType = activity.txType() val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) when { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 84a331fb7..2d6912880 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -31,6 +31,7 @@ import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId import to.bitkit.ext.totalValue +import to.bitkit.ext.txType import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies @@ -64,10 +65,7 @@ fun ActivityRow( is Activity.Lightning -> item.v1.timestamp is Activity.Onchain -> item.v1.timestamp } - val txType: PaymentType = when (item) { - is Activity.Lightning -> item.v1.txType - is Activity.Onchain -> item.v1.txType - } + val txType: PaymentType = item.txType() val isSent = item.isSent() val amountPrefix = if (isSent) "-" else "+" val confirmed: Boolean? = when (item) { From ca35a69c6b9e791f6195e4b1b156a5c926e50671 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Nov 2025 10:54:19 +0100 Subject: [PATCH 20/43] feat: reset activity state and wipe fixes --- .../to/bitkit/repositories/ActivityRepo.kt | 90 ++++--- .../java/to/bitkit/repositories/BackupRepo.kt | 28 +- .../java/to/bitkit/repositories/WalletRepo.kt | 34 ++- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 3 +- .../wallets/activity/ActivityDetailScreen.kt | 2 +- .../viewmodels/ActivityListViewModel.kt | 255 +++++++----------- 7 files changed, 187 insertions(+), 227 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 19549d368..9ddc78d1f 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import androidx.annotation.VisibleForTesting import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.ActivityTags @@ -29,6 +30,7 @@ import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.di.BgDispatcher import to.bitkit.ext.amountOnClose import to.bitkit.ext.matchesPaymentId +import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.models.ActivityBackupV1 @@ -54,53 +56,55 @@ class ActivityRepo @Inject constructor( ) { val isSyncingLdkNodePayments = MutableStateFlow(false) + private val _state = MutableStateFlow(ActivityState()) + val state: StateFlow = _state + private val _activitiesChanged = MutableStateFlow(0L) val activitiesChanged: StateFlow = _activitiesChanged - private fun notifyActivitiesChanged() = _activitiesChanged.update { clock.now().toEpochMilliseconds() } + private fun notifyActivitiesChanged() = _activitiesChanged.update { nowMillis(clock) } + + suspend fun resetState() = withContext(bgDispatcher) { + _state.update { ActivityState() } + isSyncingLdkNodePayments.update { false } + notifyActivitiesChanged() + Logger.debug("Activity state reset", context = TAG) + } suspend fun syncActivities(): Result = withContext(bgDispatcher) { Logger.debug("syncActivities called", context = TAG) - return@withContext runCatching { + val result = runCatching { withTimeout(SYNC_TIMEOUT_MS) { Logger.debug("isSyncingLdkNodePayments = ${isSyncingLdkNodePayments.value}", context = TAG) isSyncingLdkNodePayments.first { !it } } - isSyncingLdkNodePayments.value = true + isSyncingLdkNodePayments.update { true } deletePendingActivities() - return@withContext lightningRepo.getPayments() - .onSuccess { payments -> - Logger.debug("Got payments with success, syncing activities", context = TAG) - syncLdkNodePayments(payments = payments).onFailure { e -> - return@withContext Result.failure(e) - } - updateActivitiesMetadata() - syncTagsMetadata() - boostPendingActivities() - transferRepo.syncTransferStates() - isSyncingLdkNodePayments.value = false - return@withContext Result.success(Unit) - }.onFailure { e -> - Logger.error("Failed to sync ldk-node payments", e, context = TAG) - isSyncingLdkNodePayments.value = false - return@withContext Result.failure(e) - }.map { Unit } - }.onFailure { e -> - when (e) { - is TimeoutCancellationException -> { - isSyncingLdkNodePayments.value = false - Logger.warn("Timeout waiting for sync to complete, forcing reset", context = TAG) - } - else -> { - isSyncingLdkNodePayments.value = false - Logger.error("syncActivities error", e, context = TAG) - } + lightningRepo.getPayments().mapCatching { payments -> + Logger.debug("Got payments with success, syncing activities", context = TAG) + syncLdkNodePayments(payments).getOrThrow() + updateActivitiesMetadata() + syncTagsMetadata() + boostPendingActivities() + transferRepo.syncTransferStates() + getAllAvailableTags().map { }.getOrThrow() + }.getOrThrow() + }.onFailure { e -> + if (e is TimeoutCancellationException) { + Logger.warn("syncActivities timeout, forcing reset", context = TAG) + } else { + Logger.error("Failed to sync activities", e, context = TAG) } } + + isSyncingLdkNodePayments.update { false } + notifyActivitiesChanged() + + return@withContext result } /** @@ -542,10 +546,11 @@ class ActivityRepo @Inject constructor( cacheStore.addActivityToPendingBoost(pendingBoostActivity) } - /** - * Adds tags to an activity with business logic validation - */ - suspend fun addTagsToActivity(activityId: String, tags: List): Result = withContext(bgDispatcher) { + @VisibleForTesting + suspend fun addTagsToActivity( + activityId: String, + tags: List, + ): Result = withContext(bgDispatcher) { return@withContext runCatching { checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } @@ -578,11 +583,9 @@ class ActivityRepo @Inject constructor( paymentHashOrTxId = paymentHashOrTxId, type = type, txType = txType - ).onSuccess { activity -> - addTagsToActivity(activity.rawId(), tags = tags) - }.onFailure { e -> - return@withContext Result.failure(e) - }.map { Unit } + ).mapCatching { activity -> + addTagsToActivity(activity.rawId(), tags = tags).getOrThrow() + } } /** @@ -612,12 +615,11 @@ class ActivityRepo @Inject constructor( } } - /** - * Gets all possible tags across all activities - */ suspend fun getAllAvailableTags(): Result> = withContext(bgDispatcher) { return@withContext runCatching { coreService.activity.allPossibleTags() + }.onSuccess { tags -> + _state.update { it.copy(tags = tags) } }.onFailure { e -> Logger.error("getAllAvailableTags error", e, context = TAG) } @@ -705,3 +707,7 @@ class ActivityRepo @Inject constructor( private const val TAG = "ActivityRepo" } } + +data class ActivityState( + val tags: List = emptyList(), +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index bfd97eb70..7bdc8ec83 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -69,16 +69,20 @@ class BackupRepo @Inject constructor( private val dataListenerJobs = mutableListOf() private var periodicCheckJob: Job? = null private var isObserving = false + private var lastNotificationTime = 0L + private val _isRestoring = MutableStateFlow(false) val isRestoring: StateFlow = _isRestoring.asStateFlow() - private var lastNotificationTime = 0L + private val _isWiping = MutableStateFlow(false) fun reset() { stopObservingBackups() vssBackupClient.reset() } + fun setWiping(isWiping: Boolean) = _isWiping.update { isWiping } + fun startObservingBackups() { if (isObserving) return @@ -144,7 +148,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.SETTINGS) } } @@ -155,7 +159,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.WIDGETS) } } @@ -167,7 +171,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.WALLET) } } @@ -179,7 +183,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.METADATA) } } @@ -192,7 +196,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.METADATA) } } @@ -203,18 +207,18 @@ class BackupRepo @Inject constructor( blocktankRepo.blocktankState .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.BLOCKTANK) } } dataListenerJobs.add(blocktankJob) - // ACTIVITY - Observe all activity changes notified by ActivityRepo on any mutation to core's activity store + // ACTIVITY - Observe activity changes val activityChangesJob = scope.launch { activityRepo.activitiesChanged .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.ACTIVITY) } } @@ -227,7 +231,7 @@ class BackupRepo @Inject constructor( val lastSync = lightningService.status?.latestLightningWalletSyncTimestamp?.toLong() ?.let { it * 1000 } // Convert seconds to millis ?: return@collect - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { it.copy(required = lastSync, synced = lastSync, running = false) } @@ -505,7 +509,9 @@ class BackupRepo @Inject constructor( private fun currentTimeMillis(): Long = nowMillis(clock) - private fun BackupItemStatus.shouldBackup() = this.isRequired && !this.running && !isRestoring.value + private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value + + private fun BackupItemStatus.shouldBackup() = this.isRequired && !this.running && !shouldSkipBackup() companion object { private const val TAG = "BackupRepo" diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b286b3d98..6413ce711 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -52,6 +52,7 @@ class WalletRepo @Inject constructor( private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, private val backupRepo: BackupRepo, private val blocktankRepo: BlocktankRepo, + private val activityRepo: ActivityRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -237,29 +238,44 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + // !order is critical try { + backupRepo.setWiping(true) backupRepo.reset() - blocktankRepo.resetState() - - _walletState.update { WalletState() } - _balanceState.update { BalanceState() } keychain.wipe() + + // clear stored state + coreService.wipeData() db.clearAllTables() settingsStore.reset() cacheStore.reset() - // TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities - coreService.wipeData() - Logger.reset() - setWalletExistsState() - return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) + // clear cached state + blocktankRepo.resetState() + activityRepo.resetState() + resetState() + + // stop and wipe node caches + return@withContext lightningRepo.wipeStorage(walletIndex) + .onSuccess { + // trigger nav to onboarding + setWalletExistsState() + Logger.reset() + } } catch (e: Throwable) { Logger.error("Wipe wallet error", e) Result.failure(e) + } finally { + backupRepo.setWiping(false) } } + private fun resetState() { + _walletState.update { WalletState() } + _balanceState.update { BalanceState() } + } + // Blockchain address management fun getOnchainAddress(): String = _walletState.value.onchainAddress diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c4979c18d..f7d234cc6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -311,7 +311,7 @@ fun ContentView( LaunchedEffect(balance) { // Anytime we receive a balance update, we should sync the payments to activity list - activityListViewModel.syncLdkNodePayments() + activityListViewModel.resync() } // Keep backups in sync diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 7e091afb3..2701817eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -161,10 +161,9 @@ fun HomeScreen( drawerState = drawerState, latestActivities = latestActivities, onRefresh = { - activityListViewModel.fetchLatestActivities() + activityListViewModel.resync() walletViewModel.onPullToRefresh() homeViewModel.refreshWidgets() - activityListViewModel.syncLdkNodePayments() }, onClickProfile = { if (!hasSeenProfileIntro) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 318ea6f72..2c77ff150 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -155,7 +155,7 @@ fun ActivityDetailScreen( title = context.getString(R.string.wallet__boost_success_title), description = context.getString(R.string.wallet__boost_success_msg) ) - listViewModel.fetchLatestActivities() + listViewModel.resync() onCloseClick() }, onFailure = { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index ccdc74a2a..e42417bf9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -8,15 +8,20 @@ import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isTransfer import to.bitkit.repositories.ActivityRepo -import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger @@ -25,7 +30,6 @@ import javax.inject.Inject @HiltViewModel class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val coreService: CoreService, private val ldkNodeEventBus: LdkNodeEventBus, private val activityRepo: ActivityRepo, ) : ViewModel() { @@ -38,201 +42,130 @@ class ActivityListViewModel @Inject constructor( private val _onchainActivities = MutableStateFlow?>(null) val onchainActivities = _onchainActivities.asStateFlow() - private val _searchText = MutableStateFlow("") - val searchText = _searchText.asStateFlow() - - fun setSearchText(text: String) { - _searchText.value = text - } - - private val _startDate = MutableStateFlow(null) - val startDate = _startDate.asStateFlow() - - private val _endDate = MutableStateFlow(null) - val endDate = _endDate.asStateFlow() - - private val _selectedTags = MutableStateFlow>(emptySet()) - val selectedTags = _selectedTags.asStateFlow() - - fun toggleTag(tag: String) { - _selectedTags.value = if (_selectedTags.value.contains(tag)) { - _selectedTags.value - tag - } else { - _selectedTags.value + tag - } - } - private val _latestActivities = MutableStateFlow?>(null) val latestActivities = _latestActivities.asStateFlow() - private val _availableTags = MutableStateFlow>(emptyList()) - val availableTags = _availableTags.asStateFlow() + val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(emptyList()) - private var isClearingFilters = false + private val _filters = MutableStateFlow(ActivityFilters()) - private val _selectedTab = MutableStateFlow(ActivityTab.ALL) - val selectedTab = _selectedTab.asStateFlow() + // individual filters for UI + val searchText: StateFlow = _filters.map { it.searchText }.stateInScope("") + val startDate: StateFlow = _filters.map { it.startDate }.stateInScope(null) + val endDate: StateFlow = _filters.map { it.endDate }.stateInScope(null) + val selectedTags: StateFlow> = _filters.map { it.tags }.stateInScope(emptySet()) + val selectedTab: StateFlow = _filters.map { it.tab }.stateInScope(ActivityTab.ALL) - fun setTab(tab: ActivityTab) { - _selectedTab.value = tab - viewModelScope.launch(bgDispatcher) { - updateFilteredActivities() - } + fun setSearchText(text: String) = _filters.update { it.copy(searchText = text) } + + fun setTab(tab: ActivityTab) = _filters.update { it.copy(tab = tab) } + + fun toggleTag(tag: String) = _filters.update { + val newTags = if (tag in it.tags) it.tags - tag else it.tags + tag + it.copy(tags = newTags) } init { - viewModelScope.launch(bgDispatcher) { - ldkNodeEventBus.events.collect { - // TODO: sync only on specific events for better performance - syncLdkNodePayments() - } - } - - observeSearchText() - observeDateRange() - observeSelectedTags() + observeActivities() + observeFilters() + observerNodeEvents() + resync() + } - fetchLatestActivities() + fun resync() = viewModelScope.launch { + activityRepo.syncActivities() } - @OptIn(FlowPreview::class) - private fun observeSearchText() { - viewModelScope.launch(bgDispatcher) { - _searchText - .debounce(300) - .collect { - if (!isClearingFilters) { - updateFilteredActivities() - } - } + private fun observeActivities() = viewModelScope.launch { + activityRepo.activitiesChanged.collect { + refreshActivityState() } } - private fun observeDateRange() { - viewModelScope.launch(bgDispatcher) { - combine(_startDate, _endDate) { _, _ -> } - .collect { - if (!isClearingFilters) { - updateFilteredActivities() - } - } - } + private fun observeFilters() = viewModelScope.launch { + @OptIn(FlowPreview::class) + combine( + _filters.map { it.searchText }.debounce(300), + _filters.map { it.copy(searchText = "") }, + ) { debouncedSearch, filtersWithoutSearch -> + fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) + }.collect { _filteredActivities.value = it } } - private fun observeSelectedTags() { - viewModelScope.launch(bgDispatcher) { - _selectedTags.collect { - if (!isClearingFilters) { - updateFilteredActivities() - } - } + private fun observerNodeEvents() = viewModelScope.launch { + ldkNodeEventBus.events.collect { + // TODO: resync only on specific events for better performance + resync() } } - fun fetchLatestActivities() { - viewModelScope.launch(bgDispatcher) { - try { - // Fetch latest activities for the home screen - val limitLatest = 3u - _latestActivities.value = coreService.activity.get(filter = ActivityFilter.ALL, limit = limitLatest) - - // Fetch lightning and onchain activities - _lightningActivities.value = coreService.activity.get(filter = ActivityFilter.LIGHTNING) - _onchainActivities.value = coreService.activity.get(filter = ActivityFilter.ONCHAIN) - - // Fetch filtered activities and available tags - updateFilteredActivities() - updateAvailableTags() - } catch (e: Exception) { - Logger.error("Failed to sync activities", e) - } - } + private suspend fun refreshActivityState() { + val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() + _latestActivities.value = all.take(3) + _lightningActivities.value = all.filter { it is Activity.Lightning } + _onchainActivities.value = all.filter { it is Activity.Onchain } } - private suspend fun updateFilteredActivities() = withContext(bgDispatcher) { - try { - var txType: PaymentType? = when (_selectedTab.value) { - ActivityTab.SENT -> PaymentType.SENT - ActivityTab.RECEIVED -> PaymentType.RECEIVED - else -> null - } - - val activities = coreService.activity.get( - filter = ActivityFilter.ALL, - txType = txType, - tags = _selectedTags.value.takeIf { it.isNotEmpty() }?.toList(), - search = _searchText.value.takeIf { it.isNotEmpty() }, - minDate = _startDate.value?.let { it / 1000 }?.toULong(), - maxDate = _endDate.value?.let { it / 1000 }?.toULong(), - ) - - _filteredActivities.value = when (_selectedTab.value) { - ActivityTab.OTHER -> activities.filter { it is Activity.Onchain && it.v1.isTransfer } - else -> activities - } - } catch (e: Exception) { + private suspend fun fetchFilteredActivities(filters: ActivityFilters): List? { + val txType = when (filters.tab) { + ActivityTab.SENT -> PaymentType.SENT + ActivityTab.RECEIVED -> PaymentType.RECEIVED + else -> null + } + + val activities = activityRepo.getActivities( + filter = ActivityFilter.ALL, + txType = txType, + tags = filters.tags.takeIf { it.isNotEmpty() }?.toList(), + search = filters.searchText.takeIf { it.isNotEmpty() }, + minDate = filters.startDate?.let { it / 1000 }?.toULong(), + maxDate = filters.endDate?.let { it / 1000 }?.toULong(), + ).getOrElse { e -> Logger.error("Failed to filter activities", e) + return null + } + + return when (filters.tab) { + ActivityTab.OTHER -> activities.filter { it.isTransfer() } + else -> activities } } fun updateAvailableTags() { - viewModelScope.launch(bgDispatcher) { - try { - _availableTags.value = coreService.activity.allPossibleTags() - } catch (e: Exception) { - Logger.error("Failed to get available tags", e) - _availableTags.value = emptyList() - } + viewModelScope.launch { + activityRepo.getAllAvailableTags() } } - fun setDateRange(startDate: Long?, endDate: Long?) { - _startDate.value = startDate - _endDate.value = endDate - } - fun clearDateRange() { - _startDate.value = null - _endDate.value = null + fun setDateRange(startDate: Long?, endDate: Long?) = _filters.update { + it.copy(startDate = startDate, endDate = endDate) } - fun clearFilters() { - viewModelScope.launch(bgDispatcher) { - try { - isClearingFilters = true - - _searchText.value = "" - _selectedTags.value = emptySet() - _startDate.value = null - _endDate.value = null - _selectedTab.value = ActivityTab.ALL - - updateFilteredActivities() - } finally { - isClearingFilters = false - } - } + fun clearDateRange() = _filters.update { + it.copy(startDate = null, endDate = null) } - fun syncLdkNodePayments() { - viewModelScope.launch { - activityRepo.syncActivities().onSuccess { - fetchLatestActivities() - } - } - } + fun clearFilters() = _filters.update { ActivityFilters() } - fun generateRandomTestData(count: Int) { - viewModelScope.launch(bgDispatcher) { - coreService.activity.generateRandomTestData(count) - fetchLatestActivities() - } + fun generateRandomTestData(count: Int) = viewModelScope.launch(bgDispatcher) { + activityRepo.generateTestData(count) } - fun removeAllActivities() { - viewModelScope.launch(bgDispatcher) { - coreService.activity.removeAll() - fetchLatestActivities() - } + fun removeAllActivities() = viewModelScope.launch(bgDispatcher) { + activityRepo.removeAllActivities() } + + private fun Flow.stateInScope( + initialValue: T, + started: SharingStarted = SharingStarted.WhileSubscribed(5000), + ): StateFlow = stateIn(viewModelScope, started, initialValue) } + +data class ActivityFilters( + val tab: ActivityTab = ActivityTab.ALL, + val tags: Set = emptySet(), + val searchText: String = "", + val startDate: Long? = null, + val endDate: Long? = null, +) From 4ecd67b5b699515dddfb50dae200da8f5d56cf57 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Nov 2025 18:49:48 +0100 Subject: [PATCH 21/43] feat: integrate bitkit-core 0.1.27 minimally --- .../main/java/to/bitkit/ext/TagMetadata.kt | 41 ++++++++++-------- .../java/to/bitkit/models/BackupPayloads.kt | 4 +- .../to/bitkit/repositories/ActivityRepo.kt | 42 ++++++++++++++++--- .../java/to/bitkit/repositories/BackupRepo.kt | 21 +++++----- .../java/to/bitkit/services/CoreService.kt | 18 +++++--- gradle/libs.versions.toml | 2 +- 6 files changed, 87 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/TagMetadata.kt b/app/src/main/java/to/bitkit/ext/TagMetadata.kt index 53707024b..383b637ae 100644 --- a/app/src/main/java/to/bitkit/ext/TagMetadata.kt +++ b/app/src/main/java/to/bitkit/ext/TagMetadata.kt @@ -1,24 +1,31 @@ package to.bitkit.ext -import com.synonym.bitkitcore.ActivityTagsMetadata +import com.synonym.bitkitcore.PreActivityMetadata import to.bitkit.data.entities.TagMetadataEntity -fun TagMetadataEntity.toActivityTagsMetadata() = ActivityTagsMetadata( - id, - paymentHash, - txId, - address, - isReceive, - tags, - createdAt.toULong(), +// TODO use PreActivityMetadata +fun TagMetadataEntity.toActivityTagsMetadata() = PreActivityMetadata( + paymentId = id, + createdAt = createdAt.toULong(), + tags = tags, + paymentHash = paymentHash, + txId = txId, + address = address, + isReceive = isReceive, + feeRate = 0u, + isTransfer = false, + channelId = "", ) -fun ActivityTagsMetadata.toTagMetadataEntity() = TagMetadataEntity( - id, - paymentHash, - txId, - address, - isReceive, - tags, - createdAt.toLong(), +fun PreActivityMetadata.toTagMetadataEntity() = TagMetadataEntity( + id = paymentId, + createdAt = createdAt.toLong(), + tags = tags, + paymentHash = paymentHash, + txId = txId, + address = address.orEmpty(), + isReceive = isReceive, + // feeRate = 0u, + // isTransfer = false, + // channelId = "", ) diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index b80a2fc54..991cd1946 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -2,11 +2,11 @@ package to.bitkit.models import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityTags -import com.synonym.bitkitcore.ActivityTagsMetadata import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry +import com.synonym.bitkitcore.PreActivityMetadata import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData import to.bitkit.data.entities.TransferEntity @@ -22,7 +22,7 @@ data class WalletBackupV1( data class MetadataBackupV1( val version: Int = 1, val createdAt: Long, - val tagMetadata: List, + val tagMetadata: List, val cache: AppCacheData, ) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 9ddc78d1f..a64de9b9d 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -9,6 +9,7 @@ import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.TimeoutCancellationException @@ -628,14 +629,36 @@ class ActivityRepo @Inject constructor( /** * Get all [ActivityTags] for backup */ - suspend fun getAllActivityTags(): Result> = withContext(bgDispatcher) { + suspend fun getAllActivitiesTags(): Result> = withContext(bgDispatcher) { return@withContext runCatching { - coreService.activity.getAllActivityTags() + coreService.activity.getAllActivitiesTags() }.onFailure { e -> Logger.error("getAllActivityTags error", e, context = TAG) } } + /** + * Get all [PreActivityMetadata] for backup + */ + suspend fun getAllPreActivityMetadata(): Result> = withContext(bgDispatcher) { + return@withContext runCatching { + coreService.activity.getAllPreActivityMetadata() + }.onFailure { e -> + Logger.error("getAllPreActivityMetadata error", e, context = TAG) + } + } + + /** + * Upsert all [PreActivityMetadata] + */ + suspend fun upsertPreActivityMetadata(list: List): Result = withContext(bgDispatcher) { + return@withContext runCatching { + coreService.activity.upsertPreActivityMetadata(list) + }.onFailure { e -> + Logger.error("upsertPreActivityMetadata error", e, context = TAG) + } + } + suspend fun saveTagsMetadata( id: String, paymentHash: String? = null, @@ -663,12 +686,19 @@ class ActivityRepo @Inject constructor( } } - suspend fun restoreFromBackup(backup: ActivityBackupV1): Result = withContext(bgDispatcher) { + suspend fun restoreFromBackup(payload: ActivityBackupV1): Result = withContext(bgDispatcher) { return@withContext runCatching { - coreService.activity.upsertList(backup.activities) - coreService.activity.upsertTags(backup.activityTags) - coreService.activity.upsertClosedChannelList(backup.closedChannels) + coreService.activity.upsertList(payload.activities) + coreService.activity.upsertTags(payload.activityTags) + coreService.activity.upsertClosedChannelList(payload.closedChannels) + }.onSuccess { + Logger.debug( + "Restored ${payload.activities.size} activities, ${payload.activityTags.size} activity tags, " + + "${payload.closedChannels.size} closed channels", + context = TAG, + ) } + } // MARK: - Development/Testing Methods diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 7bdc8ec83..b25b079b0 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -190,6 +192,7 @@ class BackupRepo @Inject constructor( dataListenerJobs.add(tagMetadataJob) // METADATA - Observe entire CacheStore excluding backup statuses + // TODO use PreActivityMetadata val cacheMetadataJob = scope.launch { cacheStore.data .map { it.copy(backupStatuses = mapOf()) } @@ -244,7 +247,7 @@ class BackupRepo @Inject constructor( private fun startPeriodicBackupFailureCheck() { periodicCheckJob = scope.launch { - while (true) { + while (currentCoroutineContext().isActive) { delay(BACKUP_CHECK_INTERVAL) checkForFailedBackups() } @@ -374,6 +377,8 @@ class BackupRepo @Inject constructor( BackupCategory.METADATA -> { val tagMetadata = db.tagMetadataDao().getAll().map { it.toActivityTagsMetadata() } val cacheData = cacheStore.data.first().copy(onchainAddress = "") // Force onchain address rotation + // TODO use PreActivityMetadata + // val preActivityMetadata = activityRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) val payload = MetadataBackupV1( createdAt = currentTimeMillis(), @@ -400,7 +405,7 @@ class BackupRepo @Inject constructor( BackupCategory.ACTIVITY -> { val activities = activityRepo.getActivities().getOrDefault(emptyList()) val closedChannels = activityRepo.getClosedChannels().getOrDefault(emptyList()) - val activityTags = activityRepo.getAllActivityTags().getOrDefault(emptyList()) + val activityTags = activityRepo.getAllActivitiesTags().getOrDefault(emptyList()) val payload = ActivityBackupV1( createdAt = currentTimeMillis(), @@ -430,9 +435,11 @@ class BackupRepo @Inject constructor( } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() + // TODO use PreActivityMetadata + // activityRepo.upsertPreActivityMetadata(parsed.tagMetadata) val tagMetadata = parsed.tagMetadata.map { it.toTagMetadataEntity() } db.tagMetadataDao().upsert(tagMetadata) - Logger.debug("Restored caches and ${tagMetadata.size} tags metadata records", TAG) + Logger.debug("Restored caches, ${tagMetadata.size} pre-activity metadata", TAG) } performRestore(BackupCategory.SETTINGS) { dataBytes -> @@ -456,13 +463,7 @@ class BackupRepo @Inject constructor( } performRestore(BackupCategory.ACTIVITY) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - activityRepo.restoreFromBackup(parsed).onSuccess { - Logger.debug( - "Restored ${parsed.activities.size} activities, ${parsed.activityTags.size} activity tags, " + - "${parsed.closedChannels.size} closed channels", - context = TAG, - ) - } + activityRepo.restoreFromBackup(parsed) } Logger.info("Full restore success", context = TAG) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 2e49cc462..e638bdae5 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -19,6 +19,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.WordCount import com.synonym.bitkitcore.addTags @@ -29,7 +30,6 @@ import com.synonym.bitkitcore.estimateOrderFeeFull import com.synonym.bitkitcore.getActivities import com.synonym.bitkitcore.getActivityById import com.synonym.bitkitcore.getAllClosedChannels -import com.synonym.bitkitcore.getAllTagMetadata import com.synonym.bitkitcore.getAllUniqueTags import com.synonym.bitkitcore.getCjitEntries import com.synonym.bitkitcore.getInfo @@ -190,7 +190,7 @@ class CoreService @Inject constructor( private const val CHUNK_SIZE = 50 class ActivityService( - private val coreService: CoreService, + @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, ) { suspend fun removeAll() { @@ -296,8 +296,16 @@ class ActivityService( com.synonym.bitkitcore.upsertTags(activityTags) } - suspend fun getAllActivityTags(): List = ServiceQueue.CORE.background { - getAllTagMetadata().map { ActivityTags(it.id, tags = it.tags) } + suspend fun getAllActivitiesTags(): List = ServiceQueue.CORE.background { + com.synonym.bitkitcore.getAllActivitiesTags() + } + + suspend fun getAllPreActivityMetadata(): List = ServiceQueue.CORE.background { + com.synonym.bitkitcore.getAllPreActivityMetadata() + } + + suspend fun upsertPreActivityMetadata(list: List) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.upsertPreActivityMetadata(list) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -595,7 +603,7 @@ class ActivityService( // region Blocktank class BlocktankService( - private val coreService: CoreService, + @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val lightningService: LightningService, ) { suspend fun info(refresh: Boolean = true): IBtInfo? { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9b9032b0..72a6b7408 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.24" } +bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.27" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 6effdffd064ddafe2eecb86b5195380c52f2e6d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Nov 2025 19:54:01 +0100 Subject: [PATCH 22/43] chore: lint --- .../ext/{TagMetadata.kt => TagMetadataEntity.kt} | 6 +++--- .../java/to/bitkit/repositories/ActivityRepo.kt | 12 +++++++----- .../java/to/bitkit/repositories/BackupRepo.kt | 15 ++++++--------- .../bitkit/viewmodels/RestoreWalletViewModel.kt | 12 ++++++------ 4 files changed, 22 insertions(+), 23 deletions(-) rename app/src/main/java/to/bitkit/ext/{TagMetadata.kt => TagMetadataEntity.kt} (73%) diff --git a/app/src/main/java/to/bitkit/ext/TagMetadata.kt b/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt similarity index 73% rename from app/src/main/java/to/bitkit/ext/TagMetadata.kt rename to app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt index 383b637ae..b586d9d8d 100644 --- a/app/src/main/java/to/bitkit/ext/TagMetadata.kt +++ b/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt @@ -12,9 +12,9 @@ fun TagMetadataEntity.toActivityTagsMetadata() = PreActivityMetadata( txId = txId, address = address, isReceive = isReceive, - feeRate = 0u, - isTransfer = false, - channelId = "", + feeRate = 0u, // TODO: update room db entity or drop it in favour of bitkit-core + isTransfer = false, // TODO: update room db entity or drop it in favour of bitkit-core + channelId = "", // TODO: update room db entity or drop it in favour of bitkit-core ) fun PreActivityMetadata.toTagMetadataEntity() = TagMetadataEntity( diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index a64de9b9d..36554791a 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -91,8 +91,9 @@ class ActivityRepo @Inject constructor( updateActivitiesMetadata() syncTagsMetadata() boostPendingActivities() - transferRepo.syncTransferStates() - getAllAvailableTags().map { }.getOrThrow() + transferRepo.syncTransferStates().getOrThrow() + }.onSuccess { + getAllAvailableTags().getOrNull() }.getOrThrow() }.onFailure { e -> if (e is TimeoutCancellationException) { @@ -332,10 +333,10 @@ class ActivityRepo @Inject constructor( }.awaitAll() } - private suspend fun syncTagsMetadata() = withContext(context = bgDispatcher) { + private suspend fun syncTagsMetadata(): Result = withContext(context = bgDispatcher) { runCatching { - if (db.tagMetadataDao().getAll().isEmpty()) return@withContext - val lastActivities = getActivities(limit = 10u).getOrNull() ?: return@withContext + if (db.tagMetadataDao().getAll().isEmpty()) return@runCatching + val lastActivities = getActivities(limit = 10u).getOrNull() ?: return@runCatching Logger.debug("syncTagsMetadata called") lastActivities.map { activity -> @@ -411,6 +412,7 @@ class ActivityRepo @Inject constructor( } } }.awaitAll() + Result.success(Unit) } } diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index b25b079b0..4a161b88a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -84,6 +84,9 @@ class BackupRepo @Inject constructor( } fun setWiping(isWiping: Boolean) = _isWiping.update { isWiping } + private fun currentTimeMillis(): Long = nowMillis(clock) + private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value + private fun BackupItemStatus.shouldBackup() = this.isRequired && !this.running && !shouldSkipBackup() fun startObservingBackups() { if (isObserving) return @@ -376,7 +379,7 @@ class BackupRepo @Inject constructor( BackupCategory.METADATA -> { val tagMetadata = db.tagMetadataDao().getAll().map { it.toActivityTagsMetadata() } - val cacheData = cacheStore.data.first().copy(onchainAddress = "") // Force onchain address rotation + val cacheData = cacheStore.data.first() // TODO use PreActivityMetadata // val preActivityMetadata = activityRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) @@ -431,7 +434,7 @@ class BackupRepo @Inject constructor( performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) cacheStore.update { - parsed.cache.copy(onchainAddress = "") // Fore onchain address rotation + parsed.cache.copy(onchainAddress = "") // Force onchain address rotation } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() @@ -439,7 +442,7 @@ class BackupRepo @Inject constructor( // activityRepo.upsertPreActivityMetadata(parsed.tagMetadata) val tagMetadata = parsed.tagMetadata.map { it.toTagMetadataEntity() } db.tagMetadataDao().upsert(tagMetadata) - Logger.debug("Restored caches, ${tagMetadata.size} pre-activity metadata", TAG) + Logger.debug("Restored ${tagMetadata.size} pre-activity metadata", TAG) } performRestore(BackupCategory.SETTINGS) { dataBytes -> @@ -508,12 +511,6 @@ class BackupRepo @Inject constructor( } } - private fun currentTimeMillis(): Long = nowMillis(clock) - - private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value - - private fun BackupItemStatus.shouldBackup() = this.isRequired && !this.running && !shouldSkipBackup() - companion object { private const val TAG = "BackupRepo" diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index 2fa52bb47..9f81f2f5e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject private const val WORDS_MIN = 12 -private const val WORS_MAX = 24 +private const val WORDS_MAX = 24 @HiltViewModel class RestoreWalletViewModel @Inject constructor() : ViewModel() { @@ -85,7 +85,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { val pastedWords = pastedText .split(separators) .filter { it.isNotBlank() } - if (pastedWords.size == WORDS_MIN || pastedWords.size == WORS_MAX) { + if (pastedWords.size == WORDS_MIN || pastedWords.size == WORDS_MAX) { val invalidIndices = pastedWords.withIndex() .filter { !isValidBip39Word(it.value) } .map { it.index } @@ -93,7 +93,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { val newWords = _uiState.value.words.toMutableList().apply { pastedWords.forEachIndexed { index, word -> this[index] = word } - for (index in pastedWords.size until WORS_MAX) { + for (index in pastedWords.size until WORDS_MAX) { this[index] = "" } } @@ -102,7 +102,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { it.copy( words = newWords, invalidWordIndices = invalidIndices, - is24Words = pastedWords.size == WORS_MAX, + is24Words = pastedWords.size == WORDS_MAX, shouldDismissKeyboard = invalidIndices.isEmpty(), focusedIndex = null, suggestions = emptyList(), @@ -149,7 +149,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } data class RestoreWalletUiState( - val words: List = List(WORS_MAX) { "" }, + val words: List = List(WORDS_MAX) { "" }, val invalidWordIndices: Set = emptySet(), val suggestions: List = emptyList(), val focusedIndex: Int? = null, @@ -159,7 +159,7 @@ data class RestoreWalletUiState( val shouldDismissKeyboard: Boolean = false, val scrollToFieldIndex: Int? = null, ) { - val wordCount: Int get() = if (is24Words) WORS_MAX else WORDS_MIN + val wordCount: Int get() = if (is24Words) WORDS_MAX else WORDS_MIN val wordsPerColumn: Int get() = if (is24Words) WORDS_MIN else 6 val checksumErrorVisible: Boolean From 42a48c7524578393ae196e9a28fcc5fdc80a4fb0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 16:20:40 +0100 Subject: [PATCH 23/43] feat: use payload models for settings and widgets --- .../main/java/to/bitkit/data/SettingsStore.kt | 10 ++++++++ .../main/java/to/bitkit/data/WidgetsStore.kt | 23 +++++++++++++------ .../java/to/bitkit/models/BackupPayloads.kt | 16 +++++++++++++ .../java/to/bitkit/repositories/BackupRepo.kt | 23 +++++++++++++++---- .../to/bitkit/repositories/WalletRepoTest.kt | 2 ++ 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index ea3c10b53..0d0aebe10 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -12,6 +12,7 @@ import to.bitkit.env.Env import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.SettingsBackupV1 import to.bitkit.models.Suggestion import to.bitkit.models.TransactionSpeed import to.bitkit.utils.Logger @@ -31,6 +32,14 @@ class SettingsStore @Inject constructor( val data: Flow = store.data + suspend fun restoreFromBackup(payload: SettingsBackupV1) = + runCatching { + val data = payload.settings.resetPin() + store.updateData { data } + }.onSuccess { + Logger.debug("Restored settings", TAG) + } + suspend fun update(transform: (SettingsData) -> SettingsData) { store.updateData(transform) } @@ -62,6 +71,7 @@ class SettingsStore @Inject constructor( } companion object { + private const val TAG = "SettingsStore" private const val MAX_LAST_USED_TAGS = 10 } } diff --git a/app/src/main/java/to/bitkit/data/WidgetsStore.kt b/app/src/main/java/to/bitkit/data/WidgetsStore.kt index 2a7fab9ae..dd43fc8ae 100644 --- a/app/src/main/java/to/bitkit/data/WidgetsStore.kt +++ b/app/src/main/java/to/bitkit/data/WidgetsStore.kt @@ -15,6 +15,7 @@ import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.serializers.WidgetsSerializer import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.WidgetsBackupV1 import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.CalculatorValues import to.bitkit.models.widget.FactsPreferences @@ -43,9 +44,13 @@ class WidgetsStore @Inject constructor( val weatherFlow: Flow = data.map { it.weather } val priceFlow: Flow = data.map { it.price } - suspend fun update(transform: (WidgetsData) -> WidgetsData) { - store.updateData(transform) - } + suspend fun restoreFromBackup(payload: WidgetsBackupV1) = + runCatching { + val data = payload.widgets + store.updateData { data } + }.onSuccess { + Logger.debug("Restored widgets", TAG) + } suspend fun updateCalculatorValues(calculatorValues: CalculatorValues) { store.updateData { @@ -127,16 +132,16 @@ class WidgetsStore @Inject constructor( suspend fun addWidget(type: WidgetType) { if (store.data.first().widgets.map { it.type }.contains(type)) return - store.updateData { - it.copy(widgets = (it.widgets + WidgetWithPosition(type = type)).sortedBy { it.position }) + store.updateData { data -> + data.copy(widgets = (data.widgets + WidgetWithPosition(type = type)).sortedBy { it.position }) } } suspend fun deleteWidget(type: WidgetType) { if (!store.data.first().widgets.map { it.type }.contains(type)) return - store.updateData { - it.copy(widgets = it.widgets.filterNot { it.type == type }) + store.updateData { data -> + data.copy(widgets = data.widgets.filterNot { it.type == type }) } } @@ -145,6 +150,10 @@ class WidgetsStore @Inject constructor( it.copy(widgets = widgets) } } + + companion object { + private const val TAG = "WidgetsStore" + } } @Serializable diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 991cd1946..ab0a8980f 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -9,6 +9,8 @@ import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.PreActivityMetadata import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData +import to.bitkit.data.SettingsData +import to.bitkit.data.WidgetsData import to.bitkit.data.entities.TransferEntity @Serializable @@ -43,3 +45,17 @@ data class ActivityBackupV1( val activityTags: List, val closedChannels: List, ) + +@Serializable +data class SettingsBackupV1( + val version: Int = 1, + val createdAt: Long, + val settings: SettingsData, +) + +@Serializable +data class WidgetsBackupV1( + val version: Int = 1, + val createdAt: Long, + val widgets: WidgetsData, +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 4a161b88a..953b0d8b1 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -40,8 +40,10 @@ import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 +import to.bitkit.models.SettingsBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WidgetsBackupV1 import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -358,12 +360,20 @@ class BackupRepo @Inject constructor( private suspend fun getBackupDataBytes(category: BackupCategory): ByteArray = when (category) { BackupCategory.SETTINGS -> { val data = settingsStore.data.first().resetPin() - json.encodeToString(data).toByteArray() + val payload = SettingsBackupV1( + createdAt = currentTimeMillis(), + settings = data, + ) + json.encodeToString(payload).toByteArray() } BackupCategory.WIDGETS -> { val data = widgetsStore.data.first() - json.encodeToString(data).toByteArray() + val payload = WidgetsBackupV1( + createdAt = currentTimeMillis(), + widgets = data, + ) + json.encodeToString(payload).toByteArray() } BackupCategory.WALLET -> { @@ -433,9 +443,8 @@ class BackupRepo @Inject constructor( return@withContext try { performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - cacheStore.update { - parsed.cache.copy(onchainAddress = "") // Force onchain address rotation - } + val cleanedUp = parsed.cache.copy(onchainAddress = "") // Force address rotation + cacheStore.update { cleanedUp } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() // TODO use PreActivityMetadata @@ -448,10 +457,14 @@ class BackupRepo @Inject constructor( performRestore(BackupCategory.SETTINGS) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)).resetPin() settingsStore.update { parsed } + val parsed = json.decodeFromString(String(dataBytes)) + settingsStore.restoreFromBackup(parsed) } performRestore(BackupCategory.WIDGETS) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) widgetsStore.update { parsed } + val parsed = json.decodeFromString(String(dataBytes)) + widgetsStore.restoreFromBackup(parsed) } performRestore(BackupCategory.WALLET) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index a8aee822b..0b0082a1b 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -48,6 +48,7 @@ class WalletRepoTest : BaseUnitTest() { private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase = mock() private val backupRepo = mock() private val blocktankRepo = mock() + private val activityRepo = mock() @Before fun setUp() { @@ -79,6 +80,7 @@ class WalletRepoTest : BaseUnitTest() { deriveBalanceStateUseCase = deriveBalanceStateUseCase, backupRepo = backupRepo, blocktankRepo = blocktankRepo, + activityRepo = activityRepo, ) @Test From e699c5413d5157c457d97176012078cebb8eb7db Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 17:44:45 +0100 Subject: [PATCH 24/43] fix: preserve backup times & fix race condition --- .../java/to/bitkit/repositories/BackupRepo.kt | 69 ++++++++++++++----- .../to/bitkit/repositories/BlocktankRepo.kt | 16 +++-- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 953b0d8b1..a432ee473 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -23,9 +23,7 @@ import kotlinx.datetime.Clock import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.resetPin @@ -48,6 +46,7 @@ import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -72,6 +71,9 @@ class BackupRepo @Inject constructor( private val statusObserverJobs = mutableListOf() private val dataListenerJobs = mutableListOf() private var periodicCheckJob: Job? = null + + private val runningBackups = ConcurrentHashMap.newKeySet() + private var isObserving = false private var lastNotificationTime = 0L @@ -97,6 +99,22 @@ class BackupRepo @Inject constructor( Logger.debug("Start observing backup statuses and data store changes", context = TAG) scope.launch { vssBackupClient.setup() } + + scope.launch { + BackupCategory.entries.forEach { category -> + if (category !in runningBackups) { + cacheStore.updateBackupStatus(category) { status -> + if (status.running) { + Logger.debug("Clearing stale running flag for: '$category'", context = TAG) + status.copy(running = false) + } else { + status + } + } + } + } + } + startBackupStatusObservers() startDataStoreListeners() startPeriodicBackupFailureCheck() @@ -269,29 +287,40 @@ class BackupRepo @Inject constructor( } private fun scheduleBackup(category: BackupCategory) { - // Cancel existing backup job for this category backupJobs[category]?.cancel() Logger.verbose("Scheduling backup for: '$category'", context = TAG) backupJobs[category] = scope.launch { - // Set running immediately to prevent UI showing failure during debounce + runningBackups += category cacheStore.updateBackupStatus(category) { it.copy(running = true) } delay(BACKUP_DEBOUNCE) - // Double-check if backup is still needed val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.shouldBackup()) { + if (status.isRequired && !shouldSkipBackup()) { triggerBackup(category) } else { - // Backup no longer needed, reset running flag + Logger.debug("Backup no longer needed for: '$category'", context = TAG) + runningBackups -= category cacheStore.updateBackupStatus(category) { it.copy(running = false) } } + }.also { job -> + job.invokeOnCompletion { exception -> + if (exception != null) { + Logger.debug("Backup job cancelled for: '$category'", context = TAG) + scope.launch { + runningBackups -= category + cacheStore.updateBackupStatus(category) { + it.copy(running = false) + } + } + } + } } } @@ -335,12 +364,14 @@ class BackupRepo @Inject constructor( suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) + runningBackups += category cacheStore.updateBackupStatus(category) { it.copy(running = true, required = currentTimeMillis()) } vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)) .onSuccess { + runningBackups -= category cacheStore.updateBackupStatus(category) { it.copy( running = false, @@ -350,6 +381,7 @@ class BackupRepo @Inject constructor( Logger.info("Backup succeeded for: '$category'", context = TAG) } .onFailure { e -> + runningBackups -= category cacheStore.updateBackupStatus(category) { it.copy(running = false) } @@ -452,34 +484,34 @@ class BackupRepo @Inject constructor( val tagMetadata = parsed.tagMetadata.map { it.toTagMetadataEntity() } db.tagMetadataDao().upsert(tagMetadata) Logger.debug("Restored ${tagMetadata.size} pre-activity metadata", TAG) + parsed.createdAt } performRestore(BackupCategory.SETTINGS) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)).resetPin() - settingsStore.update { parsed } val parsed = json.decodeFromString(String(dataBytes)) settingsStore.restoreFromBackup(parsed) + parsed.createdAt } performRestore(BackupCategory.WIDGETS) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) - widgetsStore.update { parsed } val parsed = json.decodeFromString(String(dataBytes)) widgetsStore.restoreFromBackup(parsed) + parsed.createdAt } performRestore(BackupCategory.WALLET) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) + parsed.createdAt } performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - blocktankRepo.restoreFromBackup(parsed).onSuccess { - Logger.debug("Restored ${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJITs", TAG) - } + blocktankRepo.restoreFromBackup(parsed) + parsed.createdAt } performRestore(BackupCategory.ACTIVITY) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) activityRepo.restoreFromBackup(parsed) + parsed.createdAt } Logger.info("Full restore success", context = TAG) @@ -503,14 +535,16 @@ class BackupRepo @Inject constructor( private suspend fun performRestore( category: BackupCategory, - restoreAction: suspend (ByteArray) -> Unit, + restoreAction: suspend (dataBytes: ByteArray) -> Long, ): Result = runCatching { + var createdAtTimestamp = currentTimeMillis() + vssBackupClient.getObject(category.name).map { it?.value } .onSuccess { dataBytes -> if (dataBytes == null) { Logger.warn("Restore null for: '$category'", context = TAG) } else { - restoreAction(dataBytes) + createdAtTimestamp = restoreAction(dataBytes) Logger.info("Restore success for: '$category'", context = TAG) } } @@ -518,9 +552,8 @@ class BackupRepo @Inject constructor( Logger.debug("Restore error for: '$category'", context = TAG) } - val now = currentTimeMillis() cacheStore.updateBackupStatus(category) { - it.copy(running = false, synced = now, required = now) + it.copy(running = false, synced = createdAtTimestamp, required = createdAtTimestamp) } } diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 811ffdca3..49a5326d9 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -376,11 +376,11 @@ class BlocktankRepo @Inject constructor( Logger.debug("Blocktank state reset", context = TAG) } - suspend fun restoreFromBackup(backup: BlocktankBackupV1): Result = withContext(bgDispatcher) { + suspend fun restoreFromBackup(payload: BlocktankBackupV1): Result = withContext(bgDispatcher) { return@withContext runCatching { - coreService.blocktank.upsertOrderList(backup.orders) - coreService.blocktank.upsertCjitList(backup.cjitEntries) - backup.info?.let { info -> + coreService.blocktank.upsertOrderList(payload.orders) + coreService.blocktank.upsertCjitList(payload.cjitEntries) + payload.info?.let { info -> coreService.blocktank.setInfo(info) } @@ -389,11 +389,13 @@ class BlocktankRepo @Inject constructor( _blocktankState.update { it.copy( - orders = backup.orders, - cjitEntries = backup.cjitEntries, - info = backup.info, + orders = payload.orders, + cjitEntries = payload.cjitEntries, + info = payload.info, ) } + }.onSuccess { + Logger.debug("Restored ${payload.orders.size} orders, ${payload.cjitEntries.size} CJITs", TAG) } } From df103407f7a1c0fb04f59eff2224fa14be913248 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 17:51:57 +0100 Subject: [PATCH 25/43] chore: backup status docs & comments --- .../main/java/to/bitkit/models/BackupCategory.kt | 6 +++--- .../java/to/bitkit/repositories/BackupRepo.kt | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 519d85964..0181b711e 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -50,9 +50,9 @@ enum class BackupCategory( } /** - * @property running In progress - * @property synced Timestamp in ms of last time this backup was synced - * @property required Timestamp in ms of last time this backup was required + * @property running Backup is currently in progress + * @property synced Timestamp in millis of last time this backup succeeded + * @property required Timestamp in millis of last time the data changed */ @Serializable data class BackupItemStatus( diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a432ee473..b337e3fc5 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -50,6 +50,20 @@ import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +/** + * Manages backup & restore of wallet metadata to a remote VSS server. + * + * **Backup State Machine:** + * ``` + * Idle State: running=false, synced≥required + * ↓ (data changes → markBackupRequired()) + * Pending State: running=false, synced() private var periodicCheckJob: Job? = null - private val runningBackups = ConcurrentHashMap.newKeySet() + private val runningBackups = ConcurrentHashMap.newKeySet() // Tracks active jobs since app start private var isObserving = false private var lastNotificationTime = 0L From 67cfcf96a55af967ea863e3b0c84fd19d9f168ac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 17:53:31 +0100 Subject: [PATCH 26/43] chore: fix params compiler ambiguity --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index b337e3fc5..142a0f4b2 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -67,8 +67,8 @@ import javax.inject.Singleton @Suppress("LongParameterList") @Singleton class BackupRepo @Inject constructor( - @ApplicationContext private val context: Context, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val cacheStore: CacheStore, private val vssBackupClient: VssBackupClient, private val settingsStore: SettingsStore, From 3aa6ff88c2d2db4466a3d046bfa91dd306d453f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 18:11:21 +0100 Subject: [PATCH 27/43] fix: notify observers after activity restore --- app/src/main/java/to/bitkit/repositories/ActivityRepo.kt | 1 + .../main/java/to/bitkit/viewmodels/ActivityListViewModel.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 36554791a..5e45e32e1 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -699,6 +699,7 @@ class ActivityRepo @Inject constructor( "${payload.closedChannels.size} closed channels", context = TAG, ) + notifyActivitiesChanged() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index e42417bf9..650e5ca62 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -87,7 +87,8 @@ class ActivityListViewModel @Inject constructor( combine( _filters.map { it.searchText }.debounce(300), _filters.map { it.copy(searchText = "") }, - ) { debouncedSearch, filtersWithoutSearch -> + activityRepo.activitiesChanged, + ) { debouncedSearch, filtersWithoutSearch, _ -> fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) }.collect { _filteredActivities.value = it } } From b5d724c21159916f2a825f7de2f4ba2aa9bb2ed5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 18:56:15 +0100 Subject: [PATCH 28/43] fix: restore wallet input cursor & text style --- .../main/java/to/bitkit/ui/MainActivity.kt | 4 ++-- .../ui/onboarding/RestoreWalletScreen.kt | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index bd9ae8bf3..cab8868cf 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -36,7 +36,7 @@ import to.bitkit.ui.components.ToastOverlay import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen import to.bitkit.ui.onboarding.IntroScreen import to.bitkit.ui.onboarding.OnboardingSlidesScreen -import to.bitkit.ui.onboarding.RestoreWalletView +import to.bitkit.ui.onboarding.RestoreWalletScreen import to.bitkit.ui.onboarding.TermsOfUseScreen import to.bitkit.ui.onboarding.WarningMultipleDevicesScreen import to.bitkit.ui.screens.SplashScreen @@ -236,7 +236,7 @@ private fun OnboardingNav( ) } composableWithDefaultTransitions { - RestoreWalletView( + RestoreWalletScreen( onBackClick = { startupNavController.popBackStack() }, onRestoreClick = { mnemonic, passphrase -> scope.launch { diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 5bd9ab3fc..65f679735 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.TextRange import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -72,7 +71,7 @@ import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.RestoreWalletViewModel @Composable -fun RestoreWalletView( +fun RestoreWalletScreen( viewModel: RestoreWalletViewModel = hiltViewModel(), onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, @@ -337,13 +336,23 @@ fun MnemonicInputField( focusRequester: FocusRequester, index: Int, ) { - var isFocused by remember { mutableStateOf(false) } - val textFieldValue = TextFieldValue(text = value, selection = TextRange(value.length)) + var textFieldValue by remember { mutableStateOf(TextFieldValue()) } + + // Sync text from parent while preserving selection + LaunchedEffect(value) { + if (textFieldValue.text != value) { + val selection = textFieldValue.selection + textFieldValue = TextFieldValue(value, selection) + } + } OutlinedTextField( value = textFieldValue, - onValueChange = { onValueChanged(it.text) }, - textStyle = if (isFocused) AppTextStyles.BodySSB else AppTextStyles.BodyS, + onValueChange = { + textFieldValue = it + onValueChanged(it.text) + }, + textStyle = AppTextStyles.BodySSB, prefix = { Text( text = label, @@ -376,7 +385,6 @@ fun MnemonicInputField( } .testTag("Word-$index") .onFocusChanged { focusState -> - isFocused = focusState.isFocused onFocusChanged(focusState.isFocused) } .onGloballyPositioned { coordinates -> @@ -390,7 +398,7 @@ fun MnemonicInputField( @Composable private fun Preview() { AppThemeSurface { - RestoreWalletView( + RestoreWalletScreen( onBackClick = {}, onRestoreClick = { _, _ -> }, ) From b63446454df02147f333b8316cf83cd2e6afb71b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 19:39:51 +0100 Subject: [PATCH 29/43] refactor: extract wipe wallet use case --- .../java/to/bitkit/repositories/WalletRepo.kt | 43 +++---------- .../to/bitkit/usecases/WipeWalletUseCase.kt | 64 +++++++++++++++++++ .../to/bitkit/repositories/WalletRepoTest.kt | 47 +++++++++----- 3 files changed, 104 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6413ce711..9157ffb68 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -30,6 +30,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.AddressChecker import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -50,9 +51,7 @@ class WalletRepo @Inject constructor( private val lightningRepo: LightningRepo, private val cacheStore: CacheStore, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, - private val backupRepo: BackupRepo, - private val blocktankRepo: BlocktankRepo, - private val activityRepo: ActivityRepo, + private val wipeWalletUseCase: WipeWalletUseCase, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -238,40 +237,14 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - // !order is critical - try { - backupRepo.setWiping(true) - backupRepo.reset() - - keychain.wipe() - - // clear stored state - coreService.wipeData() - db.clearAllTables() - settingsStore.reset() - cacheStore.reset() - - // clear cached state - blocktankRepo.resetState() - activityRepo.resetState() - resetState() - - // stop and wipe node caches - return@withContext lightningRepo.wipeStorage(walletIndex) - .onSuccess { - // trigger nav to onboarding - setWalletExistsState() - Logger.reset() - } - } catch (e: Throwable) { - Logger.error("Wipe wallet error", e) - Result.failure(e) - } finally { - backupRepo.setWiping(false) - } + return@withContext wipeWalletUseCase( + walletIndex = walletIndex, + resetWalletState = ::resetState, + onSuccess = ::setWalletExistsState, + ) } - private fun resetState() { + fun resetState() { _walletState.update { WalletState() } _balanceState.update { BalanceState() } } diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt new file mode 100644 index 000000000..39053593f --- /dev/null +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -0,0 +1,64 @@ +package to.bitkit.usecases + +import to.bitkit.data.AppDb +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.services.CoreService +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WipeWalletUseCase @Inject constructor( + private val backupRepo: BackupRepo, + private val keychain: Keychain, + private val coreService: CoreService, + private val db: AppDb, + private val settingsStore: SettingsStore, + private val cacheStore: CacheStore, + private val blocktankRepo: BlocktankRepo, + private val activityRepo: ActivityRepo, + private val lightningRepo: LightningRepo, +) { + suspend operator fun invoke( + walletIndex: Int = 0, + resetWalletState: () -> Unit, + onSuccess: () -> Unit, + ): Result { + try { + backupRepo.setWiping(true) + backupRepo.reset() + + keychain.wipe() + + coreService.wipeData() + db.clearAllTables() + settingsStore.reset() + cacheStore.reset() + + blocktankRepo.resetState() + activityRepo.resetState() + resetWalletState() + + return lightningRepo.wipeStorage(walletIndex) + .onSuccess { + onSuccess() + Logger.reset() + } + } catch (e: Throwable) { + Logger.error("Wipe wallet error", e, context = TAG) + return Result.failure(e) + } finally { + backupRepo.setWiping(false) + } + } + + companion object Companion { + const val TAG = "WipeWalletUseCase" + } +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 0b0082a1b..654987146 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -26,6 +26,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.AddressChecker import to.bitkit.utils.AddressInfo import to.bitkit.utils.AddressStats @@ -37,18 +38,16 @@ class WalletRepoTest : BaseUnitTest() { private lateinit var sut: WalletRepo - private val db: AppDb = mock() - private val keychain: Keychain = mock() - private val coreService: CoreService = mock() - private val onchainService: OnchainService = mock() - private val settingsStore: SettingsStore = mock() - private val addressChecker: AddressChecker = mock() - private val lightningRepo: LightningRepo = mock() - private val cacheStore: CacheStore = mock() - private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase = mock() - private val backupRepo = mock() - private val blocktankRepo = mock() - private val activityRepo = mock() + private val db = mock() + private val keychain = mock() + private val coreService = mock() + private val onchainService = mock() + private val settingsStore = mock() + private val addressChecker = mock() + private val lightningRepo = mock() + private val cacheStore = mock() + private val deriveBalanceStateUseCase = mock() + private val wipeWalletUseCase = mock() @Before fun setUp() { @@ -78,9 +77,7 @@ class WalletRepoTest : BaseUnitTest() { lightningRepo = lightningRepo, cacheStore = cacheStore, deriveBalanceStateUseCase = deriveBalanceStateUseCase, - backupRepo = backupRepo, - blocktankRepo = blocktankRepo, - activityRepo = activityRepo, + wipeWalletUseCase = wipeWalletUseCase, ) @Test @@ -609,6 +606,26 @@ class WalletRepoTest : BaseUnitTest() { verify(lightningRepo, never()).newAddress() } + + @Test + fun `wipeWallet should call use case`() = test { + wheneverBlocking { wipeWalletUseCase.invoke(any(), any(), any()) }.thenReturn(Result.success(Unit)) + + val result = sut.wipeWallet() + + assertTrue(result.isSuccess) + verify(wipeWalletUseCase).invoke(any(), any(), any()) + } + + @Test + fun `wipeWallet should return failure when use case fails`() = test { + val error = RuntimeException("Reset failed") + wheneverBlocking { wipeWalletUseCase.invoke(any(), any(), any()) }.thenReturn(Result.failure(error)) + + val result = sut.wipeWallet() + + assertTrue(result.isFailure) + } } private fun mockAddressInfo() = AddressInfo( From c359badf8650544e787c91c942aac44285ae60ab Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 19:40:05 +0100 Subject: [PATCH 30/43] test: wipe wallet use case --- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt new file mode 100644 index 000000000..008dc4b79 --- /dev/null +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -0,0 +1,136 @@ +package to.bitkit.usecases + +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking +import to.bitkit.data.AppDb +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertTrue + +class WipeWalletUseCaseTest : BaseUnitTest() { + + private lateinit var sut: WipeWalletUseCase + + private val backupRepo = mock() + private val keychain = mock() + private val coreService = mock() + private val db = mock() + private val settingsStore = mock() + private val cacheStore = mock() + private val blocktankRepo = mock() + private val activityRepo = mock() + private val lightningRepo = mock() + + private var onWipeCalled = false + private var onSetWalletExistsStateCalled = false + + @Before + fun setUp() { + wheneverBlocking { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) + onWipeCalled = false + onSetWalletExistsStateCalled = false + + sut = WipeWalletUseCase( + backupRepo = backupRepo, + keychain = keychain, + coreService = coreService, + db = db, + settingsStore = settingsStore, + cacheStore = cacheStore, + blocktankRepo = blocktankRepo, + activityRepo = activityRepo, + lightningRepo = lightningRepo, + ) + } + + @Test + fun `invoke should reset all app state in correct order`() = runTest { + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isSuccess) + verify(backupRepo).setWiping(true) + verify(backupRepo).reset() + verify(keychain).wipe() + verify(coreService).wipeData() + verify(db).clearAllTables() + verify(settingsStore).reset() + verify(cacheStore).reset() + verify(blocktankRepo).resetState() + verify(activityRepo).resetState() + assertTrue(onWipeCalled) + verify(lightningRepo).wipeStorage(0) + assertTrue(onSetWalletExistsStateCalled) + verify(backupRepo).setWiping(false) + } + + @Test + fun `invoke should pass walletIndex to lightningRepo wipeStorage`() = runTest { + val walletIndex = 5 + wheneverBlocking { lightningRepo.wipeStorage(walletIndex) }.thenReturn(Result.success(Unit)) + + val result = sut.invoke( + walletIndex = walletIndex, + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isSuccess) + verify(lightningRepo).wipeStorage(walletIndex) + } + + @Test + fun `invoke should set wiping to false even on failure`() = runTest { + whenever(keychain.wipe()).thenThrow(RuntimeException("Test error")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + verify(backupRepo).setWiping(true) + verify(backupRepo).setWiping(false) + } + + @Test + fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { + val error = RuntimeException("Lightning wipe failed") + wheneverBlocking { lightningRepo.wipeStorage(0) }.thenReturn(Result.failure(error)) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + verify(backupRepo).setWiping(false) + } + + @Test + fun `invoke should return failure when database clear fails`() = runTest { + whenever(db.clearAllTables()).thenThrow(RuntimeException("DB clear failed")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + verify(backupRepo).setWiping(false) + } +} From d8318030d56b5fea036b78ec9a013a6dc6fad246 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 20:49:17 +0100 Subject: [PATCH 31/43] refactor: split restore screen content --- .../ui/onboarding/RestoreWalletScreen.kt | 114 +++++++++++++----- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 65f679735..66f7fe4ff 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -61,6 +61,7 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.theme.AppTextFieldDefaults @@ -68,27 +69,64 @@ import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.RestoreWalletUiState import to.bitkit.viewmodels.RestoreWalletViewModel @Composable fun RestoreWalletScreen( - viewModel: RestoreWalletViewModel = hiltViewModel(), onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, + modifier: Modifier = Modifier, + viewModel: RestoreWalletViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Content( + uiState = uiState, + onWordChanged = viewModel::onWordChanged, + onWordFocusChanged = viewModel::onWordFocusChanged, + onPassphraseChanged = viewModel::onPassphraseChanged, + onBackspaceInEmpty = viewModel::onBackspaceInEmpty, + onSuggestionSelected = viewModel::onSuggestionSelected, + onKeyboardDismissed = viewModel::onKeyboardDismissed, + onScrollCompleted = viewModel::onScrollCompleted, + onAdvanced = viewModel::onAdvancedClick, + onBack = onBackClick, + onRestore = onRestoreClick, + modifier = modifier, + ) +} + +@Composable +private fun Content( + uiState: RestoreWalletUiState, + modifier: Modifier = Modifier, + onWordChanged: (Int, String) -> Unit = { _, _ -> }, + onWordFocusChanged: (Int, Boolean) -> Unit = { _, _ -> }, + onAdvanced: () -> Unit = {}, + onPassphraseChanged: (String) -> Unit = {}, + onBackspaceInEmpty: (Int) -> Unit = {}, + onSuggestionSelected: (String) -> Unit = {}, + onKeyboardDismissed: () -> Unit = {}, + onScrollCompleted: () -> Unit = {}, + onBack: () -> Unit = {}, + onRestore: (mnemonic: String, passphrase: String?) -> Unit = { _, _ -> }, +) { val scrollState = rememberScrollState() val inputFieldPositions = remember { mutableMapOf() } - val focusRequesters = remember { List(24) { FocusRequester() } } + val focusRequesters = remember(uiState.wordCount) { List(uiState.wordCount) { FocusRequester() } } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current + val onPositionChanged = { index: Int, position: Int -> + inputFieldPositions[index] = position + } + LaunchedEffect(uiState.shouldDismissKeyboard) { if (uiState.shouldDismissKeyboard) { focusManager.clearFocus() keyboardController?.hide() - viewModel.onKeyboardDismissed() + onKeyboardDismissed() } } @@ -97,7 +135,7 @@ fun RestoreWalletScreen( inputFieldPositions[index]?.let { position -> scrollState.animateScrollTo(position) } - viewModel.onScrollCompleted() + onScrollCompleted() } } @@ -111,9 +149,10 @@ fun RestoreWalletScreen( topBar = { AppTopBar( titleText = null, - onBackClick = onBackClick, + onBackClick = onBack, ) - } + }, + modifier = modifier, ) { paddingValues -> Box( modifier = Modifier @@ -147,15 +186,15 @@ fun RestoreWalletScreen( label = "${index + 1}.", value = uiState.words[index], isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, - onValueChanged = { viewModel.onWordChanged(index, it) }, + onValueChanged = { onWordChanged(index, it) }, onFocusChanged = { focused -> - viewModel.onWordFocusChanged(index, focused) + onWordFocusChanged(index, focused) }, onPositionChanged = { position -> - inputFieldPositions[index] = position + onPositionChanged(index, position) }, onBackspaceInEmpty = { - viewModel.onBackspaceInEmpty(index) + onBackspaceInEmpty(index) }, focusRequester = focusRequesters[index], index = index, @@ -172,15 +211,15 @@ fun RestoreWalletScreen( label = "${index + 1}.", value = uiState.words[index], isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, - onValueChanged = { viewModel.onWordChanged(index, it) }, + onValueChanged = { onWordChanged(index, it) }, onFocusChanged = { focused -> - viewModel.onWordFocusChanged(index, focused) + onWordFocusChanged(index, focused) }, onPositionChanged = { position -> - inputFieldPositions[index] = position + onPositionChanged(index, position) }, onBackspaceInEmpty = { - viewModel.onBackspaceInEmpty(index) + onBackspaceInEmpty(index) }, focusRequester = focusRequesters[index], index = index, @@ -191,16 +230,10 @@ fun RestoreWalletScreen( // Passphrase if (uiState.showingPassphrase) { - OutlinedTextField( + TextInput( value = uiState.bip39Passphrase, - onValueChange = { viewModel.onPassphraseChanged(it) }, - placeholder = { - Text( - text = stringResource(R.string.onboarding__restore_passphrase_placeholder) - ) - }, - shape = RoundedCornerShape(8.dp), - colors = AppTextFieldDefaults.semiTransparent, + onValueChange = onPassphraseChanged, + placeholder = stringResource(R.string.onboarding__restore_passphrase_placeholder), singleLine = true, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, @@ -253,7 +286,7 @@ fun RestoreWalletScreen( AnimatedVisibility(visible = !uiState.showingPassphrase, modifier = Modifier.weight(1f)) { SecondaryButton( text = stringResource(R.string.onboarding__advanced), - onClick = { viewModel.onAdvancedClick() }, + onClick = { onAdvanced() }, enabled = uiState.areButtonsEnabled, modifier = Modifier .weight(1f) @@ -263,7 +296,7 @@ fun RestoreWalletScreen( PrimaryButton( text = stringResource(R.string.onboarding__restore), onClick = { - onRestoreClick(uiState.bip39Mnemonic, uiState.bip39Passphrase.takeIf { it.isNotEmpty() }) + onRestore(uiState.bip39Mnemonic, uiState.bip39Passphrase.takeIf { it.isNotEmpty() }) }, enabled = uiState.areButtonsEnabled, modifier = Modifier @@ -275,7 +308,7 @@ fun RestoreWalletScreen( SuggestionsRow( suggestions = uiState.suggestions, - onSelect = { viewModel.onSuggestionSelected(it) } + onSelect = { onSuggestionSelected(it) } ) } } @@ -398,9 +431,32 @@ fun MnemonicInputField( @Composable private fun Preview() { AppThemeSurface { - RestoreWalletScreen( - onBackClick = {}, - onRestoreClick = { _, _ -> }, + Content(uiState = RestoreWalletUiState()) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewAdvanced() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState( + showingPassphrase = true, + bip39Passphrase = "mypassphrase" + ) + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview24Words() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState( + is24Words = true, + words = List(24) { if (it < 20) "word${it + 1}" else "" } + ) ) } } From 1f97313c7a123520de13652c149dd8bfc49502d2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 17 Nov 2025 21:37:29 +0100 Subject: [PATCH 32/43] chore: lint also: chore: loosen lint for TooManyFunctions --- .../to/bitkit/repositories/ActivityRepo.kt | 2 +- .../ui/onboarding/RestoreWalletScreen.kt | 89 ++++++++----------- .../to/bitkit/usecases/WipeWalletUseCase.kt | 2 + .../viewmodels/ActivityListViewModel.kt | 10 ++- .../viewmodels/RestoreWalletViewModel.kt | 22 +++-- .../to/bitkit/viewmodels/WalletViewModel.kt | 1 - config/detekt/detekt.yml | 6 +- 7 files changed, 66 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 5e45e32e1..62dd15156 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -333,6 +333,7 @@ class ActivityRepo @Inject constructor( }.awaitAll() } + @Suppress("LongMethod") private suspend fun syncTagsMetadata(): Result = withContext(context = bgDispatcher) { runCatching { if (db.tagMetadataDao().getAll().isEmpty()) return@runCatching @@ -701,7 +702,6 @@ class ActivityRepo @Inject constructor( ) notifyActivitiesChanged() } - } // MARK: - Development/Testing Methods diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 66f7fe4ff..a3a2ece67 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -83,14 +84,14 @@ fun RestoreWalletScreen( Content( uiState = uiState, - onWordChanged = viewModel::onWordChanged, - onWordFocusChanged = viewModel::onWordFocusChanged, - onPassphraseChanged = viewModel::onPassphraseChanged, + onChangeWord = viewModel::onChangeWord, + onChangeWordFocus = viewModel::onChangeWordFocus, + onChangePassphrase = viewModel::onChangePassphrase, onBackspaceInEmpty = viewModel::onBackspaceInEmpty, - onSuggestionSelected = viewModel::onSuggestionSelected, - onKeyboardDismissed = viewModel::onKeyboardDismissed, - onScrollCompleted = viewModel::onScrollCompleted, - onAdvanced = viewModel::onAdvancedClick, + onSelectSuggestion = viewModel::onSelectSuggestion, + onKeyboardDismiss = viewModel::onKeyboardDismiss, + onScrollComplete = viewModel::onScrollComplete, + onAdvancedClick = viewModel::onAdvancedClick, onBack = onBackClick, onRestore = onRestoreClick, modifier = modifier, @@ -101,16 +102,16 @@ fun RestoreWalletScreen( private fun Content( uiState: RestoreWalletUiState, modifier: Modifier = Modifier, - onWordChanged: (Int, String) -> Unit = { _, _ -> }, - onWordFocusChanged: (Int, Boolean) -> Unit = { _, _ -> }, - onAdvanced: () -> Unit = {}, - onPassphraseChanged: (String) -> Unit = {}, + onChangeWord: (Int, String) -> Unit = { _, _ -> }, + onChangeWordFocus: (Int, Boolean) -> Unit = { _, _ -> }, + onChangePassphrase: (String) -> Unit = {}, onBackspaceInEmpty: (Int) -> Unit = {}, - onSuggestionSelected: (String) -> Unit = {}, - onKeyboardDismissed: () -> Unit = {}, - onScrollCompleted: () -> Unit = {}, - onBack: () -> Unit = {}, + onSelectSuggestion: (String) -> Unit = {}, + onKeyboardDismiss: () -> Unit = {}, + onScrollComplete: () -> Unit = {}, + onAdvancedClick: () -> Unit = {}, onRestore: (mnemonic: String, passphrase: String?) -> Unit = { _, _ -> }, + onBack: () -> Unit = {}, ) { val scrollState = rememberScrollState() val inputFieldPositions = remember { mutableMapOf() } @@ -118,15 +119,14 @@ private fun Content( val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val onPositionChanged = { index: Int, position: Int -> - inputFieldPositions[index] = position - } + val currentOnKeyboardDismiss by rememberUpdatedState(onKeyboardDismiss) + val currentOnScrollComplete by rememberUpdatedState(onScrollComplete) LaunchedEffect(uiState.shouldDismissKeyboard) { if (uiState.shouldDismissKeyboard) { focusManager.clearFocus() keyboardController?.hide() - onKeyboardDismissed() + currentOnKeyboardDismiss() } } @@ -135,7 +135,7 @@ private fun Content( inputFieldPositions[index]?.let { position -> scrollState.animateScrollTo(position) } - onScrollCompleted() + currentOnScrollComplete() } } @@ -186,16 +186,10 @@ private fun Content( label = "${index + 1}.", value = uiState.words[index], isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, - onValueChanged = { onWordChanged(index, it) }, - onFocusChanged = { focused -> - onWordFocusChanged(index, focused) - }, - onPositionChanged = { position -> - onPositionChanged(index, position) - }, - onBackspaceInEmpty = { - onBackspaceInEmpty(index) - }, + onValueChange = { onChangeWord(index, it) }, + onFocusChange = { focused -> onChangeWordFocus(index, focused) }, + onPositionChange = { position -> inputFieldPositions[index] = position }, + onBackspaceInEmpty = { onBackspaceInEmpty(index) }, focusRequester = focusRequesters[index], index = index, ) @@ -211,16 +205,10 @@ private fun Content( label = "${index + 1}.", value = uiState.words[index], isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, - onValueChanged = { onWordChanged(index, it) }, - onFocusChanged = { focused -> - onWordFocusChanged(index, focused) - }, - onPositionChanged = { position -> - onPositionChanged(index, position) - }, - onBackspaceInEmpty = { - onBackspaceInEmpty(index) - }, + onValueChange = { onChangeWord(index, it) }, + onFocusChange = { focused -> onChangeWordFocus(index, focused) }, + onPositionChange = { position -> inputFieldPositions[index] = position }, + onBackspaceInEmpty = { onBackspaceInEmpty(index) }, focusRequester = focusRequesters[index], index = index, ) @@ -232,7 +220,7 @@ private fun Content( if (uiState.showingPassphrase) { TextInput( value = uiState.bip39Passphrase, - onValueChange = onPassphraseChanged, + onValueChange = onChangePassphrase, placeholder = stringResource(R.string.onboarding__restore_passphrase_placeholder), singleLine = true, keyboardOptions = KeyboardOptions( @@ -286,7 +274,7 @@ private fun Content( AnimatedVisibility(visible = !uiState.showingPassphrase, modifier = Modifier.weight(1f)) { SecondaryButton( text = stringResource(R.string.onboarding__advanced), - onClick = { onAdvanced() }, + onClick = { onAdvancedClick() }, enabled = uiState.areButtonsEnabled, modifier = Modifier .weight(1f) @@ -308,7 +296,7 @@ private fun Content( SuggestionsRow( suggestions = uiState.suggestions, - onSelect = { onSuggestionSelected(it) } + onSelect = { onSelectSuggestion(it) } ) } } @@ -362,9 +350,9 @@ fun MnemonicInputField( label: String, isError: Boolean = false, value: String, - onValueChanged: (String) -> Unit, - onFocusChanged: (Boolean) -> Unit, - onPositionChanged: (Int) -> Unit, + onValueChange: (String) -> Unit, + onFocusChange: (Boolean) -> Unit, + onPositionChange: (Int) -> Unit, onBackspaceInEmpty: () -> Unit, focusRequester: FocusRequester, index: Int, @@ -383,7 +371,7 @@ fun MnemonicInputField( value = textFieldValue, onValueChange = { textFieldValue = it - onValueChanged(it.text) + onValueChange(it.text) }, textStyle = AppTextStyles.BodySSB, prefix = { @@ -417,12 +405,10 @@ fun MnemonicInputField( } } .testTag("Word-$index") - .onFocusChanged { focusState -> - onFocusChanged(focusState.isFocused) - } + .onFocusChanged { focusState -> onFocusChange(focusState.isFocused) } .onGloballyPositioned { coordinates -> val position = coordinates.positionInParent().y.toInt() * 2 // double the scroll to ensure enough space - onPositionChanged(position) + onPositionChange(position) } ) } @@ -452,6 +438,7 @@ private fun PreviewAdvanced() { @Composable private fun Preview24Words() { AppThemeSurface { + @Suppress("MagicNumber") Content( uiState = RestoreWalletUiState( is24Words = true, diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 39053593f..d4af42a38 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -13,6 +13,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +@Suppress("LongParameterList") @Singleton class WipeWalletUseCase @Inject constructor( private val backupRepo: BackupRepo, @@ -25,6 +26,7 @@ class WipeWalletUseCase @Inject constructor( private val activityRepo: ActivityRepo, private val lightningRepo: LightningRepo, ) { + @Suppress("TooGenericExceptionCaught") suspend operator fun invoke( walletIndex: Int = 0, resetWalletState: () -> Unit, diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 650e5ca62..bebd66a76 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -102,7 +102,7 @@ class ActivityListViewModel @Inject constructor( private suspend fun refreshActivityState() { val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() - _latestActivities.value = all.take(3) + _latestActivities.value = all.take(SIZE_LATEST) _lightningActivities.value = all.filter { it is Activity.Lightning } _onchainActivities.value = all.filter { it is Activity.Onchain } } @@ -138,7 +138,6 @@ class ActivityListViewModel @Inject constructor( } } - fun setDateRange(startDate: Long?, endDate: Long?) = _filters.update { it.copy(startDate = startDate, endDate = endDate) } @@ -159,8 +158,13 @@ class ActivityListViewModel @Inject constructor( private fun Flow.stateInScope( initialValue: T, - started: SharingStarted = SharingStarted.WhileSubscribed(5000), + started: SharingStarted = SharingStarted.WhileSubscribed(MS_TIMEOUT_SUB), ): StateFlow = stateIn(viewModelScope, started, initialValue) + + companion object { + private const val SIZE_LATEST = 3 + private const val MS_TIMEOUT_SUB = 5000L + } } data class ActivityFilters( diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index 9f81f2f5e..ed7fd0ded 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -23,7 +23,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { _uiState.update { it.copy(focusedIndex = 0) } } - fun onWordChanged(index: Int, value: String) { + fun onChangeWord(index: Int, value: String) { if (value.contains(" ")) { handlePastedWords(value) } else { @@ -33,7 +33,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } } - fun onWordFocusChanged(index: Int, focused: Boolean) { + fun onChangeWordFocus(index: Int, focused: Boolean) { if (focused) { _uiState.update { it.copy( @@ -52,7 +52,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } } - fun onSuggestionSelected(suggestion: String) { + fun onSelectSuggestion(suggestion: String) { _uiState.value.focusedIndex?.let { index -> updateWordValidity(index, suggestion) _uiState.update { it.copy(suggestions = emptyList()) } @@ -68,7 +68,7 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } } - fun onPassphraseChanged(passphrase: String) = _uiState.update { it.copy(bip39Passphrase = passphrase) } + fun onChangePassphrase(passphrase: String) = _uiState.update { it.copy(bip39Passphrase = passphrase) } fun onBackspaceInEmpty(index: Int) { if (index > 0) { @@ -76,9 +76,9 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { } } - fun onKeyboardDismissed() = _uiState.update { it.copy(shouldDismissKeyboard = false) } + fun onKeyboardDismiss() = _uiState.update { it.copy(shouldDismissKeyboard = false) } - fun onScrollCompleted() = _uiState.update { it.copy(scrollToFieldIndex = null) } + fun onScrollComplete() = _uiState.update { it.copy(scrollToFieldIndex = null) } private fun handlePastedWords(pastedText: String) { val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers @@ -183,8 +183,16 @@ data class RestoreWalletUiState( } private fun List.validBip39Checksum(): Boolean { - if (this.size != 12 && this.size != 24) return false + if (!MnemonicSize.isValid(this)) return false if (this.any { !isValidBip39Word(it) }) return false return runCatching { validateMnemonic(this.joinToString(" ")) }.isSuccess } + +private enum class MnemonicSize(val wordCount: Int) { + TWELVE(12), TWENTY_FOUR(24); + + companion object { + fun isValid(wordList: List): Boolean = entries.any { it.wordCount == wordList.size } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 1fe0ab99b..1f5a3e03d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -118,7 +118,6 @@ class WalletViewModel @Inject constructor( restoreState = RestoreState.BackupRestoreCompleted } - fun onRestoreContinue() { restoreState = RestoreState.NotRestoring } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 74528cab2..0450146ab 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -177,9 +177,9 @@ complexity: thresholdInInterfaces: 11 thresholdInObjects: 11 thresholdInEnums: 11 - ignoreDeprecated: false - ignorePrivate: false - ignoreOverridden: false + ignoreDeprecated: true + ignorePrivate: true + ignoreOverridden: true ignoreAnnotatedFunctions: ['Preview'] coroutines: From 99941302c96720c1408bda34af3ae898a330a100 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 00:17:06 +0100 Subject: [PATCH 33/43] refactor: extract bip39 service --- .../to/bitkit/services/core/Bip39Service.kt | 30 ++++++ .../ui/onboarding/RestoreWalletScreen.kt | 57 ++++++++++-- .../viewmodels/RestoreWalletViewModel.kt | 93 +++++++++++-------- 3 files changed, 131 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/to/bitkit/services/core/Bip39Service.kt diff --git a/app/src/main/java/to/bitkit/services/core/Bip39Service.kt b/app/src/main/java/to/bitkit/services/core/Bip39Service.kt new file mode 100644 index 000000000..4541fbf53 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/core/Bip39Service.kt @@ -0,0 +1,30 @@ +package to.bitkit.services.core + +import com.synonym.bitkitcore.getBip39Suggestions +import com.synonym.bitkitcore.isValidBip39Word +import to.bitkit.async.ServiceQueue +import javax.inject.Inject + +class Bip39Service @Inject constructor() { + suspend fun getSuggestions(input: String, count: UInt): List = ServiceQueue.CORE.background { + getBip39Suggestions(input, count) + } + + suspend fun isValidWord(word: String): Boolean = ServiceQueue.CORE.background { + isValidBip39Word(word) + } + + suspend fun validateMnemonic(mnemonic: String): Result = ServiceQueue.CORE.background { + runCatching { com.synonym.bitkitcore.validateMnemonic(mnemonic) } + } + + fun isValidMnemonicSize(wordList: List): Boolean = MnemonicSize.isValid(wordList) + + private enum class MnemonicSize(val wordCount: Int) { + TWELVE(12), TWENTY_FOUR(24); + + companion object { + fun isValid(wordList: List): Boolean = entries.any { it.wordCount == wordList.size } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index a3a2ece67..b30b5183c 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -84,6 +84,8 @@ fun RestoreWalletScreen( Content( uiState = uiState, + checksumErrorVisible = uiState.checksumErrorVisible, + areButtonsEnabled = uiState.areButtonsEnabled, onChangeWord = viewModel::onChangeWord, onChangeWordFocus = viewModel::onChangeWordFocus, onChangePassphrase = viewModel::onChangePassphrase, @@ -101,6 +103,8 @@ fun RestoreWalletScreen( @Composable private fun Content( uiState: RestoreWalletUiState, + checksumErrorVisible: Boolean, + areButtonsEnabled: Boolean, modifier: Modifier = Modifier, onChangeWord: (Int, String) -> Unit = { _, _ -> }, onChangeWordFocus: (Int, Boolean) -> Unit = { _, _ -> }, @@ -257,7 +261,7 @@ private fun Content( ) } - AnimatedVisibility(visible = uiState.checksumErrorVisible) { + AnimatedVisibility(visible = checksumErrorVisible) { BodyS( text = stringResource(R.string.onboarding__restore_inv_checksum), color = Colors.Red, @@ -275,7 +279,7 @@ private fun Content( SecondaryButton( text = stringResource(R.string.onboarding__advanced), onClick = { onAdvancedClick() }, - enabled = uiState.areButtonsEnabled, + enabled = areButtonsEnabled, modifier = Modifier .weight(1f) .testTag("AdvancedButton") @@ -286,7 +290,7 @@ private fun Content( onClick = { onRestore(uiState.bip39Mnemonic, uiState.bip39Passphrase.takeIf { it.isNotEmpty() }) }, - enabled = uiState.areButtonsEnabled, + enabled = areButtonsEnabled, modifier = Modifier .weight(1f) .testTag("RestoreButton") @@ -417,7 +421,11 @@ fun MnemonicInputField( @Composable private fun Preview() { AppThemeSurface { - Content(uiState = RestoreWalletUiState()) + Content( + uiState = RestoreWalletUiState(), + checksumErrorVisible = false, + areButtonsEnabled = false, + ) } } @@ -428,8 +436,39 @@ private fun PreviewAdvanced() { Content( uiState = RestoreWalletUiState( showingPassphrase = true, - bip39Passphrase = "mypassphrase" - ) + ), + checksumErrorVisible = false, + areButtonsEnabled = false, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewValid() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState( + words = List(12) { if (it % 2 == 0) "abandon" else "ability" }, + is24Words = false, + ), + checksumErrorVisible = false, + areButtonsEnabled = true, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewInvalid() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState( + words = List(12) { if (it % 2 == 0) "rock" else "roll" }, + is24Words = false, + ), + checksumErrorVisible = true, + areButtonsEnabled = false, ) } } @@ -442,8 +481,10 @@ private fun Preview24Words() { Content( uiState = RestoreWalletUiState( is24Words = true, - words = List(24) { if (it < 20) "word${it + 1}" else "" } - ) + words = List(24) { "word${it + 1}" } + ), + checksumErrorVisible = false, + areButtonsEnabled = false, ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index ed7fd0ded..e22219dee 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -1,26 +1,42 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel -import com.synonym.bitkitcore.getBip39Suggestions -import com.synonym.bitkitcore.isValidBip39Word -import com.synonym.bitkitcore.validateMnemonic +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.services.core.Bip39Service import javax.inject.Inject private const val WORDS_MIN = 12 private const val WORDS_MAX = 24 @HiltViewModel -class RestoreWalletViewModel @Inject constructor() : ViewModel() { +class RestoreWalletViewModel @Inject constructor( + private val bip39Service: Bip39Service, +) : ViewModel() { private val _uiState = MutableStateFlow(RestoreWalletUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { _uiState.update { it.copy(focusedIndex = 0) } + recomputeValidationState() + } + + private fun recomputeValidationState() = viewModelScope.launch { + val currentState = _uiState.value + val checksumError = currentState.isChecksumErrorVisible() + val buttonsEnabled = currentState.areButtonsEnabled() + + _uiState.update { + it.copy( + checksumErrorVisible = checksumError, + areButtonsEnabled = buttonsEnabled + ) + } } fun onChangeWord(index: Int, value: String) { @@ -80,14 +96,14 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { fun onScrollComplete() = _uiState.update { it.copy(scrollToFieldIndex = null) } - private fun handlePastedWords(pastedText: String) { + private fun handlePastedWords(pastedText: String) = viewModelScope.launch { val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers val pastedWords = pastedText .split(separators) .filter { it.isNotBlank() } if (pastedWords.size == WORDS_MIN || pastedWords.size == WORDS_MAX) { val invalidIndices = pastedWords.withIndex() - .filter { !isValidBip39Word(it.value) } + .filter { !bip39Service.isValidWord(it.value) } .map { it.index } .toSet() @@ -108,16 +124,17 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { suggestions = emptyList(), ) } + recomputeValidationState() } } - private fun updateWordValidity(index: Int, value: String) { + private fun updateWordValidity(index: Int, value: String) = viewModelScope.launch { val newWords = _uiState.value.words.toMutableList().apply { this[index] = value } val newInvalidIndices = _uiState.value.invalidWordIndices.toMutableSet() - if (!isValidBip39Word(value) && value.isNotEmpty()) { + if (!bip39Service.isValidWord(value) && value.isNotEmpty()) { newInvalidIndices.add(index) } else { newInvalidIndices.remove(index) @@ -129,15 +146,16 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { invalidWordIndices = newInvalidIndices, ) } + recomputeValidationState() } - private fun updateSuggestions(input: String, index: Int?) { + private fun updateSuggestions(input: String, index: Int?) = viewModelScope.launch { if (index == null || input.length < 2) { _uiState.update { it.copy(suggestions = emptyList()) } - return + return@launch } - val suggestions = getBip39Suggestions(input.lowercase(), 3u) + val suggestions = bip39Service.getSuggestions(input.lowercase(), 3u) val filtered = if (suggestions.size == 1 && suggestions.firstOrNull() == input) { emptyList() } else { @@ -146,6 +164,27 @@ class RestoreWalletViewModel @Inject constructor() : ViewModel() { _uiState.update { it.copy(suggestions = filtered) } } + + private suspend fun RestoreWalletUiState.areButtonsEnabled(): Boolean { + val activeWords = words.subList(0, wordCount) + return activeWords.none { it.isBlank() } && + invalidWordIndices.isEmpty() && + !isChecksumErrorVisible() + } + + private suspend fun RestoreWalletUiState.isChecksumErrorVisible(): Boolean { + val activeWords = words.subList(0, wordCount) + return activeWords.none { it.isBlank() } && + invalidWordIndices.isEmpty() && + !validateBip39Checksum(activeWords) + } + + private suspend fun validateBip39Checksum(wordList: List): Boolean { + if (!bip39Service.isValidMnemonicSize(wordList)) return false + if (wordList.any { !bip39Service.isValidWord(it) }) return false + + return bip39Service.validateMnemonic(wordList.joinToString(" ")).isSuccess + } } data class RestoreWalletUiState( @@ -158,41 +197,13 @@ data class RestoreWalletUiState( val is24Words: Boolean = false, val shouldDismissKeyboard: Boolean = false, val scrollToFieldIndex: Int? = null, + val checksumErrorVisible: Boolean = false, + val areButtonsEnabled: Boolean = false, ) { val wordCount: Int get() = if (is24Words) WORDS_MAX else WORDS_MIN val wordsPerColumn: Int get() = if (is24Words) WORDS_MIN else 6 - val checksumErrorVisible: Boolean - get() { - val activeWords = words.subList(0, wordCount) - return activeWords.none { it.isBlank() } && - invalidWordIndices.isEmpty() && - !activeWords.validBip39Checksum() - } - val bip39Mnemonic: String get() = words.subList(0, wordCount).joinToString(" ").trim() - val areButtonsEnabled: Boolean - get() { - val activeWords = words.subList(0, wordCount) - return activeWords.none { it.isBlank() } && - invalidWordIndices.isEmpty() && - !checksumErrorVisible - } -} - -private fun List.validBip39Checksum(): Boolean { - if (!MnemonicSize.isValid(this)) return false - if (this.any { !isValidBip39Word(it) }) return false - - return runCatching { validateMnemonic(this.joinToString(" ")) }.isSuccess -} - -private enum class MnemonicSize(val wordCount: Int) { - TWELVE(12), TWENTY_FOUR(24); - - companion object { - fun isValid(wordList: List): Boolean = entries.any { it.wordCount == wordList.size } - } } From d1f8e8ed0479054fd5a51c17c3922a090b5d7ba2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 00:17:24 +0100 Subject: [PATCH 34/43] test: restore screen viewmodel --- .../viewmodels/RestoreWalletViewModelTest.kt | 663 ++++++++++++++++++ 1 file changed, 663 insertions(+) create mode 100644 app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt new file mode 100644 index 000000000..83ada5bf1 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -0,0 +1,663 @@ +package to.bitkit.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.services.core.Bip39Service +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class RestoreWalletViewModelTest : BaseUnitTest() { + + private lateinit var viewModel: RestoreWalletViewModel + + private val bip39Service = mock() + + @Before + fun setup() = runBlocking { + whenever(bip39Service.isValidWord(any())).thenReturn(true) + whenever(bip39Service.getSuggestions(any(), any())).thenReturn(emptyList()) + whenever(bip39Service.isValidMnemonicSize(any())).thenReturn(true) + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + + viewModel = RestoreWalletViewModel(bip39Service) + } + + // region Initial State + + @Test + fun `initial state should have 24 empty word slots`() { + val state = viewModel.uiState.value + + assertEquals(24, state.words.size) + assertTrue(state.words.all { it.isEmpty() }) + } + + @Test + fun `initial state should have focused index 0`() { + val state = viewModel.uiState.value + + assertEquals(0, state.focusedIndex) + } + + @Test + fun `initial state should be in 12-word mode`() { + val state = viewModel.uiState.value + + assertFalse(state.is24Words) + assertEquals(12, state.wordCount) + } + + @Test + fun `initial state should have no suggestions`() { + val state = viewModel.uiState.value + + assertTrue(state.suggestions.isEmpty()) + } + + @Test + fun `initial state should have passphrase section hidden`() { + val state = viewModel.uiState.value + + assertFalse(state.showingPassphrase) + assertEquals("", state.bip39Passphrase) + } + + @Test + fun `initial state should have checksumErrorVisible false`() { + val state = viewModel.uiState.value + + assertFalse(state.checksumErrorVisible) + } + + @Test + fun `initial state should have areButtonsEnabled false`() { + val state = viewModel.uiState.value + + assertFalse(state.areButtonsEnabled) + } + + // endregion + + // region Word Input + + @Test + fun `onChangeWord should update word at correct index`() { + viewModel.onChangeWord(5, "abandon") + + val state = viewModel.uiState.value + assertEquals("abandon", state.words[5]) + } + + @Test + fun `onChangeWord should update scrollToFieldIndex`() { + viewModel.onChangeWord(7, "ability") + + val state = viewModel.uiState.value + assertEquals(7, state.scrollToFieldIndex) + } + + @Test + fun `onChangeWord should mark invalid words`() = runBlocking { + whenever(bip39Service.isValidWord("invalid_word")).thenReturn(false) + + viewModel.onChangeWord(3, "invalid_word") + + val state = viewModel.uiState.value + assertTrue(state.invalidWordIndices.contains(3)) + } + + @Test + fun `onChangeWord should clear invalid flag when word becomes valid`() = runBlocking { + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + whenever(bip39Service.isValidWord("valid")).thenReturn(true) + + viewModel.onChangeWord(2, "invalid") + assertTrue(viewModel.uiState.value.invalidWordIndices.contains(2)) + + viewModel.onChangeWord(2, "valid") + assertFalse(viewModel.uiState.value.invalidWordIndices.contains(2)) + } + + // endregion + + // region Paste Handling + + @Test + fun `handlePastedWords should parse 12 words separated by spaces`() { + val words = "abandon ability able about above absent absorb abstract absurd abuse access accident" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("abandon", state.words[0]) + assertEquals("ability", state.words[1]) + assertEquals("accident", state.words[11]) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 24 words separated by spaces`() { + val words = List(24) { "w${it + 1}" }.joinToString(" ") + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w24", state.words[23]) + assertTrue(state.is24Words) + } + + @Test + fun `handlePastedWords should handle multiple whitespace types`() { + val words = "w1\tw2\nw3 w4\t\nw5 w6 w7 w8 w9 w10 w11 w12" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w2", state.words[1]) + assertEquals("w3", state.words[2]) + } + + @Test + fun `handlePastedWords should clear excess slots when pasting 12 words`() { + // First manually set all 24 words + for (i in 0 until 24) { + viewModel.onChangeWord(i, "word$i") + } + + // Then paste 12 words + val words = "w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12" + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w12", state.words[11]) + assertEquals("", state.words[12]) + assertEquals("", state.words[23]) + } + + @Test + fun `handlePastedWords should detect invalid words`() = runBlocking { + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + whenever(bip39Service.isValidWord(any())).thenReturn(true) + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + + val words = List(12) { if (it == 2) "invalid" else "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertTrue(state.invalidWordIndices.contains(2)) + } + + @Test + fun `handlePastedWords should dismiss keyboard when all words valid`() { + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertTrue(state.shouldDismissKeyboard) + } + + @Test + fun `handlePastedWords should not dismiss keyboard when words invalid`() = runBlocking { + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + + val words = List(12) { if (it == 0) "invalid" else "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertFalse(state.shouldDismissKeyboard) + } + + // endregion + + // region Focus Management + + @Test + fun `onChangeWordFocus should set focused index when gaining focus`() { + viewModel.onChangeWordFocus(5, true) + + assertEquals(5, viewModel.uiState.value.focusedIndex) + } + + @Test + fun `onChangeWordFocus should clear focused index when losing focus`() { + viewModel.onChangeWordFocus(5, true) + viewModel.onChangeWordFocus(5, false) + + assertNull(viewModel.uiState.value.focusedIndex) + } + + @Test + fun `onChangeWordFocus should update scrollToFieldIndex`() { + viewModel.onChangeWordFocus(8, true) + + assertEquals(8, viewModel.uiState.value.scrollToFieldIndex) + } + + @Test + fun `onChangeWordFocus should clear suggestions when blurring`() { + viewModel.onChangeWordFocus(0, true) + viewModel.onChangeWordFocus(0, false) + + assertTrue(viewModel.uiState.value.suggestions.isEmpty()) + } + + // endregion + + // region Suggestions + + @Test + fun `updateSuggestions should return suggestions for valid input`() = runBlocking { + whenever(bip39Service.getSuggestions("aba", 3u)).thenReturn(listOf("abandon", "ability", "able")) + + viewModel.onChangeWordFocus(0, true) + viewModel.onChangeWord(0, "aba") + + val state = viewModel.uiState.value + assertEquals(3, state.suggestions.size) + } + + @Test + fun `updateSuggestions should filter exact matches`() = runBlocking { + whenever(bip39Service.getSuggestions("abandon", 3u)).thenReturn(listOf("abandon")) + + viewModel.onChangeWordFocus(0, true) + viewModel.onChangeWord(0, "abandon") + + val state = viewModel.uiState.value + assertTrue(state.suggestions.isEmpty()) + } + + @Test + fun `onSelectSuggestion should apply suggestion to focused word`() { + viewModel.onChangeWordFocus(0, true) + viewModel.onSelectSuggestion("abandon") + + assertEquals("abandon", viewModel.uiState.value.words[0]) + } + + @Test + fun `onSelectSuggestion should clear suggestions`() { + viewModel.onChangeWordFocus(0, true) + viewModel.onSelectSuggestion("abandon") + + assertTrue(viewModel.uiState.value.suggestions.isEmpty()) + } + + // endregion + + // region Passphrase Management + + @Test + fun `onAdvancedClick should toggle showingPassphrase`() { + assertFalse(viewModel.uiState.value.showingPassphrase) + + viewModel.onAdvancedClick() + assertTrue(viewModel.uiState.value.showingPassphrase) + + viewModel.onAdvancedClick() + assertFalse(viewModel.uiState.value.showingPassphrase) + } + + @Test + fun `onAdvancedClick should clear passphrase when toggling`() { + viewModel.onAdvancedClick() + viewModel.onChangePassphrase("test-passphrase") + + viewModel.onAdvancedClick() + + assertEquals("", viewModel.uiState.value.bip39Passphrase) + } + + @Test + fun `onChangePassphrase should update passphrase value`() { + viewModel.onChangePassphrase("my-passphrase-123") + + assertEquals("my-passphrase-123", viewModel.uiState.value.bip39Passphrase) + } + + // endregion + + // region Navigation + + @Test + fun `onBackspaceInEmpty should move focus to previous field`() { + viewModel.onBackspaceInEmpty(5) + + assertEquals(4, viewModel.uiState.value.focusedIndex) + } + + @Test + fun `onBackspaceInEmpty at index 0 should not change focus`() { + viewModel.onBackspaceInEmpty(0) + + assertEquals(0, viewModel.uiState.value.focusedIndex) + } + + // endregion + + // region UX Flags + + @Test + fun `onKeyboardDismiss should reset shouldDismissKeyboard flag`() { + viewModel.onKeyboardDismiss() + + assertFalse(viewModel.uiState.value.shouldDismissKeyboard) + } + + @Test + fun `onScrollComplete should reset scrollToFieldIndex`() { + viewModel.onScrollComplete() + + assertNull(viewModel.uiState.value.scrollToFieldIndex) + } + + // endregion + + // region Computed Properties + + @Test + fun `wordCount should return 12 when is24Words is false`() { + val state = viewModel.uiState.value + + assertFalse(state.is24Words) + assertEquals(12, state.wordCount) + } + + @Test + fun `wordCount should return 24 when is24Words is true`() { + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertTrue(state.is24Words) + assertEquals(24, state.wordCount) + } + + @Test + fun `wordsPerColumn should return correct values`() { + val state12 = viewModel.uiState.value + assertEquals(6, state12.wordsPerColumn) + + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state24 = viewModel.uiState.value + assertEquals(12, state24.wordsPerColumn) + } + + @Test + fun `areButtonsEnabled should be true with valid mnemonic`() { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be false with checksum error`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + // endregion + + // region Checksum Error Visibility + + @Test + fun `checksumErrorVisible should be true with 12 valid BIP39 words but invalid checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be true with 24 valid BIP39 words but invalid checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words24 = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words24) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false when correcting invalid checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.checksumErrorVisible) + + // Now fix the checksum by mocking success + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + viewModel.onChangeWord(11, "corrected") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false with incomplete mnemonic`() { + for (i in 0 until 6) { + viewModel.onChangeWord(i, "word$i") + } + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false when invalid BIP39 words present`() = runBlocking { + whenever(bip39Service.isValidWord("invalidword")).thenReturn(false) + + for (i in 0 until 11) { + viewModel.onChangeWord(i, "word$i") + } + viewModel.onChangeWord(11, "invalidword") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be true after pasting 12 words with bad checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be true after pasting 24 words with bad checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false after pasting valid mnemonic`() { + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + // endregion + + // region Buttons Enabled Reactive Updates + + @Test + fun `areButtonsEnabled should remain false during progressive word entry`() { + for (i in 0 until 6) { + viewModel.onChangeWord(i, "word$i") + } + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be true after entering all 12 valid words`() { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be true after entering all 24 valid words`() { + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be false when changing valid word to invalid`() = runBlocking { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.isValidWord("badword")).thenReturn(false) + viewModel.onChangeWord(5, "badword") + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be false when valid word causes checksum error`() = runBlocking { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + viewModel.onChangeWord(11, "different") + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be true after pasting valid 12-word mnemonic`() { + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + // endregion + + // region Correction Flows + + @Test + fun `correction flow - invalid words to checksum error to corrected to enabled`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.checksumErrorVisible) + assertFalse(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + viewModel.onChangeWord(11, "corrected") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `correction flow - paste invalid mnemonic then correct individual words`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + assertFalse(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + viewModel.onChangeWord(0, "fixed") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `correction flow - invalid BIP39 word to valid word enables buttons`() = runBlocking { + whenever(bip39Service.isValidWord("badword")).thenReturn(false) + + for (i in 0 until 11) { + viewModel.onChangeWord(i, "word$i") + } + viewModel.onChangeWord(11, "badword") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertFalse(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.isValidWord("goodword")).thenReturn(true) + viewModel.onChangeWord(11, "goodword") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + // endregion + + // region State Consistency + + @Test + fun `buttons should always be disabled when checksum error visible`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + val state = viewModel.uiState.value + assertTrue(state.checksumErrorVisible) + assertFalse(state.areButtonsEnabled) + } + + @Test + fun `buttons should always be disabled when invalid BIP39 words present`() = runBlocking { + whenever(bip39Service.isValidWord("invalidword")).thenReturn(false) + + for (i in 0 until 11) { + viewModel.onChangeWord(i, "word$i") + } + viewModel.onChangeWord(11, "invalidword") + + val state = viewModel.uiState.value + assertFalse(state.checksumErrorVisible) + assertFalse(state.areButtonsEnabled) + assertTrue(state.invalidWordIndices.contains(11)) + } + + // endregion +} From 3ebd9d5d21d756d1708b6e55c6b35539cd21bbdf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 02:28:53 +0100 Subject: [PATCH 35/43] feat: backup relative dates --- app/build.gradle.kts | 5 +- app/src/main/java/to/bitkit/env/Env.kt | 1 + app/src/main/java/to/bitkit/ext/DateTime.kt | 77 +++++++++- .../ui/settings/BackupSettingsScreen.kt | 15 +- app/src/main/res/values/strings.xml | 2 + .../java/to/bitkit/ext/DateTimeExtTest.kt | 140 ++++++++++++++++++ 6 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2574afff7..65e0f0ce8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,6 +34,8 @@ val keystoreProperties by lazy { keystoreProperties } +val locales = listOf("en", "ar", "ca", "cs", "de", "el", "es", "fr", "it", "nl", "pl", "pt", "ru") + android { namespace = "to.bitkit" compileSdk = 35 @@ -49,6 +51,7 @@ android { } buildConfigField("boolean", "E2E", System.getenv("E2E")?.toBoolean()?.toString() ?: "false") buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true") + buildConfigField("String", "LOCALES", "\"${locales.joinToString(",")}\"") } flavorDimensions += "network" @@ -131,7 +134,7 @@ android { } androidResources { @Suppress("UnstableApiUsage") - localeFilters.addAll(listOf("en", "ar", "ca", "cs", "de", "el", "es", "fr", "it", "nl", "pl", "pt", "ru")) + localeFilters.addAll(locales) @Suppress("UnstableApiUsage") generateLocaleConfig = true } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 6a61de412..47a96b86f 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -18,6 +18,7 @@ internal object Env { const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO val network = Network.valueOf(BuildConfig.NETWORK) + val locales = BuildConfig.LOCALES.split(",") val walletSyncIntervalSecs = 10_uL // TODO review val platform = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" const val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index e67045cd1..6cb3df256 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -1,8 +1,10 @@ -@file:Suppress("TooManyFunctions") +@file:Suppress("TooManyFunctions", "MagicNumber") package to.bitkit.ext import android.icu.text.DateFormat +import android.icu.text.DisplayContext +import android.icu.text.RelativeDateTimeFormatter import android.icu.util.ULocale import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate @@ -48,9 +50,82 @@ fun Long.toDateUTC(): String { fun Long.toLocalizedTimestamp(): String { val uLocale = ULocale.forLocale(Locale.US) val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, uLocale) + ?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.US).format(Date(this)) return formatter.format(Date(this)) } +@Suppress("LongMethod") +fun Long.toRelativeTimeString( + locale: Locale = Locale.getDefault(), + clock: Clock = Clock.System, +): String { + val now = nowMillis(clock) + val diffMillis = now - this + + val formatter = RelativeDateTimeFormatter.getInstance( + ULocale.forLocale(locale), + null, + RelativeDateTimeFormatter.Style.LONG, + DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE, + ) ?: return toLocalizedTimestamp() + + val seconds = diffMillis / 1000.0 + val minutes = (seconds / 60.0).toInt() + val hours = (minutes / 60.0).toInt() + val days = (hours / 24.0).toInt() + val weeks = (days / 7.0).toInt() + val months = (days / 30.0).toInt() + val years = (days / 365.0).toInt() + + return when { + seconds < 60 -> formatter.format( + RelativeDateTimeFormatter.Direction.PLAIN, + RelativeDateTimeFormatter.AbsoluteUnit.NOW, + ) + + minutes < 60 -> formatter.format( + minutes.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.MINUTES, + ) + + hours < 24 -> formatter.format( + hours.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.HOURS, + ) + + days < 2 -> formatter.format( + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.AbsoluteUnit.DAY, + ) + + days < 7 -> formatter.format( + days.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.DAYS, + ) + + weeks < 4 -> formatter.format( + weeks.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.WEEKS, + ) + + months < 12 -> formatter.format( + months.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.MONTHS, + ) + + else -> formatter.format( + years.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.YEARS, + ) + } +} + fun getDaysInMonth(month: LocalDate): List { val firstDayOfMonth = LocalDate(month.year, month.month, CalendarConstants.FIRST_DAY_OF_MONTH) val daysInMonth = month.month.length(isLeapYear(month.year)) diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index bf4a1e942..99962eafa 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -28,7 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R import to.bitkit.env.Env -import to.bitkit.ext.toLocalizedTimestamp +import to.bitkit.ext.toRelativeTimeString import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.ui.Routes @@ -156,13 +156,18 @@ private fun BackupStatusItem( ) { val status = uiState.status + val timeString = if (status.synced == 0L) { + stringResource(R.string.common__never) + } else { + status.synced.toRelativeTimeString() + } + val subtitle = when { - status.running -> "Running" // TODO add missing localized text + status.running -> stringResource(R.string.settings__backup__status_running) !status.isRequired -> stringResource(R.string.settings__backup__status_success) - .replace("{time}", status.synced.toLocalizedTimestamp()) - + .replace("{time}", timeString) else -> stringResource(R.string.settings__backup__status_failed) - .replace("{time}", status.synced.toLocalizedTimestamp()) + .replace("{time}", timeString) } Row( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0752b9d8..f1f09c921 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ No Back Learn More + Never Understood Connect Min @@ -625,6 +626,7 @@ Data Backup Failure Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}. latest data backups + Running Failed Backup: {time} Latest Backup: {time} Connections diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt new file mode 100644 index 000000000..3fcd5408e --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -0,0 +1,140 @@ +package to.bitkit.ext + +import org.junit.Test +import to.bitkit.env.Env +import to.bitkit.test.BaseUnitTest +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DateTimeExtTest : BaseUnitTest() { + + @Test + fun `toRelativeTimeString returns now for very recent timestamps`() { + val now = System.currentTimeMillis() + val result = now.toRelativeTimeString() + // May return "now" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns minutes ago for recent timestamps`() { + val fiveMinutesAgo = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5) + val result = fiveMinutesAgo.toRelativeTimeString() + // May return relative "minute" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns hours ago for timestamps within a day`() { + val twoHoursAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) + val result = twoHoursAgo.toRelativeTimeString() + // May return relative "hour" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns yesterday for one day ago`() { + val oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1) + val result = oneDayAgo.toRelativeTimeString() + // May return relative "yesterday"/"day" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns days ago for multiple days`() { + val threeDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(3) + val result = threeDaysAgo.toRelativeTimeString() + // May return relative "day" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns weeks ago for multiple weeks`() { + val twoWeeksAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(14) + val result = twoWeeksAgo.toRelativeTimeString() + // May return relative "week" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns months ago for multiple months`() { + val twoMonthsAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60) + val result = twoMonthsAgo.toRelativeTimeString() + // May return relative "month" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns years ago for multiple years`() { + val twoYearsAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(730) + val result = twoYearsAgo.toRelativeTimeString() + // May return relative "year" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString handles future timestamps gracefully`() { + val future = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1) + val result = future.toRelativeTimeString() + // May return relative "in" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString supports all configured locales`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + + Env.locales.forEach { languageTag -> + val locale = Locale.forLanguageTag(languageTag) + val result = twoDaysAgo.toRelativeTimeString(locale) + + assertNotNull(result, "Locale $languageTag returned null") + assertTrue(result.isNotEmpty(), "Locale $languageTag returned empty string") + } + } + + @Test + fun `toRelativeTimeString with explicit English locale produces expected output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.ENGLISH) + + // May return relative "day" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString with explicit German locale produces non-empty output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.GERMAN) + + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString with explicit French locale produces non-empty output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.FRENCH) + + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString with explicit Italian locale produces non-empty output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.ITALIAN) + + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString preserves backward compatibility with default locale`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val resultWithoutParam = twoDaysAgo.toRelativeTimeString() + val resultWithDefaultParam = twoDaysAgo.toRelativeTimeString(Locale.getDefault()) + + assertEquals(resultWithDefaultParam, resultWithoutParam) + } +} From 59d9b480b5c85f98d6e0d57d6780eb11c81b3aec Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 02:32:50 +0100 Subject: [PATCH 36/43] chore: lint --- app/detekt-baseline.xml | 47 --------- app/src/main/java/to/bitkit/ext/DateTime.kt | 98 ++++++++++--------- .../activity/DateRangeSelectorSheet.kt | 2 +- .../viewmodels/RestoreWalletViewModel.kt | 4 +- 4 files changed, 56 insertions(+), 95 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index a130bdd36..56314ea81 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -64,7 +64,6 @@ ConstructorParameterNaming:AddressChecker.kt$TxStatus$val block_height: Int? = null ConstructorParameterNaming:AddressChecker.kt$TxStatus$val block_time: Long? = null CyclomaticComplexMethod:ActivityDetailScreen.kt$@Composable private fun ActivityDetailContent( item: Activity, tags: List<String>, onRemoveTag: (String) -> Unit, onAddTagClick: () -> Unit, onClickBoost: () -> Unit, onExploreClick: (String) -> Unit, onCopy: (String) -> Unit, ) - CyclomaticComplexMethod:ActivityIcon.kt$@Composable fun ActivityIcon( activity: Activity, size: Dp = 32.dp, modifier: Modifier = Modifier, ) CyclomaticComplexMethod:ActivityListGrouped.kt$private fun groupActivityItems(activityItems: List<Activity>): List<Any> CyclomaticComplexMethod:ActivityRow.kt$@Composable fun ActivityRow( item: Activity, onClick: (String) -> Unit, testTag: String, ) CyclomaticComplexMethod:ActivityRow.kt$@Composable private fun TransactionStatusText( txType: PaymentType, isLightning: Boolean, status: PaymentState?, isTransfer: Boolean, ) @@ -79,8 +78,6 @@ CyclomaticComplexMethod:HealthRepo.kt$HealthRepo$private fun collectState() CyclomaticComplexMethod:HomeScreen.kt$@Composable fun HomeScreen( mainUiState: MainUiState, drawerState: DrawerState, rootNavController: NavController, walletNavController: NavHostController, settingsViewModel: SettingsViewModel, walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, homeViewModel: HomeViewModel = hiltViewModel(), ) CyclomaticComplexMethod:LightningService.kt$LightningService$private fun logEvent(event: Event) - CyclomaticComplexMethod:ReceiveQrScreen.kt$@Composable fun ReceiveQrScreen( cjitInvoice: MutableState<String?>, cjitActive: MutableState<Boolean>, walletState: MainUiState, onCjitToggle: (Boolean) -> Unit, onClickEditInvoice: () -> Unit, onClickReceiveOnSpending: () -> Unit, modifier: Modifier = Modifier, ) - CyclomaticComplexMethod:RestoreWalletScreen.kt$@Composable fun RestoreWalletView( onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, ) CyclomaticComplexMethod:SendSheet.kt$@Composable fun SendSheet( appViewModel: AppViewModel, walletViewModel: WalletViewModel, startDestination: SendRoute = SendRoute.Recipient, ) CyclomaticComplexMethod:SettingsButtonRow.kt$@Composable fun SettingsButtonRow( title: String, modifier: Modifier = Modifier, subtitle: String? = null, value: SettingsButtonValue = SettingsButtonValue.None, description: String? = null, iconRes: Int? = null, iconTint: Color = Color.Unspecified, iconSize: Dp = 32.dp, maxLinesSubtitle: Int = Int.MAX_VALUE, enabled: Boolean = true, loading: Boolean = false, onClick: () -> Unit, ) CyclomaticComplexMethod:Slider.kt$@Composable fun StepSlider( value: Int, steps: List<Int>, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, ) @@ -100,10 +97,7 @@ EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$orderPaymentConfirmed EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$wakeToTimeout ForbiddenComment:ActivityDetailScreen.kt$/* TODO: Implement assign functionality */ - ForbiddenComment:ActivityListViewModel.kt$ActivityListViewModel$// TODO: sync only on specific events for better performance ForbiddenComment:ActivityRow.kt$// TODO: calculate confirmsIn text - ForbiddenComment:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$// TODO: get from actual repository state - ForbiddenComment:BackupRepo.kt$BackupRepo$// TODO: Add other backup categories as they get implemented: ForbiddenComment:BoostTransactionViewModel.kt$BoostTransactionUiState$// TODO: Implement dynamic time estimation ForbiddenComment:ChannelStatusView.kt$// TODO: handle closed channels marking & detection ForbiddenComment:ContentView.kt$// TODO: display as sheet @@ -151,7 +145,6 @@ LambdaParameterInRestartableEffect:SpendingAmountScreen.kt$toastException LargeClass:AppViewModel.kt$AppViewModel : ViewModel LargeClass:LightningRepo.kt$LightningRepo - LongMethod:ActivityRepo.kt$ActivityRepo$private suspend fun syncTagsMetadata() LongMethod:AppViewModel.kt$AppViewModel$private fun observeLdkNodeEvents() LongMethod:AppViewModel.kt$AppViewModel$private suspend fun proceedWithPayment() LongMethod:ContentView.kt$private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, currencyViewModel: CurrencyViewModel, ) @@ -176,8 +169,6 @@ MagicNumber:AddressViewerScreen.kt$250000L MagicNumber:AddressViewerScreen.kt$50000L MagicNumber:AddressViewerViewModel.kt$AddressViewerViewModel$300 - MagicNumber:AllActivityScreen.kt$0xFF161616 - MagicNumber:AllActivityScreen.kt$0xFF1e1e1e MagicNumber:AppStatus.kt$0.4f MagicNumber:AppViewModel.kt$AppViewModel$1000 MagicNumber:AppViewModel.kt$AppViewModel$250 @@ -187,16 +178,11 @@ MagicNumber:ArticleModel.kt$60 MagicNumber:AutoReadClipboardHandler.kt$1000 MagicNumber:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$200 - MagicNumber:BackupRepo.kt$BackupRepo$60000 MagicNumber:BackupsViewModel.kt$BackupsViewModel$500 MagicNumber:BiometricsView.kt$5 - MagicNumber:Bip39Utils.kt$12 - MagicNumber:Bip39Utils.kt$24 - MagicNumber:Bip39Utils.kt$8 MagicNumber:ChangePinConfirmScreen.kt$500 MagicNumber:ChannelDetailScreen.kt$1.5f MagicNumber:ChannelOrdersScreen.kt$10 - MagicNumber:ChannelOrdersScreen.kt$100 MagicNumber:ConfirmMnemonicScreen.kt$300 MagicNumber:ContentView.kt$100 MagicNumber:ContentView.kt$500 @@ -215,7 +201,6 @@ MagicNumber:InitializingWalletView.kt$500 MagicNumber:InitializingWalletView.kt$99.9 MagicNumber:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$500 - MagicNumber:Logger.kt$Logger$4 MagicNumber:NewsService.kt$NewsService$10 MagicNumber:OnboardingSlidesScreen.kt$3 MagicNumber:OnboardingSlidesScreen.kt$4 @@ -232,7 +217,6 @@ MagicNumber:ReceiveQrScreen.kt$17.33f MagicNumber:ReceiveQrScreen.kt$32 MagicNumber:RestoreWalletScreen.kt$12 - MagicNumber:RestoreWalletScreen.kt$24 MagicNumber:SavingsConfirmScreen.kt$300 MagicNumber:SavingsProgressScreen.kt$2500 MagicNumber:SavingsProgressScreen.kt$5000 @@ -265,18 +249,6 @@ MatchingDeclarationName:SavingsProgressScreen.kt$SavingsProgressState MatchingDeclarationName:SettingsButtonRow.kt$SettingsButtonValue MaxLineLength:ActivityDetailScreen.kt$description = "Unable to increase the fee any further. Otherwise, it will exceed half the current input balance" - MaxLineLength:Bip39Test.kt$Bip39Test$"AbAnDoN abandon ABANDON abandon abandon abandon abandon abandon abandon abandon abandon about".toWordList().validBip39Checksum() - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" to true - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false - MaxLineLength:Bip39Test.kt$Bip39Test$"dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic" to true - MaxLineLength:Bip39Test.kt$Bip39Test$"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title" to true - MaxLineLength:Bip39Test.kt$Bip39Test$"letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless" to true - MaxLineLength:Bip39Test.kt$Bip39Test$val validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - MaxLineLength:Bip39Utils.kt$setOf("abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance", "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become", "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog", "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk", "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify", "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", "coil", "coin", "collect", "color", "column", "combine", "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper", "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay", "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk", "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide", "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft", "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil", "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit", "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint", "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault", "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field", "figure", "file", "film", "filter", "final", "find", "fine", "finger", "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly", "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil", "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel", "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment", "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle", "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue", "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass", "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun", "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital", "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose", "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial", "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest", "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel", "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup", "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law", "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend", "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", "life", "lift", "light", "like", "limb", "limit", "link", "lion", "liquid", "list", "little", "live", "lizard", "load", "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop", "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet", "maid", "mail", "main", "major", "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin", "marine", "market", "marriage", "mask", "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure", "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message", "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake", "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning", "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music", "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative", "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", "noble", "noise", "nominee", "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey", "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", "often", "oil", "okay", "old", "olive", "olympic", "omit", "once", "one", "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit", "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over", "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper", "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut", "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical", "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet", "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare", "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", "prison", "private", "prize", "problem", "process", "produce", "profit", "program", "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil", "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz", "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid", "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle", "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove", "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire", "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid", "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room", "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness", "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say", "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea", "search", "season", "seat", "second", "secret", "section", "security", "seed", "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence", "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine", "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side", "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", "sister", "situate", "six", "size", "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan", "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social", "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject", "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim", "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", "tennis", "tent", "term", "test", "text", "thank", "that", "theme", "then", "theory", "there", "they", "thing", "this", "thought", "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger", "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token", "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist", "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree", "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try", "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical", "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown", "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", "usage", "use", "used", "useful", "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle", "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view", "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote", "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave", "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat", "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", "winner", "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo") MaxLineLength:BlocksEditScreen.kt$enabled = blocksPreferences.run { showBlock || showTime || showDate || showTransactions || showSize || showSource } MaxLineLength:BlocktankRegtestScreen.kt$"Initiating channel close with fundingTxId: $fundingTxId, vout: $vout, forceCloseAfter: $forceCloseAfter" MaxLineLength:BlocktankRepo.kt$BlocktankRepo$"Buying channel with lspBalanceSat: $receivingBalanceSats, channelExpiryWeeks: $channelExpiryWeeks, options: $options" @@ -302,8 +274,6 @@ MaxLineLength:SettingsScreen.kt$if (newValue) R.string.settings__dev_enabled_message else R.string.settings__dev_disabled_message MaxLineLength:WeatherService.kt$WeatherService$val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE MaximumLineLength:ActivityDetailScreen.kt$ - MaximumLineLength:Bip39Test.kt$Bip39Test$ - MaximumLineLength:Bip39Utils.kt$ MaximumLineLength:BlocksEditScreen.kt$ MaximumLineLength:BlocktankRegtestScreen.kt$ MaximumLineLength:BlocktankRepo.kt$BlocktankRepo$ @@ -371,7 +341,6 @@ ModifierMissing:ReportIssueResultScreen.kt$ReportIssueResultScreen ModifierMissing:ReportIssueScreen.kt$ReportIssueContent ModifierMissing:RestoreWalletScreen.kt$MnemonicInputField - ModifierMissing:RestoreWalletScreen.kt$RestoreWalletView ModifierMissing:SavingsAdvancedScreen.kt$ChannelItem ModifierMissing:SavingsAvailabilityScreen.kt$SavingsAvailabilityScreen ModifierMissing:SavingsIntroScreen.kt$SavingsIntroScreen @@ -429,8 +398,6 @@ ParameterNaming:PinConfirmScreen.kt$onPinConfirmed ParameterNaming:QrCodeImage.kt$onBitmapGenerated ParameterNaming:ReceiveAmountScreen.kt$onCjitCreated - ParameterNaming:RestoreWalletScreen.kt$onPositionChanged - ParameterNaming:RestoreWalletScreen.kt$onValueChanged ParameterNaming:SpendingAdvancedScreen.kt$onOrderCreated ParameterNaming:SpendingAmountScreen.kt$onOrderCreated ParameterNaming:TransactionSpeedSettingsScreen.kt$onSpeedSelected @@ -440,18 +407,14 @@ PreviewPublic:EmptyWalletView.kt$EmptyStateViewPreview PreviewPublic:HighlightLabel.kt$FlexibleLogoPreview PreviewPublic:HighlightLabel.kt$LongTextLogoPreview - PreviewPublic:RestoreWalletScreen.kt$RestoreWalletViewPreview PreviewPublic:SplashScreen.kt$SplashScreenPreview PrintStackTrace:ShareSheet.kt$e ReturnCount:AppViewModel.kt$AppViewModel$private suspend fun handleSanityChecks(amountSats: ULong) - ReturnCount:ChannelStatusView.kt$@Composable private fun getStatusInfo( channel: ChannelUi, blocktankOrder: IBtOrder?, ): StatusInfo ReturnCount:FcmService.kt$FcmService$private fun decryptPayload(response: EncryptedNotification) ReturnCount:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$private fun findUpdatedChannel( currentChannel: ChannelDetails, allChannels: List<ChannelDetails>, ): ChannelDetails? - SpreadOperator:RestoreWalletScreen.kt$(*Array(24) { "" }) SwallowedException:Crypto.kt$Crypto$e: Exception TooGenericExceptionCaught:ActivityDetailViewModel.kt$ActivityDetailViewModel$e: Exception TooGenericExceptionCaught:ActivityDetailViewModel.kt$ActivityDetailViewModel$e: Throwable - TooGenericExceptionCaught:ActivityListViewModel.kt$ActivityListViewModel$e: Exception TooGenericExceptionCaught:ActivityRepo.kt$ActivityRepo$e: Exception TooGenericExceptionCaught:AddressChecker.kt$AddressChecker$e: Exception TooGenericExceptionCaught:AppViewModel.kt$AppViewModel$e: Exception @@ -473,7 +436,6 @@ TooGenericExceptionCaught:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$e: Exception TooGenericExceptionCaught:LightningRepo.kt$LightningRepo$e: Throwable TooGenericExceptionCaught:LightningService.kt$LightningService$e: Exception - TooGenericExceptionCaught:Logger.kt$Logger$e: Throwable TooGenericExceptionCaught:LogsRepo.kt$LogsRepo$e: Exception TooGenericExceptionCaught:LogsViewModel.kt$LogsViewModel$e: Exception TooGenericExceptionCaught:NewTransactionSheetDetails.kt$NewTransactionSheetDetails.Companion$e: Exception @@ -493,27 +455,18 @@ TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("HTTP error: ${response.status}") TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("LNURL channel error: ${parsedResponse.reason}") TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("LNURL error: ${withdrawResponse.reason}") - TooManyFunctions:ActivityListViewModel.kt$ActivityListViewModel : ViewModel TooManyFunctions:ActivityRepo.kt$ActivityRepo TooManyFunctions:AppViewModel.kt$AppViewModel : ViewModel TooManyFunctions:BackupNavSheetViewModel.kt$BackupNavSheetViewModel : ViewModel - TooManyFunctions:BackupRepo.kt$BackupRepo TooManyFunctions:BlocktankRepo.kt$BlocktankRepo - TooManyFunctions:BoostTransactionViewModel.kt$BoostTransactionViewModel : ViewModel TooManyFunctions:CacheStore.kt$CacheStore - TooManyFunctions:ChannelOrdersScreen.kt$to.bitkit.ui.settings.ChannelOrdersScreen.kt TooManyFunctions:ContentView.kt$to.bitkit.ui.ContentView.kt TooManyFunctions:CoreService.kt$ActivityService TooManyFunctions:CoreService.kt$BlocktankService TooManyFunctions:DevSettingsViewModel.kt$DevSettingsViewModel : ViewModel - TooManyFunctions:ElectrumConfigViewModel.kt$ElectrumConfigViewModel : ViewModel - TooManyFunctions:HomeViewModel.kt$HomeViewModel : ViewModel - TooManyFunctions:LightningConnectionsViewModel.kt$LightningConnectionsViewModel : ViewModel TooManyFunctions:LightningRepo.kt$LightningRepo TooManyFunctions:LightningService.kt$LightningService : BaseCoroutineScope - TooManyFunctions:Logger.kt$Logger TooManyFunctions:SettingsViewModel.kt$SettingsViewModel : ViewModel - TooManyFunctions:TOS.kt$to.bitkit.ui.onboarding.TOS.kt TooManyFunctions:TagMetadataDao.kt$TagMetadataDao TooManyFunctions:Text.kt$to.bitkit.ui.components.Text.kt TooManyFunctions:TransferViewModel.kt$TransferViewModel : ViewModel diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 6cb3df256..db306ec46 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -1,4 +1,4 @@ -@file:Suppress("TooManyFunctions", "MagicNumber") +@file:Suppress("TooManyFunctions") package to.bitkit.ext @@ -69,50 +69,50 @@ fun Long.toRelativeTimeString( DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE, ) ?: return toLocalizedTimestamp() - val seconds = diffMillis / 1000.0 - val minutes = (seconds / 60.0).toInt() - val hours = (minutes / 60.0).toInt() - val days = (hours / 24.0).toInt() - val weeks = (days / 7.0).toInt() - val months = (days / 30.0).toInt() - val years = (days / 365.0).toInt() + val seconds = diffMillis / Constants.MILLIS_TO_SECONDS + val minutes = (seconds / Constants.SECONDS_TO_MINUTES).toInt() + val hours = (minutes / Constants.MINUTES_TO_HOURS).toInt() + val days = (hours / Constants.HOURS_TO_DAYS).toInt() + val weeks = (days / Constants.DAYS_TO_WEEKS).toInt() + val months = (days / Constants.DAYS_TO_MONTHS).toInt() + val years = (days / Constants.DAYS_TO_YEARS).toInt() return when { - seconds < 60 -> formatter.format( + seconds < Constants.SECONDS_THRESHOLD -> formatter.format( RelativeDateTimeFormatter.Direction.PLAIN, RelativeDateTimeFormatter.AbsoluteUnit.NOW, ) - minutes < 60 -> formatter.format( + minutes < Constants.MINUTES_THRESHOLD -> formatter.format( minutes.toDouble(), RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.RelativeUnit.MINUTES, ) - hours < 24 -> formatter.format( + hours < Constants.HOURS_THRESHOLD -> formatter.format( hours.toDouble(), RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.RelativeUnit.HOURS, ) - days < 2 -> formatter.format( + days < Constants.YESTERDAY_THRESHOLD -> formatter.format( RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.AbsoluteUnit.DAY, ) - days < 7 -> formatter.format( + days < Constants.DAYS_THRESHOLD -> formatter.format( days.toDouble(), RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.RelativeUnit.DAYS, ) - weeks < 4 -> formatter.format( + weeks < Constants.WEEKS_THRESHOLD -> formatter.format( weeks.toDouble(), RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.RelativeUnit.WEEKS, ) - months < 12 -> formatter.format( + months < Constants.MONTHS_THRESHOLD -> formatter.format( months.toDouble(), RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.RelativeUnit.MONTHS, @@ -127,7 +127,7 @@ fun Long.toRelativeTimeString( } fun getDaysInMonth(month: LocalDate): List { - val firstDayOfMonth = LocalDate(month.year, month.month, CalendarConstants.FIRST_DAY_OF_MONTH) + val firstDayOfMonth = LocalDate(month.year, month.month, Constants.FIRST_DAY_OF_MONTH) val daysInMonth = month.month.length(isLeapYear(month.year)) // Get the day of week for the first day (1 = Monday, 7 = Sunday) @@ -145,7 +145,7 @@ fun getDaysInMonth(month: LocalDate): List { } // Add all days of the month - for (day in CalendarConstants.FIRST_DAY_OF_MONTH..daysInMonth) { + for (day in Constants.FIRST_DAY_OF_MONTH..daysInMonth) { days.add(LocalDate(month.year, month.month, day)) } @@ -158,20 +158,19 @@ fun getDaysInMonth(month: LocalDate): List { } fun isLeapYear(year: Int): Boolean { - return (year % CalendarConstants.LEAP_YEAR_DIVISOR_4 == 0 && year % CalendarConstants.LEAP_YEAR_DIVISOR_100 != 0) || - (year % CalendarConstants.LEAP_YEAR_DIVISOR_400 == 0) + return (year % Constants.LEAP_YEAR_DIVISOR_4 == 0 && year % Constants.LEAP_YEAR_DIVISOR_100 != 0) || (year % Constants.LEAP_YEAR_DIVISOR_400 == 0) } fun isDateInRange(dateMillis: Long, startMillis: Long?, endMillis: Long?): Boolean { if (startMillis == null) return false val end = endMillis ?: startMillis - val normalizedDate = kotlinx.datetime.Instant.fromEpochMilliseconds(dateMillis) - .toLocalDateTime(TimeZone.currentSystemDefault()).date + val normalizedDate = + kotlinx.datetime.Instant.fromEpochMilliseconds(dateMillis).toLocalDateTime(TimeZone.currentSystemDefault()).date val normalizedStart = kotlinx.datetime.Instant.fromEpochMilliseconds(startMillis) .toLocalDateTime(TimeZone.currentSystemDefault()).date - val normalizedEnd = kotlinx.datetime.Instant.fromEpochMilliseconds(end) - .toLocalDateTime(TimeZone.currentSystemDefault()).date + val normalizedEnd = + kotlinx.datetime.Instant.fromEpochMilliseconds(end).toLocalDateTime(TimeZone.currentSystemDefault()).date return normalizedDate >= normalizedStart && normalizedDate <= normalizedEnd } @@ -179,27 +178,20 @@ fun isDateInRange(dateMillis: Long, startMillis: Long?, endMillis: Long?): Boole fun LocalDate.toMonthYearString(): String { val formatter = SimpleDateFormat(DatePattern.MONTH_YEAR_FORMAT, Locale.getDefault()) val calendar = Calendar.getInstance() - calendar.set(year, monthNumber - CalendarConstants.MONTH_INDEX_OFFSET, CalendarConstants.FIRST_DAY_OF_MONTH) + calendar.set(year, monthNumber - CalendarConstants.MONTH_INDEX_OFFSET, Constants.FIRST_DAY_OF_MONTH) return formatter.format(calendar.time) } fun LocalDate.minusMonths(months: Int): LocalDate = - this.toJavaLocalDate() - .minusMonths(months.toLong()) - .withDayOfMonth(1) // Always use first day of month for display + this.toJavaLocalDate().minusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display .toKotlinLocalDate() fun LocalDate.plusMonths(months: Int): LocalDate = - this.toJavaLocalDate() - .plusMonths(months.toLong()) - .withDayOfMonth(1) // Always use first day of month for display + this.toJavaLocalDate().plusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display .toKotlinLocalDate() fun LocalDate.endOfDay(): Long { - return this.atStartOfDayIn(TimeZone.currentSystemDefault()) - .plus(1.days) - .minus(1.milliseconds) - .toEpochMilliseconds() + return this.atStartOfDayIn(TimeZone.currentSystemDefault()).plus(1.days).minus(1.milliseconds).toEpochMilliseconds() } fun utcDateFormatterOf(pattern: String) = SimpleDateFormat(pattern, Locale.US).apply { @@ -222,11 +214,37 @@ object DatePattern { const val WEEKDAY_FORMAT = "EEE" } -object CalendarConstants { +private object Constants { + // Time conversion factors + const val MILLIS_TO_SECONDS = 1000.0 + const val SECONDS_TO_MINUTES = 60.0 + const val MINUTES_TO_HOURS = 60.0 + const val HOURS_TO_DAYS = 24.0 + const val DAYS_TO_WEEKS = 7.0 + const val DAYS_TO_MONTHS = 30.0 + const val DAYS_TO_YEARS = 365.0 + + // Time unit thresholds + const val SECONDS_THRESHOLD = 60 + const val MINUTES_THRESHOLD = 60 + const val HOURS_THRESHOLD = 24 + const val YESTERDAY_THRESHOLD = 2 + const val DAYS_THRESHOLD = 7 + const val WEEKS_THRESHOLD = 4 + const val MONTHS_THRESHOLD = 12 + + // Calendar + const val FIRST_DAY_OF_MONTH = 1 + + // Leap year calculation + const val LEAP_YEAR_DIVISOR_4 = 4 + const val LEAP_YEAR_DIVISOR_100 = 100 + const val LEAP_YEAR_DIVISOR_400 = 400 +} +object CalendarConstants { // Calendar grid const val DAYS_IN_WEEK = 7 - const val FIRST_DAY_OF_MONTH = 1 // Date formatting const val WEEKDAY_ABBREVIATION_LENGTH = 3 @@ -235,12 +253,4 @@ object CalendarConstants { const val DAYS_IN_WEEK_MOD = 7 const val CALENDAR_WEEK_OFFSET = 1 const val MONTH_INDEX_OFFSET = 1 - - // Leap year calculation - const val LEAP_YEAR_DIVISOR_4 = 4 - const val LEAP_YEAR_DIVISOR_100 = 100 - const val LEAP_YEAR_DIVISOR_400 = 400 - - // Preview - const val PREVIEW_DAYS_AGO = 7 } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt index 5eab26ba1..99a8de58a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt @@ -647,7 +647,7 @@ private fun PreviewWithSelection() { BottomSheetPreview { Content( initialStartDate = Clock.System.now() - .minus(CalendarConstants.PREVIEW_DAYS_AGO.days) + .minus(CalendarConstants.DAYS_IN_WEEK.days) .toEpochMilliseconds(), initialEndDate = Clock.System.now().toEpochMilliseconds(), ) diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index e22219dee..b0807b63e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -203,7 +203,5 @@ data class RestoreWalletUiState( val wordCount: Int get() = if (is24Words) WORDS_MAX else WORDS_MIN val wordsPerColumn: Int get() = if (is24Words) WORDS_MIN else 6 - val bip39Mnemonic: String - get() = words.subList(0, wordCount).joinToString(" ").trim() - + val bip39Mnemonic: String get() = words.subList(0, wordCount).joinToString(" ").trim() } From daed5dba93aa2887315ef27a7d02d2bf8b2286fb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 03:07:09 +0100 Subject: [PATCH 37/43] fix: backup relative dates --- app/src/main/java/to/bitkit/ext/DateTime.kt | 150 ++++++++------------ 1 file changed, 62 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index db306ec46..d15dde622 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -4,7 +4,11 @@ package to.bitkit.ext import android.icu.text.DateFormat import android.icu.text.DisplayContext +import android.icu.text.NumberFormat import android.icu.text.RelativeDateTimeFormatter +import android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit +import android.icu.text.RelativeDateTimeFormatter.Direction +import android.icu.text.RelativeDateTimeFormatter.RelativeUnit import android.icu.util.ULocale import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate @@ -62,67 +66,33 @@ fun Long.toRelativeTimeString( val now = nowMillis(clock) val diffMillis = now - this + val uLocale = ULocale.forLocale(locale) + val numberFormat = NumberFormat.getNumberInstance(uLocale)?.apply { maximumFractionDigits = 0 } + val formatter = RelativeDateTimeFormatter.getInstance( - ULocale.forLocale(locale), - null, + uLocale, + numberFormat, RelativeDateTimeFormatter.Style.LONG, - DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE, + DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, ) ?: return toLocalizedTimestamp() - val seconds = diffMillis / Constants.MILLIS_TO_SECONDS - val minutes = (seconds / Constants.SECONDS_TO_MINUTES).toInt() - val hours = (minutes / Constants.MINUTES_TO_HOURS).toInt() - val days = (hours / Constants.HOURS_TO_DAYS).toInt() - val weeks = (days / Constants.DAYS_TO_WEEKS).toInt() - val months = (days / Constants.DAYS_TO_MONTHS).toInt() - val years = (days / Constants.DAYS_TO_YEARS).toInt() + val seconds = diffMillis / Factor.MILLIS_TO_SECONDS + val minutes = seconds / Factor.SECONDS_TO_MINUTES + val hours = minutes / Factor.MINUTES_TO_HOURS + val days = hours / Factor.HOURS_TO_DAYS + val weeks = days / Factor.DAYS_TO_WEEKS + val months = days / Factor.DAYS_TO_MONTHS + val years = days / Factor.DAYS_TO_YEARS return when { - seconds < Constants.SECONDS_THRESHOLD -> formatter.format( - RelativeDateTimeFormatter.Direction.PLAIN, - RelativeDateTimeFormatter.AbsoluteUnit.NOW, - ) - - minutes < Constants.MINUTES_THRESHOLD -> formatter.format( - minutes.toDouble(), - RelativeDateTimeFormatter.Direction.LAST, - RelativeDateTimeFormatter.RelativeUnit.MINUTES, - ) - - hours < Constants.HOURS_THRESHOLD -> formatter.format( - hours.toDouble(), - RelativeDateTimeFormatter.Direction.LAST, - RelativeDateTimeFormatter.RelativeUnit.HOURS, - ) - - days < Constants.YESTERDAY_THRESHOLD -> formatter.format( - RelativeDateTimeFormatter.Direction.LAST, - RelativeDateTimeFormatter.AbsoluteUnit.DAY, - ) - - days < Constants.DAYS_THRESHOLD -> formatter.format( - days.toDouble(), - RelativeDateTimeFormatter.Direction.LAST, - RelativeDateTimeFormatter.RelativeUnit.DAYS, - ) - - weeks < Constants.WEEKS_THRESHOLD -> formatter.format( - weeks.toDouble(), - RelativeDateTimeFormatter.Direction.LAST, - RelativeDateTimeFormatter.RelativeUnit.WEEKS, - ) - - months < Constants.MONTHS_THRESHOLD -> formatter.format( - months.toDouble(), - RelativeDateTimeFormatter.Direction.LAST, - RelativeDateTimeFormatter.RelativeUnit.MONTHS, - ) - - else -> formatter.format( - years.toDouble(), - RelativeDateTimeFormatter.Direction.LAST, - RelativeDateTimeFormatter.RelativeUnit.YEARS, - ) + seconds < Threshold.SECONDS -> formatter.format(Direction.PLAIN, AbsoluteUnit.NOW) + minutes < Threshold.MINUTES -> formatter.format(minutes, Direction.LAST, RelativeUnit.MINUTES) + hours < Threshold.HOURS -> formatter.format(hours, Direction.LAST, RelativeUnit.HOURS) + days < Threshold.YESTERDAY -> formatter.format(Direction.LAST, AbsoluteUnit.DAY) + days < Threshold.DAYS -> formatter.format(days, Direction.LAST, RelativeUnit.DAYS) + weeks < Threshold.WEEKS -> formatter.format(weeks, Direction.LAST, RelativeUnit.WEEKS) + months < Threshold.MONTHS -> formatter.format(months, Direction.LAST, RelativeUnit.MONTHS) + else -> formatter.format(years, Direction.LAST, RelativeUnit.YEARS) } } @@ -158,41 +128,43 @@ fun getDaysInMonth(month: LocalDate): List { } fun isLeapYear(year: Int): Boolean { - return (year % Constants.LEAP_YEAR_DIVISOR_4 == 0 && year % Constants.LEAP_YEAR_DIVISOR_100 != 0) || (year % Constants.LEAP_YEAR_DIVISOR_400 == 0) + return (year % Constants.LEAP_YEAR_DIVISOR_4 == 0 && year % Constants.LEAP_YEAR_DIVISOR_100 != 0) || + (year % Constants.LEAP_YEAR_DIVISOR_400 == 0) } -fun isDateInRange(dateMillis: Long, startMillis: Long?, endMillis: Long?): Boolean { +fun isDateInRange( + dateMillis: Long, + startMillis: Long?, + endMillis: Long?, + zone: TimeZone = TimeZone.currentSystemDefault(), +): Boolean { if (startMillis == null) return false val end = endMillis ?: startMillis - val normalizedDate = - kotlinx.datetime.Instant.fromEpochMilliseconds(dateMillis).toLocalDateTime(TimeZone.currentSystemDefault()).date - val normalizedStart = kotlinx.datetime.Instant.fromEpochMilliseconds(startMillis) - .toLocalDateTime(TimeZone.currentSystemDefault()).date - val normalizedEnd = - kotlinx.datetime.Instant.fromEpochMilliseconds(end).toLocalDateTime(TimeZone.currentSystemDefault()).date + val normalizedDate = kotlinx.datetime.Instant.fromEpochMilliseconds(dateMillis).toLocalDateTime(zone).date + val normalizedStart = kotlinx.datetime.Instant.fromEpochMilliseconds(startMillis).toLocalDateTime(zone).date + val normalizedEnd = kotlinx.datetime.Instant.fromEpochMilliseconds(end).toLocalDateTime(zone).date - return normalizedDate >= normalizedStart && normalizedDate <= normalizedEnd + return normalizedDate in normalizedStart..normalizedEnd } -fun LocalDate.toMonthYearString(): String { - val formatter = SimpleDateFormat(DatePattern.MONTH_YEAR_FORMAT, Locale.getDefault()) +fun LocalDate.toMonthYearString(locale: Locale = Locale.getDefault()): String { + val formatter = SimpleDateFormat(DatePattern.MONTH_YEAR_FORMAT, locale) val calendar = Calendar.getInstance() calendar.set(year, monthNumber - CalendarConstants.MONTH_INDEX_OFFSET, Constants.FIRST_DAY_OF_MONTH) return formatter.format(calendar.time) } fun LocalDate.minusMonths(months: Int): LocalDate = - this.toJavaLocalDate().minusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display + toJavaLocalDate().minusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display .toKotlinLocalDate() fun LocalDate.plusMonths(months: Int): LocalDate = - this.toJavaLocalDate().plusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display + toJavaLocalDate().plusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display .toKotlinLocalDate() -fun LocalDate.endOfDay(): Long { - return this.atStartOfDayIn(TimeZone.currentSystemDefault()).plus(1.days).minus(1.milliseconds).toEpochMilliseconds() -} +fun LocalDate.endOfDay(zone: TimeZone = TimeZone.currentSystemDefault()): Long = + atStartOfDayIn(zone).plus(1.days).minus(1.milliseconds).toEpochMilliseconds() fun utcDateFormatterOf(pattern: String) = SimpleDateFormat(pattern, Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") @@ -215,7 +187,16 @@ object DatePattern { } private object Constants { - // Time conversion factors + // Calendar + const val FIRST_DAY_OF_MONTH = 1 + + // Leap year calculation + const val LEAP_YEAR_DIVISOR_4 = 4 + const val LEAP_YEAR_DIVISOR_100 = 100 + const val LEAP_YEAR_DIVISOR_400 = 400 +} + +private object Factor { const val MILLIS_TO_SECONDS = 1000.0 const val SECONDS_TO_MINUTES = 60.0 const val MINUTES_TO_HOURS = 60.0 @@ -223,23 +204,16 @@ private object Constants { const val DAYS_TO_WEEKS = 7.0 const val DAYS_TO_MONTHS = 30.0 const val DAYS_TO_YEARS = 365.0 +} - // Time unit thresholds - const val SECONDS_THRESHOLD = 60 - const val MINUTES_THRESHOLD = 60 - const val HOURS_THRESHOLD = 24 - const val YESTERDAY_THRESHOLD = 2 - const val DAYS_THRESHOLD = 7 - const val WEEKS_THRESHOLD = 4 - const val MONTHS_THRESHOLD = 12 - - // Calendar - const val FIRST_DAY_OF_MONTH = 1 - - // Leap year calculation - const val LEAP_YEAR_DIVISOR_4 = 4 - const val LEAP_YEAR_DIVISOR_100 = 100 - const val LEAP_YEAR_DIVISOR_400 = 400 +private object Threshold { + const val SECONDS = 60 + const val MINUTES = 60 + const val HOURS = 24 + const val YESTERDAY = 2 + const val DAYS = 7 + const val WEEKS = 4 + const val MONTHS = 12 } object CalendarConstants { From 342e14474e726d66b0addd954a7fb9ca116f1596 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 03:19:32 +0100 Subject: [PATCH 38/43] test: fix syncActivities success flow test --- .../bitkit/repositories/ActivityRepoTest.kt | 22 ++++++++++--------- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 4 ++-- .../viewmodels/RestoreWalletViewModelTest.kt | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index c1612aad4..03fe06b12 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -12,7 +12,6 @@ import org.junit.Test import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -32,12 +31,13 @@ import kotlin.test.assertTrue class ActivityRepoTest : BaseUnitTest() { - private val coreService: CoreService = mock() - private val lightningRepo: LightningRepo = mock() - private val cacheStore: CacheStore = mock() - private val addressChecker: AddressChecker = mock() - private val db: AppDb = mock() - private val clock: Clock = mock() + private val coreService = mock() + private val lightningRepo = mock() + private val transferRepo = mock() + private val cacheStore = mock() + private val addressChecker = mock() + private val db = mock() + private val clock = mock() private lateinit var sut: ActivityRepo @@ -66,7 +66,7 @@ class ActivityRepoTest : BaseUnitTest() { cacheStore = cacheStore, addressChecker = addressChecker, db = db, - transferRepo = mock(), + transferRepo = transferRepo, clock = clock, ) } @@ -76,13 +76,15 @@ class ActivityRepoTest : BaseUnitTest() { val payments = listOf(testPaymentDetails) wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(payments)) wheneverBlocking { coreService.activity.getActivity(any()) }.thenReturn(null) - wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(any(), eq(false)) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(payments) }.thenReturn(Unit) + wheneverBlocking { transferRepo.syncTransferStates() }.thenReturn(Result.success(Unit)) + wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) val result = sut.syncActivities() assertTrue(result.isSuccess) verify(lightningRepo).getPayments() - verify(coreService.activity).syncLdkNodePaymentsToActivities(payments, forceUpdate = false) + verify(coreService.activity).syncLdkNodePaymentsToActivities(payments) assertFalse(sut.isSyncingLdkNodePayments.value) } diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 008dc4b79..61196f1d5 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -21,8 +21,6 @@ import kotlin.test.assertTrue class WipeWalletUseCaseTest : BaseUnitTest() { - private lateinit var sut: WipeWalletUseCase - private val backupRepo = mock() private val keychain = mock() private val coreService = mock() @@ -33,6 +31,8 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val activityRepo = mock() private val lightningRepo = mock() + private lateinit var sut: WipeWalletUseCase + private var onWipeCalled = false private var onSetWalletExistsStateCalled = false diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index 83ada5bf1..de00e7f34 100644 --- a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -17,10 +17,10 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class RestoreWalletViewModelTest : BaseUnitTest() { - private lateinit var viewModel: RestoreWalletViewModel - private val bip39Service = mock() + private lateinit var viewModel: RestoreWalletViewModel + @Before fun setup() = runBlocking { whenever(bip39Service.isValidWord(any())).thenReturn(true) From 1a47b856b0675f3188583b89e821a686b12d5cf9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 15:27:42 +0100 Subject: [PATCH 39/43] test: validate wipe order --- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 61196f1d5..9cec51f23 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -3,6 +3,7 @@ package to.bitkit.usecases import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -63,19 +64,30 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) assertTrue(result.isSuccess) - verify(backupRepo).setWiping(true) - verify(backupRepo).reset() - verify(keychain).wipe() - verify(coreService).wipeData() - verify(db).clearAllTables() - verify(settingsStore).reset() - verify(cacheStore).reset() - verify(blocktankRepo).resetState() - verify(activityRepo).resetState() + val inOrder = inOrder( + backupRepo, + keychain, + coreService, + db, + settingsStore, + cacheStore, + blocktankRepo, + activityRepo, + lightningRepo + ) + inOrder.verify(backupRepo).setWiping(true) + inOrder.verify(backupRepo).reset() + inOrder.verify(keychain).wipe() + inOrder.verify(coreService).wipeData() + inOrder.verify(db).clearAllTables() + inOrder.verify(settingsStore).reset() + inOrder.verify(cacheStore).reset() + inOrder.verify(blocktankRepo).resetState() + inOrder.verify(activityRepo).resetState() assertTrue(onWipeCalled) - verify(lightningRepo).wipeStorage(0) + inOrder.verify(lightningRepo).wipeStorage(0) assertTrue(onSetWalletExistsStateCalled) - verify(backupRepo).setWiping(false) + inOrder.verify(backupRepo).setWiping(false) } @Test From 907a54ddb711814c36c159c501306f880928ad1f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 15:45:14 +0100 Subject: [PATCH 40/43] fix: support tab and newline mnemonic separators --- .../viewmodels/RestoreWalletViewModel.kt | 2 +- .../viewmodels/RestoreWalletViewModelTest.kt | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index b0807b63e..b45b58bdb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -40,7 +40,7 @@ class RestoreWalletViewModel @Inject constructor( } fun onChangeWord(index: Int, value: String) { - if (value.contains(" ")) { + if (value.contains(Regex("\\s"))) { handlePastedWords(value) } else { updateWordValidity(index, value) diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index de00e7f34..e8ee09a42 100644 --- a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -168,6 +168,56 @@ class RestoreWalletViewModelTest : BaseUnitTest() { assertEquals("w3", state.words[2]) } + @Test + fun `handlePastedWords should parse 12 words separated by tabs only`() { + val words = "w1\tw2\tw3\tw4\tw5\tw6\tw7\tw8\tw9\tw10\tw11\tw12" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w2", state.words[1]) + assertEquals("w12", state.words[11]) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 24 words separated by tabs only`() { + val words = List(24) { "w${it + 1}" }.joinToString("\t") + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w24", state.words[23]) + assertTrue(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 12 words separated by newlines only`() { + val words = "w1\nw2\nw3\nw4\nw5\nw6\nw7\nw8\nw9\nw10\nw11\nw12" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w2", state.words[1]) + assertEquals("w12", state.words[11]) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 24 words separated by newlines only`() { + val words = List(24) { "w${it + 1}" }.joinToString("\n") + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w24", state.words[23]) + assertTrue(state.is24Words) + } + @Test fun `handlePastedWords should clear excess slots when pasting 12 words`() { // First manually set all 24 words From e173ae6301881be8656b610828319e055558085b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 16:35:27 +0100 Subject: [PATCH 41/43] fix: dependencies repositories ordering --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 49c424185..538bf73e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,7 +34,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven("https://jitpack.io") maven { url = uri("https://maven.pkg.github.com/synonymdev/bitkit-core") credentials { @@ -51,6 +50,7 @@ dependencyResolutionManagement { password = pass } } + maven("https://jitpack.io") } } rootProject.name = "bitkit-android" From f0e8371fb58f4f56bc59229fa39966a6a6008a8b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Nov 2025 16:46:30 +0100 Subject: [PATCH 42/43] chore: enable dynamic agent loading explicitly --- app/build.gradle.kts | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65e0f0ce8..21208b3d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import io.gitlab.arturbosch.detekt.Detekt import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag @@ -156,7 +158,7 @@ android { applicationVariants.all { val variant = this outputs - .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .map { it as BaseVariantOutputImpl } .forEach { output -> val apkName = "bitkit-android-${defaultConfig.versionCode}-${variant.name}.apk" output.outputFileName = apkName @@ -172,17 +174,6 @@ composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") } -tasks.withType().configureEach { - ignoreFailures = true - reports { - html.required.set(true) - sarif.required.set(true) - md.required.set(false) - txt.required.set(false) - xml.required.set(false) - } -} - dependencies { implementation(fileTree("libs") { include("*.aar") }) implementation(libs.jna) { artifact { type = "aar" } } @@ -284,6 +275,19 @@ room { schemaDirectory("$projectDir/schemas") } +// region Tasks + +tasks.withType().configureEach { + ignoreFailures = true + reports { + html.required.set(true) + sarif.required.set(true) + md.required.set(false) + txt.required.set(false) + xml.required.set(false) + } +} + tasks.withType { testLogging { events( @@ -300,3 +304,12 @@ tasks.withType { showStackTraces = true } } + +// JDK 21+ prints warnings when ByteBuddy loads a dynamic Java agent during tests. +// Our test stack triggers this automatically. +// Explicitly enabling dynamic agent loading silences the warning without altering behavior. +tasks.withType().configureEach { + jvmArgs("-XX:+EnableDynamicAgentLoading") +} + +// endregion From c6ed58328b27e88d91c157974a928edbe9272b6a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Nov 2025 09:39:17 +0100 Subject: [PATCH 43/43] fix: clear widgets data on wipe --- app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt | 4 ++++ .../test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index d4af42a38..a836be49b 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -3,6 +3,7 @@ package to.bitkit.usecases import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo @@ -22,6 +23,7 @@ class WipeWalletUseCase @Inject constructor( private val db: AppDb, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, + private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, private val lightningRepo: LightningRepo, @@ -40,8 +42,10 @@ class WipeWalletUseCase @Inject constructor( coreService.wipeData() db.clearAllTables() + settingsStore.reset() cacheStore.reset() + widgetsStore.reset() blocktankRepo.resetState() activityRepo.resetState() diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 9cec51f23..cb6c8dfab 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -11,6 +11,7 @@ import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo @@ -28,6 +29,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val db = mock() private val settingsStore = mock() private val cacheStore = mock() + private val widgetsStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() private val lightningRepo = mock() @@ -50,6 +52,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db = db, settingsStore = settingsStore, cacheStore = cacheStore, + widgetsStore = widgetsStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, lightningRepo = lightningRepo, @@ -71,6 +74,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db, settingsStore, cacheStore, + widgetsStore, blocktankRepo, activityRepo, lightningRepo @@ -82,6 +86,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() inOrder.verify(cacheStore).reset() + inOrder.verify(widgetsStore).reset() inOrder.verify(blocktankRepo).resetState() inOrder.verify(activityRepo).resetState() assertTrue(onWipeCalled)