Skip to content

Commit 23c2f7b

Browse files
committed
fix(ios): retry app startup on lifecycle resume after keychain-locked failure
Production logs showed users hitting the "App Startup Error" / "Contact support" screen on iOS pre-warmed app spawns. Root cause: AppStartupBloc fired AppStartupStarted during pre-warm (before first-unlock-since-boot), CheckForExistingDefaultWalletsUsecase tried to read wallet seeds from the keychain, the typed KeychainLockedException propagated up to the bloc's generic catch, and the bloc emitted AppStartupState.failure(...). That state is sticky — the pre-warmed Flutter engine is reused when the user finally opens the app post-unlock, so the failure screen persists until the next cold launch. Fix: AppStartupBloc now registers as a WidgetsBindingObserver and catches KeychainLockedException specifically. On that exception it stays in AppStartupState.loadingInProgress (OnboardingSplash) and sets _awaitingKeychainUnlock = true. The observer's didChangeAppLifecycleState(resumed) then re-dispatches AppStartupStarted — which only fires after the user has unlocked the device since boot, so the retry's keychain reads succeed and the bloc transitions to AppStartupSuccess cleanly. Net effect: pre-warmed launches that hit the locked keychain at boot are invisible to the user; they see the splash transition straight to the unlock/home flow on first open. The bloc removes itself as an observer in close() to keep the lifecycle contract correct if the bloc is ever torn down.
1 parent 3340a33 commit 23c2f7b

3 files changed

Lines changed: 52 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ All notable changes to Bull Bitcoin Mobile will be documented in this file.
1616
- **iOS background tasks now actually execute**: Registered Flutter plugins in the workmanager background isolate via `WorkmanagerPlugin.setPluginRegistrantCallback`. Without this, every iOS background fire (`bitcoin-sync`, `liquid-sync`, `swaps-sync`, `logs-prune`) crashed at plugin init with `Unable to establish connection on channel: dev.flutter.pigeon.shared_preferences_foundation.LegacyUserDefaultsApi.getAll` because the background `FlutterEngine` started with an empty plugin registry. Periodic syncs now run for the first time.
1717
- **No more "App Init Error -25308" on iOS**: Closed every pre-first-unlock keychain-read surface on iOS startup. The legacy Hive box opens lazily — the keychain read for its encryption key now fires only when a v4/v5 migration path actually needs old data, instead of eagerly during DI bootstrap; previously, every iOS pre-warmed app spawn (which runs `application:didFinishLaunchingWithOptions:` before the user has unlocked the device since boot) crashed app init with `errSecInteractionNotAllowed`. The Future cache self-clears on failure so a later post-unlock attempt succeeds. iOS additionally short-circuits three legacy paths entirely on platforms that never shipped them: the Hive datasource, the fss9/fss10 hybrid probe in `StorageLocator` (Android-only — ESP is `androidx.security.crypto`), and the legacy version-marker check in `RequiresMigrationUsecase`. Android is the only platform that ever shipped a v0.1–v0.4 BULL build, so no install on iOS/macOS/web/Linux/Windows can hold a legacy version marker or Hive data and the eager keychain reads were dead work.
1818
- **Keychain-locked state no longer conflated with "seed missing"**: The iOS keychain error `-25308 / errSecInteractionNotAllowed` is now mapped to a typed `KeychainLockedException` at the secure-storage datasource layer (both fss10 and the legacy fss9 paths). The seed datasource explicitly rethrows it before its `SeedNotFoundException` fallback, preventing a transient pre-unlock failure from being mistaken for a missing wallet seed and triggering destructive recovery flows downstream.
19+
- **No more "App Startup Error" / "Contact support" screen on iOS pre-warm**: `AppStartupBloc` now catches `KeychainLockedException` specifically and stays in the loading state (splash screen) instead of emitting `AppStartupState.failure`. The bloc registers as a `WidgetsBindingObserver` and re-dispatches `AppStartupStarted` on `AppLifecycleState.resumed` — which only fires after the user has unlocked the device since boot. Result: a pre-warmed app that hit the locked keychain at boot transitions cleanly to the success state the first time the user opens the app, instead of leaving them stuck on the error screen.
1920
- **Logs no longer interleave between foreground and background isolates**: Main and workmanager isolates now write to separate files (`bull_logs.tsv` and `bull_background_logs.tsv`). When iOS spawns the app process to fire a periodic task, both engines can be alive simultaneously inside the same process; previously their concurrent writes to a single TSV file tore log lines mid-string. The log viewer and share/export paths merge both files by timestamp on read, so the user-visible behavior is unchanged. Additional flush points were added around the foreground crash zone-guard, the BG isolate's task return path, and `inactive`/`hidden`/`paused` lifecycle transitions so log lines reach disk before any abrupt teardown. Each isolate prunes its own file (cross-isolate prune was rejected to avoid `writeAsString`-vs-`IOSink.flush` races that would destroy the most recently buffered BG lines).
2021
- **`Unknown Background Task` on iOS BG fire**: `BackgroundTask.fromName` now accepts both the Android short name (e.g. `logs-prune`) and the iOS BGTaskScheduler identifier (e.g. `com.bullbitcoin.mobile.logs-prune-id`). `workmanager_apple` forwards the full reverse-DNS identifier while `workmanager_android` forwards the short task name, an asymmetry that previously aborted iOS BG dispatch on the first fire.
2122
- **SQLite "database is locked (code 261)" on BG isolate startup**: Set `PRAGMA busy_timeout` BEFORE `PRAGMA journal_mode = WAL` in both the main-isolate and BG-spawned drift connection setups. The busy handler is connection-scoped (see `sqlite3_busy_timeout`); installing it second meant the WAL-mode flip itself had no retry window and returned `SQLITE_BUSY_RECOVERY` (extended errno 261) when the other isolate held the file at open time. The 2000ms timeout itself is unchanged — only the ordering was wrong.

lib/features/app_startup/presentation/bloc/app_startup_bloc.dart

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:async';
22

3+
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/keychain_locked_exception.dart';
34
import 'package:bb_mobile/core/storage/migrations/004_legacy/migrate_v4_legacy_usecase.dart';
45
import 'package:bb_mobile/core/storage/migrations/005_hive_to_sqlite/migrate_v5_hive_to_sqlite_usecase.dart';
56
import 'package:bb_mobile/core/storage/requires_migration_usecase.dart';
@@ -11,6 +12,8 @@ import 'package:bb_mobile/features/app_startup/domain/usecases/reset_app_data_us
1112
import 'package:bb_mobile/features/app_unlock/domain/usecases/check_pin_code_exists_usecase.dart';
1213
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/check_backup_usecase.dart';
1314
import 'package:flutter/foundation.dart';
15+
import 'package:flutter/widgets.dart'
16+
show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState;
1417
import 'package:flutter_bloc/flutter_bloc.dart';
1518
import 'package:freezed_annotation/freezed_annotation.dart';
1619
import 'package:package_info_plus/package_info_plus.dart';
@@ -19,7 +22,8 @@ part 'app_startup_bloc.freezed.dart';
1922
part 'app_startup_event.dart';
2023
part 'app_startup_state.dart';
2124

22-
class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState> {
25+
class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
26+
with WidgetsBindingObserver {
2327
AppStartupBloc({
2428
required ResetAppDataUsecase resetAppDataUsecase,
2529
required CheckPinCodeExistsUsecase checkPinCodeExistsUsecase,
@@ -43,6 +47,7 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState> {
4347
_initTorUsecase = initTorUsecase,
4448
super(const AppStartupState.initial()) {
4549
on<AppStartupStarted>(_onAppStartupStarted);
50+
WidgetsBinding.instance.addObserver(this);
4651
}
4752

4853
final ResetAppDataUsecase _resetAppDataUsecase;
@@ -56,6 +61,27 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState> {
5661
final IsTorRequiredUsecase _isTorRequiredUsecase;
5762
final InitTorUsecase _initTorUsecase;
5863

64+
/// True while we're sitting on the splash because a startup step
65+
/// threw `KeychainLockedException` (iOS pre-first-unlock pre-warm).
66+
/// Cleared by `didChangeAppLifecycleState(resumed)`, which re-fires
67+
/// `AppStartupStarted` so init can retry on a now-unlocked keychain.
68+
bool _awaitingKeychainUnlock = false;
69+
70+
@override
71+
Future<void> close() {
72+
WidgetsBinding.instance.removeObserver(this);
73+
return super.close();
74+
}
75+
76+
@override
77+
void didChangeAppLifecycleState(AppLifecycleState state) {
78+
if (state == AppLifecycleState.resumed && _awaitingKeychainUnlock) {
79+
_awaitingKeychainUnlock = false;
80+
log.fine('App resumed — retrying startup after keychain unlock');
81+
add(const AppStartupStarted());
82+
}
83+
}
84+
5985
Future<void> _onAppStartupStarted(
6086
AppStartupStarted event,
6187
Emitter<AppStartupState> emit,
@@ -146,6 +172,23 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState> {
146172
hasDefaultWallets: doDefaultWalletsExist,
147173
),
148174
);
175+
} on KeychainLockedException catch (_) {
176+
// iOS pre-first-unlock pre-warm: the keychain is locked, so any
177+
// seed read (CheckForExistingDefaultWalletsUsecase →
178+
// _seedRepository.get) throws this typed exception. DO NOT emit
179+
// failure — that renders the "Contact support" / "App Startup
180+
// Error" screen and leaves the user permanently stuck on it once
181+
// they actually open the app post-unlock (the pre-warmed engine
182+
// is reused, so the failure state survives until the next cold
183+
// launch). Instead stay in `loadingInProgress` (OnboardingSplash)
184+
// and arm `_awaitingKeychainUnlock`; `didChangeAppLifecycleState`
185+
// re-dispatches `AppStartupStarted` on `resumed`, which only
186+
// fires after the user has unlocked the device since boot.
187+
_awaitingKeychainUnlock = true;
188+
log.warning(
189+
'App startup blocked on keychain (device not unlocked since '
190+
'boot) — staying on splash, will retry on lifecycle resumed',
191+
);
149192
} catch (e) {
150193
bool hasBackup;
151194
try {

lib/main.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,13 @@ class _BullBitcoinWalletAppState extends State<BullBitcoinWalletApp> {
330330
// Also fetch user summary to check if user is logged in
331331
// and connect WebSocket if so (handled by ExchangeListener)
332332
context.read<ExchangeCubit>().fetchUserSummary();
333+
// Reconnect WebSocket here too — when AppStartupBloc retries
334+
// after a pre-warm KeychainLockedException, the SettingsCubit
335+
// env-change listener below has ALREADY fired during pre-warm
336+
// (with the keychain locked) and won't fire again, so without
337+
// this call the WebSocket stays disconnected post-unlock until
338+
// the user toggles environment or cold-launches.
339+
context.read<ExchangeCubit>().reconnectWebSocket();
333340
},
334341
),
335342
BlocListener<SettingsCubit, SettingsState>(

0 commit comments

Comments
 (0)