4. The bootstrap and the splash
4. The bootstrap and the splash
Section titled “4. The bootstrap and the splash”Doc 1 showed startup logic living inside a bloc, tangled up with routing, dialogs and analytics, with no phase boundaries. This doc covers where it lives now.
Three phases, three different rules
Section titled “Three phases, three different rules”lib/domain/services/app_bootstrap.dart is the contract;
lib/data/services/app_bootstrap_impl.dart implements it. The whole
design is one idea: be explicit about what each phase is allowed to
block.
┌── PHASE A ── restoreLocal() ───────────────── disk only, no network ──┐ │ │ │ 1. getCurrentUser() SharedPreferences → current_user │ │ ▲ MUST be first: cache keys are scoped per account+profile │ │ 2. restoreGeoLocation() prefs → country.value │ │ ▲ MUST precede any URL build: country is in every key │ │ 3. getCacheSettings() prefs → settings.value + navItemListGlobal│ │ 4. getProfilesFromLocal() prefs → profiles │ │ 5. getHomePage.fromCache() Hive → homePageListGlobal │ │ │ │ returns BootResult(canRenderHome: navItems ≠ ∅ && home ≠ ∅, profiles)│ │ cost: a few disk reads, tens of milliseconds │ └────────────────────────────────────────────────────────────────────────┘
┌── PHASE B ── refresh() ──────────── network, parallel, capped ─────────┐ │ │ │ if no persisted country: await geo (3 s cap) ← first install only │ │ │ │ Future.wait([ ┐ │ │ geo lookup │ each wrapped in _quietly(): │ │ GET /v2/settings │ • its own 10 s timeout │ │ GET /v2/ (home feed) │ • swallows its own failure │ │ GET /user/profiles │ • logs label + elapsed ms │ │ GET /continue-watching │ │ │ ]).timeout(12 s) ┘ ← global budget │ │ │ │ Individual failures never fail the set. Stragglers keep running and │ │ apply themselves to the globals when they land. │ └────────────────────────────────────────────────────────────────────────┘
┌── PHASE C ── warmUpBackground() ── fire-and-forget, after first frame ─┐ │ │ │ Matomo init · FCM token registration · Marktag Fcm / AppInstall │ │ · InternetConnectionChecker probe · device count · GET /auth/user │ │ · download quality preferences │ │ │ │ Nothing here gates any pixel. │ └────────────────────────────────────────────────────────────────────────┘Why geo gets special treatment
Section titled “Why geo gets special treatment”country is a query parameter on every URL. In the old code
checkGeoLocation() was fired unawaited and the home feed usually went
out under the hardcoded default 'BD' — a race that was invisible only
because nobody was checking.
The rule now:
- Returning user — the country was persisted last run, so it is already correct in Phase A. The refresh runs alongside everything else, costing nothing.
- First install — nothing is persisted, so Phase B waits up to 3 s for the lookup before fetching a whole home feed under the wrong region.
Correct in both cases, and only the first-ever launch pays for it.
_quietly is the whole error strategy
Section titled “_quietly is the whole error strategy”Future<void> _quietly(String label, Future<dynamic> future, {Duration? timeout}) async { final started = DateTime.now(); try { await future.timeout(timeout ?? _perCallTimeout); } catch (e) { debugPrint('[boot] $label failed after ' '${DateTime.now().difference(started).inMilliseconds}ms: $e'); }}Startup is a set of independent best-effort steps. One failing must never
take the others — or the app — down with it. The elapsed-ms log is also
the cheapest startup profiler you will get; adb logcat | grep '\[boot\]'
prints the whole phase breakdown.
The splash is now a gate, not a loader
Section titled “The splash is now a gate, not a loader” SplashScreenInitialEvent │ ├── if (_started) return; ← the connectivity listener can fire │ _started = true; more than once (reconnects, VPN) │ ▼ await bootstrap.restoreLocal() │ ├── unawaited(_initDeepLinks(context)) needs a BuildContext, so it │ stays here — but blocks nothing │ await _resolveProfilesForGate() ← signed in? one bounded call. See below. │ ├── canRenderHome? ──── YES ──► _routeOnward(context, profiles) │ bootstrap.warmUpBackground() │ unawaited(refresh(skipProfiles: true)) │ └── DONE. │ └──────────────── NO ──► await bootstrap.refresh(skipProfiles: true) bootstrap.warmUpBackground() │ ├── still nothing? ──► offline dialog │ or SplashScreenFailed └── otherwise ──► _routeOnward(…)Two entry points, one decision
Section titled “Two entry points, one decision”The decision is a pure function, so it can be unit-tested without a router
or a BuildContext:
enum StartupDestination { forceUpdate, chooseProfile, deviceLimit, home }
StartupDestination resolveDestination({ required bool applyProfileGate, required List<UserProfileEntity> profiles,}) { if (_updateRequired()) → forceUpdate if (applyProfileGate && _requiresProfileChoice(profiles)) → chooseProfile if (ContentHelper.hasReachedDeviceLimit()) → deviceLimit → home}applyProfileGate is the important argument, and it is the subject of the
next section. Two callers:
| Caller | Event | Gate |
|---|---|---|
_routeOnward |
SplashScreenInitialEvent — a cold start |
applied |
_routeAfterGate |
SplashScreenReload — already past it |
skipped |
Previously this decision was spread across three nested fold callbacks,
a void async method and two duplicated copies of the same checks.
The force-update check is now synchronous
Section titled “The force-update check is now synchronous”The old checkUpdate() awaited PackageInfo.fromPlatform() from a
void … async method, which is what made its emit fire after the
handler had completed and threw StateError — the bug from doc 1 that
made the retry screen unreachable.
app_version is already resolved in main() before runApp, so no await
is needed at all:
bool _updateRequired() { final minVersion = Platform.isIOS ? …ios : …android; if (minVersion == null || minVersion.isEmpty || minVersion.length < 5) { return false; // preserved from the original: <5 chars means unset } return Version.parse(app_version) < Version.parse(minVersion);}A pure predicate cannot emit after completion. The retry screen works again.
Profile gating belongs to the cold start, and only there
Section titled “Profile gating belongs to the cold start, and only there”This is worth understanding properly, because getting it wrong shipped a bug that trapped every multi-profile account on the profile picker.
The gate itself is simple:
bool _requiresProfileChoice(List<UserProfileEntity> profiles) { if (current_user.value.token == '') return false; return profiles.length > 1 || (profiles.length == 1 && profiles.first.isLocked);}The trap is what that list contains. Picking a profile does not shorten
it. switchProfile updates current_user and clears the API cache, but
the prefs key user_profiles is only ever written by saveUserProfiles,
which always stores the full list from /user/profiles. So for an
account with three profiles, _requiresProfileChoice is true forever —
before the choice and after it.
Which means the gate can only be asked once per launch, at the cold start. Everything downstream has to skip it:
ChooseProfileScreen tap │ ├─ unlocked → LoadingRoute(switchProfileEvent) │ └─ ProfileManagementBloc.add(SwitchProfile) │ └─ splashScreenBloc.add(SplashScreenReload) │ └─ locked → PIN dialog → onSwitchProfileWithPin └─ LoadingRoute(fromPage:) → LoadingScreen else-branch └─ splashScreenBloc.add(SplashScreenReload) │ ▼ _onReload → _routeAfterGate (applyProfileGate: FALSE) │ ▼ /main_scaffold ✓Point _onReload at _routeOnward instead — i.e. re-ask the gate — and
that arrow loops straight back to /choose_profile, forever. That is
exactly the regression the routing split exists to prevent, and it has a
test named after it in
test/presentation/bloc/splash_screen/splash_screen_routing_test.dart.
Note also that SplashScreenBloc is a registerFactory: LoadingScreen
resolves its own instance, so no per-instance flag on the bloc can
carry “the user already chose” across that boundary. The distinction has
to live in which routing function the event handler calls.
Why the gate always asks the network
Section titled “Why the gate always asks the network”Because the gate is skipped everywhere except the cold start, the cold start had better get it right — and a signed-in user with no cached profile list would otherwise sail past it, PIN and all. So it is the one piece of startup that always makes a request when signed in:
static const Duration _profileGateTimeout = Duration(seconds: 4);
Future<List<UserProfileEntity>> _resolveProfilesForGate() async { if (current_user.value.token == '') return const []; // nothing to gate try { await profileManagementUseCase().timeout(_profileGateTimeout); } catch (e) { /* logged */ } return _profilesFromDisk(); // fresh if it landed, cached if it did not}Two properties matter:
- Bounded. A 4 s cap, so the gate can never reintroduce the unbounded wait this whole rework exists to remove.
- Degrades, never fails.
getUserProfiles()saves through on success, so reading back from disk gives the fresh list when the call landed and the cached one when it did not — offline, the gate still works off the last known list.
The cost is one bounded round trip before routing, for signed-in users only. Signed-out users keep the instant path. This is a deliberate trade: correctness of a parental-control gate over ~0 ms of routing latency.
A profile added or locked on another device therefore takes effect on the next cold start of this one, which is the behaviour that predates the cache-first work.
The connectivity listener
Section titled “The connectivity listener”The splash used to refuse to start until ConnectivityCubit emitted
connected, and it showed the offline dialog itself on disconnected.
Both are wrong once there is a cache — an airplane-mode launch has a
perfectly good feed on disk.
if (initial == false) { initial = true; // ← the VPN branch used to forget this, connectivityStatus = state; // re-running all of startup on every splashScreenBloc.add(…); // later connectivity event return;}It now starts regardless of connectivity and lets the bloc decide: the offline dialog appears only when Phase A found nothing and Phase B could not reach anything.
Next: The first frame and after.