From 28a2fc080d3aa431a099c7d54a13dcb9602a6146 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 20:10:54 -0300 Subject: [PATCH 1/8] fix: show critical update during onboarding Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/ui/ContentView.kt | 11 ---- .../main/java/to/bitkit/ui/MainActivity.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 ++++------ .../viewmodels/AppViewModelSendFlowTest.kt | 65 +++++++++++++++++++ changelog.d/next/804.fixed.md | 1 + 5 files changed, 85 insertions(+), 31 deletions(-) create mode 100644 changelog.d/next/804.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2f94cf9c8f..93f3465723 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -80,7 +80,6 @@ import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.onboarding.InitializingWalletView import to.bitkit.ui.onboarding.WalletRestoreErrorView import to.bitkit.ui.onboarding.WalletRestoreSuccessView -import to.bitkit.ui.screens.CriticalUpdateScreen import to.bitkit.ui.screens.common.ComingSoonScreen import to.bitkit.ui.screens.contacts.AddContactScreen import to.bitkit.ui.screens.contacts.AddContactViewModel @@ -901,7 +900,6 @@ private fun RootNavHost( appViewModel = appViewModel, onNavigateHomeWidgets = onNavigateHomeWidgets, ) - update() recoveryMode(navController, appViewModel) // TODO extract transferNavigation @@ -1918,12 +1916,6 @@ private fun NavGraphBuilder.suggestions( } } -private fun NavGraphBuilder.update() { - composableWithDefaultTransitions { - CriticalUpdateScreen() - } -} - private fun NavGraphBuilder.recoveryMode( navController: NavHostController, appViewModel: AppViewModel, @@ -2469,9 +2461,6 @@ sealed interface Routes { @Serializable data object AppStatus : Routes.DeepLinkable - @Serializable - data object CriticalUpdate : Routes.InternalOnly - @Serializable data object RecoveryMode : Routes.InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 41d3bd79df..d24e9f209e 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -53,6 +53,7 @@ import to.bitkit.ui.onboarding.OnboardingSlidesScreen import to.bitkit.ui.onboarding.RestoreWalletScreen import to.bitkit.ui.onboarding.TermsOfUseScreen import to.bitkit.ui.onboarding.WarningMultipleDevicesScreen +import to.bitkit.ui.screens.CriticalUpdateScreen import to.bitkit.ui.screens.MigrationLoadingScreen import to.bitkit.ui.screens.SplashScreen import to.bitkit.ui.sheets.ForgotPinSheet @@ -126,6 +127,7 @@ class MainActivity : FragmentActivity() { val walletExists = walletViewModel.walletExists val isShowingMigrationLoading by walletViewModel.isShowingMigrationLoading.collectAsStateWithLifecycle() val restoreState by walletViewModel.restoreState.collectAsStateWithLifecycle() + val isCriticalUpdateRequired by appViewModel.isCriticalUpdateRequired.collectAsStateWithLifecycle() val hazeState = rememberHazeState(blurEnabled = true) val bottomSheetOverlayState = remember { BottomSheetOverlayState() } val authSheetOverlayState = remember { BottomSheetOverlayState() } @@ -145,7 +147,9 @@ class MainActivity : FragmentActivity() { } } - if (isShowingMigrationLoading && !isRecoveryMode) { + if (isCriticalUpdateRequired) { + CriticalUpdateScreen() + } else if (isShowingMigrationLoading && !isRecoveryMode) { MigrationLoadingScreen(isVisible = true) } else if (!walletViewModel.walletExists && !isRecoveryMode) { OnboardingNav( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c912066536..8bcb787915 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.nfc.NfcAdapter import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -301,6 +302,9 @@ class AppViewModel @Inject constructor( val sendEffect = _sendEffect.asSharedFlow() private fun setSendEffect(effect: SendEffect) = viewModelScope.launch { _sendEffect.emit(effect) } + private val _isCriticalUpdateRequired = MutableStateFlow(false) + val isCriticalUpdateRequired = _isCriticalUpdateRequired.asStateFlow() + private val _mainScreenEffect = MutableSharedFlow(extraBufferCapacity = 1) val mainScreenEffect = _mainScreenEffect.asSharedFlow() private fun mainScreenEffect(effect: MainScreenEffect) = viewModelScope.launch { _mainScreenEffect.emit(effect) } @@ -5585,27 +5589,18 @@ class AppViewModel @Inject constructor( fun dismissTimedSheet() = timedSheetManager.dismissCurrentSheet() - private suspend fun checkCriticalAppUpdate() = withContext(bgDispatcher) { - if (Env.isDebug) return@withContext - - delay(SCREEN_TRANSITION_DELAY) - - runCatching { - val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android - val currentBuildNumber = BuildConfig.VERSION_CODE - - if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext + @VisibleForTesting + internal suspend fun checkCriticalAppUpdate(isDebug: Boolean = Env.isDebug) = withContext(bgDispatcher) { + if (isDebug) return@withContext - if (androidReleaseInfo.isCritical) { - mainScreenEffect( - MainScreenEffect.Navigate( - route = Routes.CriticalUpdate, - clearStack = true, - ) - ) + runSuspendCatching { + appUpdaterService.getReleaseInfo().platforms.android + }.onSuccess { + if (it.isCritical && it.buildNumber > BuildConfig.VERSION_CODE) { + _isCriticalUpdateRequired.update { true } } - }.onFailure { e -> - Logger.warn("Failure fetching new releases", e, context = TAG) + }.onFailure { + Logger.warn("Failure fetching new releases", it, context = TAG) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 829cd2bf29..edffea4b2f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -74,12 +74,16 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLog import to.bitkit.App +import to.bitkit.BuildConfig import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.dto.PlatformDetails +import to.bitkit.data.dto.Platforms +import to.bitkit.data.dto.ReleaseInfoDTO import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived @@ -513,6 +517,53 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(hwWalletRepo).onAppForegrounded() } + @Test + fun `critical update is required for a newer critical build`() = test { + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, true)) + + sut.checkCriticalAppUpdate(isDebug = false) + + assertTrue(sut.isCriticalUpdateRequired.value) + } + + @Test + fun `critical update is not required for a newer non-critical build`() = test { + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, false)) + + sut.checkCriticalAppUpdate(isDebug = false) + + assertFalse(sut.isCriticalUpdateRequired.value) + } + + @Test + fun `critical update is not required for the same critical build`() = test { + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE, true)) + + sut.checkCriticalAppUpdate(isDebug = false) + + assertFalse(sut.isCriticalUpdateRequired.value) + } + + @Test + fun `critical update is not required when fetching release info fails`() = test { + whenever(appUpdaterService.getReleaseInfo()).thenThrow(RuntimeException("Network error")) + + sut.checkCriticalAppUpdate(isDebug = false) + + assertFalse(sut.isCriticalUpdateRequired.value) + } + + @Test + fun `critical update check is skipped in debug builds`() = test { + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, true)) + clearInvocations(appUpdaterService) + + sut.checkCriticalAppUpdate(isDebug = true) + + assertFalse(sut.isCriticalUpdateRequired.value) + verify(appUpdaterService, never()).getReleaseInfo() + } + @Test fun `foreground polling starts identity republish once and restarts after stopping`() = test { try { @@ -7116,6 +7167,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paidPeriods = emptyList(), ) + private fun releaseInfo(buildNumber: Int, isCritical: Boolean) = ReleaseInfoDTO( + platforms = Platforms( + android = PlatformDetails( + version = "1.0.0", + buildNumber = buildNumber, + notes = "Test release", + pubDate = "2024-01-01", + url = "https://example.com", + isCritical = isCritical, + ), + ios = null, + ), + ) + private fun paymentRequestCreation( request: PaykitPaymentRequest, wasPublishedToActiveState: Boolean = true, diff --git a/changelog.d/next/804.fixed.md b/changelog.d/next/804.fixed.md new file mode 100644 index 0000000000..c2fab77558 --- /dev/null +++ b/changelog.d/next/804.fixed.md @@ -0,0 +1 @@ +The mandatory update screen now also appears before a wallet is created or restored. From 47fa99df01964ff42011412ffc332af24e3aec89 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 20:16:22 -0300 Subject: [PATCH 2/8] fix: stop node on background during critical update Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/ui/MainActivity.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index d24e9f209e..34e368ca32 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -23,6 +24,9 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.core.content.IntentCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -148,6 +152,20 @@ class MainActivity : FragmentActivity() { } if (isCriticalUpdateRequired) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, walletExists, isRecoveryMode, notificationsGranted, keepActive) { + val observer = LifecycleEventObserver { _, event -> + if (event != Lifecycle.Event.ON_STOP) return@LifecycleEventObserver + val keptAliveByService = notificationsGranted && + keepActive && + appViewModel.isForegroundServiceRunning() + if (walletExists && !isRecoveryMode && !keptAliveByService) { + walletViewModel.stop() + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } CriticalUpdateScreen() } else if (isShowingMigrationLoading && !isRecoveryMode) { MigrationLoadingScreen(isVisible = true) From f2278f04660eb0fda4fb23e14e5aa545033498c8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 20:57:58 -0300 Subject: [PATCH 3/8] chore: rename changelog fragment Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/next/{804.fixed.md => 1295.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{804.fixed.md => 1295.fixed.md} (100%) diff --git a/changelog.d/next/804.fixed.md b/changelog.d/next/1295.fixed.md similarity index 100% rename from changelog.d/next/804.fixed.md rename to changelog.d/next/1295.fixed.md From 282f3e48591b38e064807d2495ec5bcb51347beb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 19:56:15 -0300 Subject: [PATCH 4/8] fix: block tx sheet while critical update is required Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8bcb787915..f712f4650a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4607,6 +4607,11 @@ class AppViewModel @Inject constructor( ) = viewModelScope.launch { if (backupRepo.isRestoring.value) return@launch + if (_isCriticalUpdateRequired.value) { + Logger.verbose("Blocked NewTransactionSheet while a critical update is required", context = TAG) + return@launch + } + if (!_isTransactionSheetEnabled) { Logger.verbose("NewTransactionSheet blocked by isNewTransactionSheetEnabled=false", context = TAG) return@launch @@ -5598,6 +5603,7 @@ class AppViewModel @Inject constructor( }.onSuccess { if (it.isCritical && it.buildNumber > BuildConfig.VERSION_CODE) { _isCriticalUpdateRequired.update { true } + hideNewTransactionSheet() } }.onFailure { Logger.warn("Failure fetching new releases", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index edffea4b2f..644fcddc69 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -553,6 +553,43 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.isCriticalUpdateRequired.value) } + @Test + fun `critical update clears an already showing transaction sheet`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "payment-hash", + sats = 1L, + ) + sut.showTransactionSheet(details) + runCurrent() + assertEquals(details, sut.transactionSheet.value) + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, true)) + + sut.checkCriticalAppUpdate(isDebug = false) + + assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) + } + + @Test + fun `transaction sheet is blocked while a critical update is required`() = test { + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, true)) + sut.checkCriticalAppUpdate(isDebug = false) + assertTrue(sut.isCriticalUpdateRequired.value) + + sut.showTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "txid", + sats = 1L, + ), + ) + runCurrent() + + assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) + } + @Test fun `critical update check is skipped in debug builds`() = test { whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, true)) From ca846203dd707c2a069f1f63c1a036118d7e235d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:37:53 -0300 Subject: [PATCH 5/8] docs: add critical update during onboarding journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 ++ .../app-update/critical-update-onboarding.xml | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 journeys/app-update/critical-update-onboarding.xml diff --git a/journeys/README.md b/journeys/README.md index 365f2ce75c..b57fc81fb8 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -115,6 +115,7 @@ fixtures, push notifications) live in each suite's README. | Suite | Journeys | Notes | | --- | --- | --- | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | +| [app-update](app-update) | 1 | Critical update blocks onboarding; needs a non-debug build and a critical release fixture; no README | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | @@ -143,6 +144,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | +| `app-update/critical-update-onboarding.xml` | not ported yet — iOS already blocks at the top level in `AppScene`, so the journey applies there once written | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS diff --git a/journeys/app-update/critical-update-onboarding.xml b/journeys/app-update/critical-update-onboarding.xml new file mode 100644 index 0000000000..d235538773 --- /dev/null +++ b/journeys/app-update/critical-update-onboarding.xml @@ -0,0 +1,26 @@ + + + Proves the mandatory update screen blocks the app before a wallet exists, not only on the home + screen (synonymdev/bitkit-android#804). MainActivity shows CriticalUpdateScreen ahead of the + migration, onboarding and wallet branches, so nothing else is composed while it is up. + Preconditions: a non-debug build, because debug builds skip the release check; the release.json + served at Env.RELEASE_URL lists an Android build marked critical with a buildNumber higher than + the installed VERSION_CODE; a fresh install with no wallet, so onboarding is what launches. Use a + throwaway device or AVD: never wipe a funded wallet to reach onboarding. + The screen has no testTags, so the steps assert its visible text. The headline is uppercased and + carries a line break, so it reads "UPDATE" then "BITKIT NOW". Toggling the release to + non-critical is the negative check: onboarding then stays on the Terms of Use screen. + + + Launch the app + Wait up to 10 seconds for the release check to finish + Verify that "Critical Update", the "UPDATE" / "BITKIT NOW" headline and "Update Bitkit" are visible + Verify that the Terms of Use screen (tag "TOS") is not visible + Press the system back button + Verify that the app closed to the launcher instead of revealing onboarding + Launch the app again + Verify that "Critical Update" is visible again once the release check finishes + Tap "Update Bitkit" + Verify that the Play Store (or a browser) opens the Bitkit listing + + From 5b3e5cff89758a84456ae8f5be963fa5377430ab Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 12:07:36 -0300 Subject: [PATCH 6/8] docs: state critical update journey setup route Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 +- .../app-update/critical-update-onboarding.xml | 36 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index a21207816f..c980cd40d4 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -115,7 +115,7 @@ fixtures, push notifications) live in each suite's README. | Suite | Journeys | Notes | | --- | --- | --- | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | -| [app-update](app-update) | 1 | Critical update blocks onboarding; needs a non-debug build and a critical release fixture; no README | +| [app-update](app-update) | 1 | Critical update blocks onboarding; cannot be run from a stock build — needs a non-debug build and a local change to reach a critical release, see the journey's setup; no README | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | diff --git a/journeys/app-update/critical-update-onboarding.xml b/journeys/app-update/critical-update-onboarding.xml index d235538773..87c93043a5 100644 --- a/journeys/app-update/critical-update-onboarding.xml +++ b/journeys/app-update/critical-update-onboarding.xml @@ -3,15 +3,39 @@ Proves the mandatory update screen blocks the app before a wallet exists, not only on the home screen (synonymdev/bitkit-android#804). MainActivity shows CriticalUpdateScreen ahead of the migration, onboarding and wallet branches, so nothing else is composed while it is up. - Preconditions: a non-debug build, because debug builds skip the release check; the release.json - served at Env.RELEASE_URL lists an Android build marked critical with a buildNumber higher than - the installed VERSION_CODE; a fresh install with no wallet, so onboarding is what launches. Use a - throwaway device or AVD: never wipe a funded wallet to reach onboarding. + + This journey cannot be run against a stock build of this repo, and the first action is the gate + that says so. Three things stand in the way, all on the current head: + debug builds skip the check entirely (`checkCriticalAppUpdate` returns early on `Env.isDebug`, + AppViewModel.kt:5598-5599), so `just run` and `just install` never reach it; + `Env.RELEASE_URL` (Env.kt:147-151) is fixed at build time, pointing at the bitkit-e2e-tests feed + when `E2E=true` and at the bitkit-android production feed otherwise; + and neither feed satisfies the condition — the e2e feed lists android `buildNumber: 0`, + `critical: false`, while the production feed lists `buildNumber: 188`, `critical: true` against + `versionCode = 188` (app/build.gradle.kts:189), and the check is a strict `>` + (AppViewModel.kt:5604), so the flag never rises. Both feeds are release assets on shared repos + that a runner must not rewrite. + + Setup, as one local uncommitted change to revert afterwards: + 1. Confirm the production feed still marks the Android build critical: + `curl -sL https://github.com/synonymdev/bitkit-android/releases/download/updater/release.json`. + If `critical` is false there, point `Env.RELEASE_URL` at a fixture you serve yourself instead + (`critical: true` and a `buildNumber` above the installed `versionCode`) and skip step 2. + 2. Lower `versionCode` in app/build.gradle.kts below the feed's `buildNumber`, e.g. to 1. + 3. Build non-debug without `E2E`: `just build assembleDevRelease`, then install the APK from + `app/build/outputs/bitkit/devRelease/`. This needs a release keystore in keystore.properties, + and release builds carry only armeabi-v7a and arm64-v8a, so use an arm device or AVD. + 4. Fresh install or clear app data, so no wallet exists and onboarding is what launches. Use a + throwaway device or AVD: never wipe a funded wallet to reach onboarding. + + If the gate does not hold, report the journey as not run rather than failed — Terms of Use at + the third action means the fixture is missing, not that the app regressed. The screen has no testTags, so the steps assert its visible text. The headline is uppercased and - carries a line break, so it reads "UPDATE" then "BITKIT NOW". Toggling the release to - non-critical is the negative check: onboarding then stays on the Terms of Use screen. + carries a line break, so it reads "UPDATE" then "BITKIT NOW". Reverting the local change is the + negative check: onboarding then stays on the Terms of Use screen. + Verify the setup above is in place: the installed build is non-debug and built without E2E, and the feed at Env.RELEASE_URL lists an Android build with critical true and a buildNumber above the installed versionCode. If not, stop and report the journey as not run Launch the app Wait up to 10 seconds for the release check to finish Verify that "Critical Update", the "UPDATE" / "BITKIT NOW" headline and "Update Bitkit" are visible From 87f3ea03d4900be9368206a24d9c4ccf67cf46d7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 12:28:19 -0300 Subject: [PATCH 7/8] docs: spell out critical update journey fixture route Co-Authored-By: Claude Opus 5 (1M context) --- .../app-update/critical-update-onboarding.xml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/journeys/app-update/critical-update-onboarding.xml b/journeys/app-update/critical-update-onboarding.xml index 87c93043a5..da17bcca2f 100644 --- a/journeys/app-update/critical-update-onboarding.xml +++ b/journeys/app-update/critical-update-onboarding.xml @@ -20,7 +20,16 @@ 1. Confirm the production feed still marks the Android build critical: `curl -sL https://github.com/synonymdev/bitkit-android/releases/download/updater/release.json`. If `critical` is false there, point `Env.RELEASE_URL` at a fixture you serve yourself instead - (`critical: true` and a `buildNumber` above the installed `versionCode`) and skip step 2. + and skip step 2. The fixture has to be that feed's whole object with only `buildNumber` and + `critical` edited (`critical: true`, `buildNumber` above the installed `versionCode`): + `PlatformDetails` (data/dto/AppUpdaterDTO.kt) declares `version`, `notes`, `pub_date` and + `url` with no defaults, and the response is decoded with the default strict `Json` + (AppUpdaterService.kt:25), so a fixture that drops a field — or adds one — throws, + `checkCriticalAppUpdate` takes `onFailure` and only logs a warning (AppViewModel.kt:5608-5610), + and the flag stays false. Serve it over HTTPS, e.g. a raw gist or a release asset on a repo of + your own: a release build has no `usesCleartextTraffic` (it is set only in + app/src/debug/AndroidManifest.xml) and targetSdk 36 blocks plain `http://`, so a local + `http://` server is refused the same silent way. 2. Lower `versionCode` in app/build.gradle.kts below the feed's `buildNumber`, e.g. to 1. 3. Build non-debug without `E2E`: `just build assembleDevRelease`, then install the APK from `app/build/outputs/bitkit/devRelease/`. This needs a release keystore in keystore.properties, @@ -29,13 +38,15 @@ throwaway device or AVD: never wipe a funded wallet to reach onboarding. If the gate does not hold, report the journey as not run rather than failed — Terms of Use at - the third action means the fixture is missing, not that the app regressed. + the third action means the fixture is missing, not that the app regressed. `adb logcat -s APP:V` + tells the two apart: "Failure fetching new releases" means the feed did not decode or was not + reachable, so the setup is wrong and the journey is again not run. The screen has no testTags, so the steps assert its visible text. The headline is uppercased and carries a line break, so it reads "UPDATE" then "BITKIT NOW". Reverting the local change is the negative check: onboarding then stays on the Terms of Use screen. - Verify the setup above is in place: the installed build is non-debug and built without E2E, and the feed at Env.RELEASE_URL lists an Android build with critical true and a buildNumber above the installed versionCode. If not, stop and report the journey as not run + Verify the setup above is in place: the installed build is non-debug and built without E2E, and the feed at Env.RELEASE_URL is served over HTTPS and lists a complete Android object with critical true and a buildNumber above the installed versionCode. If not, stop and report the journey as not run Launch the app Wait up to 10 seconds for the release check to finish Verify that "Critical Update", the "UPDATE" / "BITKIT NOW" headline and "Update Bitkit" are visible From 1d524c902f15f66aa53abee3cb15dfb2dc5376ca Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 12:43:50 -0300 Subject: [PATCH 8/8] refactor: extract root destination gate and pin its order Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/ui/RootDestinationTest.kt | 112 +++++++++++++ .../main/java/to/bitkit/ui/MainActivity.kt | 154 ++++++++++-------- 2 files changed, 202 insertions(+), 64 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/RootDestinationTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/RootDestinationTest.kt b/app/src/androidTest/java/to/bitkit/ui/RootDestinationTest.kt new file mode 100644 index 0000000000..a0ae37dc82 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/RootDestinationTest.kt @@ -0,0 +1,112 @@ +package to.bitkit.ui + +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi + +@ComposeUi +class RootDestinationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun setContent( + isCriticalUpdateRequired: Boolean = false, + isShowingMigrationLoading: Boolean = false, + walletExists: Boolean = true, + isRecoveryMode: Boolean = false, + ) { + composeTestRule.setContent { + RootDestination( + isCriticalUpdateRequired = isCriticalUpdateRequired, + isShowingMigrationLoading = isShowingMigrationLoading, + walletExists = walletExists, + isRecoveryMode = isRecoveryMode, + criticalUpdate = { Text(text = "critical", modifier = Modifier.testTag(TAG_CRITICAL)) }, + migrationLoading = { Text(text = "migration", modifier = Modifier.testTag(TAG_MIGRATION)) }, + onboarding = { Text(text = "onboarding", modifier = Modifier.testTag(TAG_ONBOARDING)) }, + wallet = { Text(text = "wallet", modifier = Modifier.testTag(TAG_WALLET)) }, + ) + } + } + + private fun assertOnly(tag: String) { + composeTestRule.onNodeWithTag(tag).assertExists() + listOf(TAG_CRITICAL, TAG_MIGRATION, TAG_ONBOARDING, TAG_WALLET) + .filterNot { it == tag } + .forEach { composeTestRule.onNodeWithTag(it).assertDoesNotExist() } + } + + @Test + fun whenCriticalUpdateRequiredDuringMigration_shouldShowCriticalUpdateOnly() { + setContent(isCriticalUpdateRequired = true, isShowingMigrationLoading = true) + + assertOnly(TAG_CRITICAL) + } + + @Test + fun whenCriticalUpdateRequiredWithoutWallet_shouldShowCriticalUpdateInsteadOfOnboarding() { + setContent(isCriticalUpdateRequired = true, walletExists = false) + + assertOnly(TAG_CRITICAL) + } + + @Test + fun whenCriticalUpdateRequiredInRecoveryMode_shouldShowCriticalUpdateInsteadOfWallet() { + setContent(isCriticalUpdateRequired = true, walletExists = false, isRecoveryMode = true) + + assertOnly(TAG_CRITICAL) + } + + @Test + fun whenCriticalUpdateRequiredWithWallet_shouldShowCriticalUpdateInsteadOfWallet() { + setContent(isCriticalUpdateRequired = true) + + assertOnly(TAG_CRITICAL) + } + + @Test + fun whenMigrationLoadingWithoutCriticalUpdate_shouldShowMigrationOnly() { + setContent(isShowingMigrationLoading = true, walletExists = false) + + assertOnly(TAG_MIGRATION) + } + + @Test + fun whenMigrationLoadingInRecoveryMode_shouldShowWallet() { + setContent(isShowingMigrationLoading = true, isRecoveryMode = true) + + assertOnly(TAG_WALLET) + } + + @Test + fun whenNoWalletAndNoCriticalUpdate_shouldShowOnboarding() { + setContent(walletExists = false) + + assertOnly(TAG_ONBOARDING) + } + + @Test + fun whenNoWalletInRecoveryMode_shouldShowWallet() { + setContent(walletExists = false, isRecoveryMode = true) + + assertOnly(TAG_WALLET) + } + + @Test + fun whenWalletExistsAndNothingBlocks_shouldShowWallet() { + setContent() + + assertOnly(TAG_WALLET) + } +} + +private const val TAG_CRITICAL = "TestCriticalUpdate" +private const val TAG_MIGRATION = "TestMigrationLoading" +private const val TAG_ONBOARDING = "TestOnboarding" +private const val TAG_WALLET = "TestWallet" diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 34e368ca32..9c80154466 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -151,83 +151,90 @@ class MainActivity : FragmentActivity() { } } - if (isCriticalUpdateRequired) { - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle, walletExists, isRecoveryMode, notificationsGranted, keepActive) { - val observer = LifecycleEventObserver { _, event -> - if (event != Lifecycle.Event.ON_STOP) return@LifecycleEventObserver - val keptAliveByService = notificationsGranted && - keepActive && - appViewModel.isForegroundServiceRunning() - if (walletExists && !isRecoveryMode && !keptAliveByService) { - walletViewModel.stop() + RootDestination( + isCriticalUpdateRequired = isCriticalUpdateRequired, + isShowingMigrationLoading = isShowingMigrationLoading, + walletExists = walletExists, + isRecoveryMode = isRecoveryMode, + criticalUpdate = { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, walletExists, isRecoveryMode, notificationsGranted, keepActive) { + val observer = LifecycleEventObserver { _, event -> + if (event != Lifecycle.Event.ON_STOP) return@LifecycleEventObserver + val keptAliveByService = notificationsGranted && + keepActive && + appViewModel.isForegroundServiceRunning() + if (walletExists && !isRecoveryMode && !keptAliveByService) { + walletViewModel.stop() + } } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } } - lifecycle.addObserver(observer) - onDispose { lifecycle.removeObserver(observer) } - } - CriticalUpdateScreen() - } else if (isShowingMigrationLoading && !isRecoveryMode) { - MigrationLoadingScreen(isVisible = true) - } else if (!walletViewModel.walletExists && !isRecoveryMode) { - OnboardingNav( - startupNavController = rememberNavController(), - scope = scope, - appViewModel = appViewModel, - walletViewModel = walletViewModel, - ) - } else { - val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() - - IsOnlineTracker(appViewModel) - ContentView( - appViewModel = appViewModel, - walletViewModel = walletViewModel, - blocktankViewModel = blocktankViewModel, - currencyViewModel = currencyViewModel, - activityListViewModel = activityListViewModel, - transferViewModel = transferViewModel, - settingsViewModel = settingsViewModel, - backupsViewModel = backupsViewModel, - hazeState = hazeState, - bottomSheetOverlayState = bottomSheetOverlayState, - modifier = Modifier.hazeSource(hazeState, zIndex = 0f), - ) + CriticalUpdateScreen() + }, + migrationLoading = { MigrationLoadingScreen(isVisible = true) }, + onboarding = { + OnboardingNav( + startupNavController = rememberNavController(), + scope = scope, + appViewModel = appViewModel, + walletViewModel = walletViewModel, + ) + }, + wallet = { + val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() - AnimatedVisibility( - visible = !isAuthenticated, - enter = fadeIn(), - exit = fadeOut(), - ) { - AuthCheckView( - showLogoOnPin = true, + IsOnlineTracker(appViewModel) + ContentView( appViewModel = appViewModel, + walletViewModel = walletViewModel, + blocktankViewModel = blocktankViewModel, + currencyViewModel = currencyViewModel, + activityListViewModel = activityListViewModel, + transferViewModel = transferViewModel, settingsViewModel = settingsViewModel, - onSuccess = { appViewModel.setIsAuthenticated(true) }, + backupsViewModel = backupsViewModel, + hazeState = hazeState, + bottomSheetOverlayState = bottomSheetOverlayState, + modifier = Modifier.hazeSource(hazeState, zIndex = 0f), ) - } - val showForgotPinSheet by appViewModel.showForgotPinSheet.collectAsStateWithLifecycle() - if (showForgotPinSheet) { - CompositionLocalProvider(LocalBottomSheetOverlayState provides authSheetOverlayState) { - ForgotPinSheet( - onDismiss = { appViewModel.setShowForgotPin(false) }, - onResetClick = { walletViewModel.wipeWallet() }, + AnimatedVisibility( + visible = !isAuthenticated, + enter = fadeIn(), + exit = fadeOut(), + ) { + AuthCheckView( + showLogoOnPin = true, + appViewModel = appViewModel, + settingsViewModel = settingsViewModel, + onSuccess = { appViewModel.setIsAuthenticated(true) }, ) } - } - BottomSheetOverlayHost(state = authSheetOverlayState) + val showForgotPinSheet by appViewModel.showForgotPinSheet.collectAsStateWithLifecycle() + if (showForgotPinSheet) { + CompositionLocalProvider(LocalBottomSheetOverlayState provides authSheetOverlayState) { + ForgotPinSheet( + onDismiss = { appViewModel.setShowForgotPin(false) }, + onResetClick = { walletViewModel.wipeWallet() }, + ) + } + } + + BottomSheetOverlayHost(state = authSheetOverlayState) - LaunchedEffect(appViewModel) { - appViewModel.mainScreenEffect.collect { - when (it) { - MainScreenEffect.WipeWallet -> walletViewModel.wipeWallet() - else -> Unit + LaunchedEffect(appViewModel) { + appViewModel.mainScreenEffect.collect { + when (it) { + MainScreenEffect.WipeWallet -> walletViewModel.wipeWallet() + else -> Unit + } } } - } - } + }, + ) val transactionSheetDetails by appViewModel.transactionSheet.collectAsStateWithLifecycle() if (transactionSheetDetails != NewTransactionSheetDetails.EMPTY) { @@ -353,6 +360,25 @@ class MainActivity : FragmentActivity() { } } +@Composable +fun RootDestination( + isCriticalUpdateRequired: Boolean, + isShowingMigrationLoading: Boolean, + walletExists: Boolean, + isRecoveryMode: Boolean, + criticalUpdate: @Composable () -> Unit, + migrationLoading: @Composable () -> Unit, + onboarding: @Composable () -> Unit, + wallet: @Composable () -> Unit, +) { + when { + isCriticalUpdateRequired -> criticalUpdate() + isShowingMigrationLoading && !isRecoveryMode -> migrationLoading() + !walletExists && !isRecoveryMode -> onboarding() + else -> wallet() + } +} + internal fun Intent?.launchKey(): String? { this ?: return null return when (action) {