Skip to content

fix(ios): eliminate App Init Error -25308 - #2112

Merged
ethicnology merged 17 commits into
mainfrom
hotfix-6.10.1
May 18, 2026
Merged

fix(ios): eliminate App Init Error -25308#2112
ethicnology merged 17 commits into
mainfrom
hotfix-6.10.1

Conversation

@ethicnology

Copy link
Copy Markdown
Member

What

hotfix for an iOS-specific failure cluster surfaced in 6.10.0

  • "App Init Error" crashes with iOS keychain -25308 (errSecInteractionNotAllowed)
  • background tasks crashing every ~20 min with channel-error
  • interleaved log lines.

Why

Three independent iOS-specific issues stacked on top of each other:

  1. iOS pre-warms apps since iOS 15 — it runs application:didFinishLaunchingWithOptions:
    and Flutter main() before the user unlocks the device after boot. The BULL DI
    bootstrap 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.

  2. workmanager_apple spawns its own FlutterEngine for every BG task fire.
    That engine starts with an empty plugin registry, so the first MethodChannel
    call from Dart (e.g. seedStoreTypeDatasource.read() hitting
    shared_preferences) fails with Unable to establish connection on channel.
    Every iOS BG task (bitcoin-sync, liquid-sync, swaps-sync, logs-prune) has been
    crashing this way since ffbcfdb76 landed in April.

  3. Both engines write to the same bull_logs.tsv when iOS fires a BG task
    on 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

  • iOS: reboot device, do not unlock, wait 25+ min, verify no longer receives "App Init Error -25308" or channel-error events
  • iOS: reboot, do not unlock, wait 25+ min, then open app → log viewer should show Background task X completed in N seconds lines from the BG isolate (first time these appear in production)
  • iOS: existing user with v4/v5 legacy data → migration still completes on app open (Hive box opens lazily on first migration access)
  • Android: regression check — wallet sync, BG tasks, migrations still work

Out of scope

  • UIScene migration (Flutter 3.41+ official path that structurally eliminates
    the pre-warm bug class — Apple mandate by iOS 27)
  • Storage locator retirement (fss10/fss9 hybrid cohort logic, ~250 LOC of
    transitional code)

cc: @i5hi once we move to v8 we can drop hive, fss and all the migration codes

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 ethicnology self-assigned this May 12, 2026
@ethicnology
ethicnology requested a review from i5hi May 12, 2026 09:09
Comment thread lib/main.dart Outdated
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.
@ethicnology

Copy link
Copy Markdown
Member Author

6a90c7c updated according to @i5hi 's review

@ethicnology

Copy link
Copy Markdown
Member Author

@kiranmetri we may need your testing talents here

@basantagoswami basantagoswami left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

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
ethicnology merged commit d40cad2 into main May 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants