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/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..9c80154466 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 @@ -53,6 +57,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 +131,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,67 +151,90 @@ class MainActivity : FragmentActivity() { } } - 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), - ) + 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) } + } + 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) { @@ -331,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) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 914896aca9..1998dd898e 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) } @@ -4603,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 @@ -5587,27 +5596,19 @@ 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 + @VisibleForTesting + internal suspend fun checkCriticalAppUpdate(isDebug: Boolean = Env.isDebug) = withContext(bgDispatcher) { + if (isDebug) return@withContext - if (androidReleaseInfo.buildNumber <= currentBuildNumber) 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 } + hideNewTransactionSheet() } - }.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 6ebf7254d0..2ac4e4e210 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,90 @@ 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 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)) + 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 { @@ -7138,6 +7226,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/1295.fixed.md b/changelog.d/next/1295.fixed.md new file mode 100644 index 0000000000..c2fab77558 --- /dev/null +++ b/changelog.d/next/1295.fixed.md @@ -0,0 +1 @@ +The mandatory update screen now also appears before a wallet is created or restored. diff --git a/journeys/README.md b/journeys/README.md index c711711637..ca3d4b93dc 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -117,6 +117,7 @@ fixtures, push notifications) live in each suite's README. | [activity](activity) | 1 | Date range sheet under rapid month taps; needs no backend, no README | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | | [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | +| [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 | | [coin-selection](coin-selection) | 1 | Manual coin selection screen; needs 3+ on-chain UTXOs; no README | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | @@ -159,6 +160,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | | `shop/gift-card-category-titles.xml` | not ported — iOS still hardcodes the category names, and its route in has no screen deeplink | +| `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 | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | `receive/receive-auto-tab-selection.xml` | not ported — the Auto tab override fix is Android-only so far; iOS parity not checked | | `security/pin-result-long-label.xml` | not ported — the toggle exists on the iOS security success screen, but the overlap check is a follow-up | diff --git a/journeys/app-update/critical-update-onboarding.xml b/journeys/app-update/critical-update-onboarding.xml new file mode 100644 index 0000000000..da17bcca2f --- /dev/null +++ b/journeys/app-update/critical-update-onboarding.xml @@ -0,0 +1,61 @@ + + + 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. + + 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 + 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, + 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. `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 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 + 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 + +