fix(ios): eliminate App Init Error -25308 - #2112
Merged
Merged
Conversation
workmanager_apple spawns a separate FlutterEngine per background task. Plugins registered against the main AppDelegate's registry never reach that engine, so every iOS BG task 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". Call WorkmanagerPlugin.setPluginRegistrantCallback to attach the generated registrant to each BG engine as workmanager creates it. After this, BG tasks reach their handler body for the first time.
When iOS spawns the app process to fire a workmanager periodic task, both the main and the BG FlutterEngine can be alive in the same process simultaneously. They were both writing to the same TSV file without locking, so log lines tore mid-string (visible in production logs as truncated 'PlatformException(channel-error, ... dev.flutter.pi' entries concatenated with the next event). Each isolate now writes to its own file: bull_logs.tsv (main) and bull_background_logs.tsv (workmanager). Logger.readLogs merges both by timestamp at read time so share/view UIs see a unified stream. deleteLogs clears both. Added a startup prune trigger in the main isolate to compensate for the BG logsPrune task only pruning its own file now.
OldHiveDatasource.getBox eagerly read the iOS keychain for the legacy Hive encryption key during StorageLocator.registerDatasources. On iOS pre-warmed launches (pre-first-unlock since boot), the keychain read threw -25308 and crashed every app-init pre-warm with 'App Init Error'. The error wasn't user-visible (pre-warm doesn't render UI) but burned through Sentry quota. Make the box open lazily inside the instance. The keychain read now only fires when a v4/v5 migration actually requests legacy data — never during DI. The cached Future self-clears on failure so a later post-unlock attempt can retry. Updated getValue/saveValue to be async and propagated the await to the five legacy-migration call sites.
iOS Keychain returns errSecInteractionNotAllowed (-25308) when the device hasn't been unlocked since boot. Items use accessibility kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, so this fires during iOS pre-warmed launches or brief data-protection transitions. Wrap secure-storage calls in both fss10 and fss9 datasource impls to map -25308 to a typed KeychainLockedException + warning log, instead of letting raw PlatformException leak with iOS-specific magic numbers across every caller. SeedDatasource.get explicitly rethrows KeychainLockedException before its 5-attempt retry loop. Without this, a transient locked state was caught as a generic exception, retried 5× (each hitting the same locked keychain, burning ~9.6s of wall clock), then converted into SeedNotFoundException — which downstream code interprets as "wallet seed is missing" and may trigger destructive recovery flows. The early rethrow surfaces the typed exception to the UI as a transient, self-healing state instead. Other secure-storage callers (api key reads, etc.) intentionally fall through to their existing generic catch blocks — the Sentry noise from those paths is acceptable for the hotfix scope.
ethicnology
commented
May 12, 2026
Self-review feedback (PR #2112): - Strip `key` and `operation` fields from KeychainLockedException — nothing downstream discriminates on them, and the debug context is already in the `log.warning` line emitted at the datasource layer before the throw. The exception is now a const marker. - Replace free-form `String operation` in each `_wrap` with a local `Operation` enum so the log labels share a closed set instead of inventing strings per call site. Enum lives next to the impl that uses it. - Shorten the warning + toString lines to "device not unlocked since boot (op "key")" — the iOS-25308 detail is implicit from the KeychainLockedException type and the warning category. - Fix stale `bull_logs_bg.tsv` reference in `Bull.initLogs` doc to match the actual filename (`bull_background_logs.tsv`).
Addresses i5hi's review on PR #2112: 1. Gate the FSS9/FSS10 hybrid behind Platform.isAndroid. The fallback chain (case null: probe fss10 → readAll() → catch → try fss9) exists solely to recover 6.5.2 Android users whose wallet data sits in Jetpack Security's EncryptedSharedPreferences (ESP, Tink- backed). ESP is androidx.security.crypto — Android-only. iOS, macOS, Linux, Windows, and web have no ESP cohort. On every non-Android platform, fss9 and fss10 hit the same OS-native secure store with byte-identical wire semantics (e.g. iOS: both call SecItemAdd / SecItemCopyMatching with the same service id + key + accessibility class). Items written by either version are mutually readable, so the fallback chain is dead code there. That dead code was the only eager keychain read left in DI bootstrap. Removing it on non-Android eliminates -25308 from pre-warmed iOS launches structurally — not via after-the-fact exception mapping. Non-Android users with a stale `fss9` cohort flag (practically unreachable since both plugins query the same backing store and would have either both succeeded or both failed) are silently transitioned to `fss10` so the cohort flag stops surfacing the Android-specific "legacy storage" UI warning (wallet_bloc.dart / home_errors.dart) on what is actually a current iOS install. 2. Harden -25308 detection. flutter_secure_storage has historically shifted the OSStatus between `details` / `code` / `message` across versions. Pinning to a single field means a future fork bump could silently regress every keychain mapping. Added `_isLocked(e)` in both impls to match all three.
Member
Author
Member
Author
|
@kiranmetri we may need your testing talents here |
Three follow-ups to the BG-flush fix, all in `main.dart`:
1. App-init `try`/`catch`/`finally`: `await log.flush()` in a finally
block. The `AppInitErrorScreen` is the screen users typically share
logs from to support, so the just-logged `App Init Error` severe
line must be on disk before the screen renders. Finally placement
also catches the success path in case the in-app retry surface
ever pivots through it.
2. `runZonedGuarded` global error handler `finally`: `unawaited(
log.flush())` after the severe write. The handler signature is
synchronous (Dart's zone-error API takes `(error, stack) {}`),
so we can't await — best-effort push to disk before a potential
subsequent crash. Sentry preserves the event independently via
`Report.error`.
3. `_onInactive` lifecycle: flush in the inactive state, not just
in `_onHidden`/`_onPaused`. iOS lifecycle sequence is `active →
inactive → hidden → paused`, and force-quit from the app switcher
can skip straight from `inactive` to process death without firing
the later states.
- sqlite_database: set PRAGMA busy_timeout BEFORE PRAGMA journal_mode = WAL in both _openConnection() and createIsolateWithSpawn() setup blocks. The busy handler is connection-scoped (sqlite3_busy_timeout) — installing it second left the WAL flip itself with no retry window and returned SQLITE_BUSY_RECOVERY (extended errno 261 — the "database is locked (code 261)" production logs showed) when another isolate held the file at open time. Timeout itself unchanged (2000ms); only the ordering was wrong. - background_tasks: BackgroundTask.fromName now accepts both the Android short name (e.g. "logs-prune" — what workmanager_android forwards) AND the iOS BGTaskScheduler identifier (e.g. "com.bullbitcoin.mobile.logs-prune-id" — what workmanager_apple forwards). Asymmetry confirmed against plugin source. Eliminates the "Unknown Background Task" exception on iOS BG fires.
Android is the only platform that ever shipped a v0.1-v0.4 BULL build (2023-2024). iOS, macOS, web, Linux, Windows all released after the v5.0 SQLite migration, so installs there cannot hold: - a legacy Hive box (OldHiveDatasource short-circuit) - a legacy OldStorageKeys.version marker (RequiresMigrationUsecase short-circuit) Short-circuiting RequiresMigrationUsecase before its secure-storage read closes the second pre-bloc keychain-read surface on iOS pre- first-unlock pre-warm launches (the first was OldHiveDatasource). With both surfaces gated on Platform.isAndroid, the cold-start migration-decision path never reads the keychain on non-Android, making errSecInteractionNotAllowed (-25308) impossible from these code paths there. OldHiveDatasource's gate is !Platform.isAndroid for the same reason — Android-only is the positive predicate; every other platform is excluded.
- secure_storage: make Operation enum file-private (_Operation) in both fss10 and fss9 impls. Top-level public name was a footgun if both impls were ever imported in the same scope. - logger: prune() docstring now explains why cross-isolate pruning is rejected (writeAsString-vs-IOSink.flush race would destroy recently buffered lines on the non-owning file). deleteLogs adds an inline note explaining why the same cross-file truncation pattern is acceptable for explicit user-initiated deletion (worst case is preserving rather than destroying lines). - log_viewer_widget: copy widget.logs before .sort() to avoid mutating the parent's list — readLogs() returns the merged FG+BG view sorted ascending, and the in-place descending sort was flipping the order for any sibling widget reading the same reference. - main.dart: wrap async lifecycle handlers (_onResumed, _onInactive, _onHidden, _onPaused) in unawaited() since AppLifecycleListener.onStateChange is sync void and Flutter does not await them. Documents the fire-and-forget contract explicitly. Updated initLogs() prune comment to match the per-isolate prune contract. - CHANGELOG: entries for the SQLite cross-isolate lock fix, the BackgroundTask name/id dispatch fix, the non-Android migration short-circuit, the FSS hybrid non-Android skip, and notes on the per-isolate prune contract + the additional log-flush points (zone error, BG isolate finally, lifecycle transitions).
… 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.
ethicnology
force-pushed
the
hotfix-6.10.1
branch
from
May 15, 2026 07:01
5837ed8 to
23c2f7b
Compare
i5hi
approved these changes
May 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
hotfix for an iOS-specific failure cluster surfaced in
6.10.0-25308 (errSecInteractionNotAllowed)channel-errorWhy
Three independent iOS-specific issues stacked on top of each other:
iOS pre-warms apps since iOS 15 — it runs
application:didFinishLaunchingWithOptions:and Flutter
main()before the user unlocks the device after boot. The BULL DIbootstrap eagerly opened the legacy Hive box, which reads its encryption key
from the keychain. Pre-unlock keychain reads throw
-25308, crashing init.Users don't see this (pre-warm has no UI) but Sentry receives an "App Init
Error" event every time iOS pre-warms BULL on a locked phone.
workmanager_applespawns its ownFlutterEnginefor every BG task fire.That engine starts with an empty plugin registry, so the first
MethodChannelcall from Dart (e.g.
seedStoreTypeDatasource.read()hittingshared_preferences) fails withUnable to establish connection on channel.Every iOS BG task (bitcoin-sync, liquid-sync, swaps-sync, logs-prune) has been
crashing this way since
ffbcfdb76landed in April.Both engines write to the same
bull_logs.tsvwhen iOS fires a BG taskon a locked device, main engine pre-warms in parallel with the workmanager
engine, both log their failures, and the lines interleave/tear mid-string in
the TSV.
Test plan
channel-erroreventsBackground task X completed in N secondslines from the BG isolate (first time these appear in production)Out of scope
the pre-warm bug class — Apple mandate by iOS 27)
transitional code)
cc: @i5hi once we move to v8 we can drop hive, fss and all the migration codes