6. Before and after
6. Before and after
Section titled “6. Before and after”Side by side
Section titled “Side by side” ══ BEFORE ═══════════════════════════════════════════════════════════════
tap native splash pre-runApp native work splash GIF (looping) │ (WHITE flash) up to 21 s of timeout budget │ │ │ │ │ ▼────────▼────────────────────▼────────────────────▼─────────────────── │ wait for ConnectivityCubit ────────────────────┤ │ ① GET /v2/settings new TCP+TLS ──────┤ ② InternetConnectionChecker DNS probes ──────┤ all serial ③ GET /user/profiles new TCP+TLS ──────┤ no connectTimeout ④ GET /v2/ (home feed) new TCP+TLS ──────┤ nothing cached ⑤ GET /continue-watching new TCP+TLS ──────┤ no deadline │ route → /main_scaffold │ │ ⑥ GET /v2/popup-notifications ────────────────┤ blank black screen │ …and at this exact moment: 100 VideoPlayerControllers, 5 video downloads, 2 more API calls from offstage tabs ▼ FIRST POSTER
dead network → no cached content, no error screen (emit threw) → hang
══ AFTER ════════════════════════════════════════════════════════════════
RETURNING USER tap native splash (BLACK) Hive + prefs route │ │ │ │ ▼────────▼───────────────────────▼──────────────▼ 0 ms ~50 ms ~0.4 s → FIRST POSTER (from disk) │ └── behind the user, in parallel: geo · settings · home · profiles · continue-watching each ≤10 s, set ≤12 s │ ▼ home_feed_revision++ → fresh sections swap in
FIRST INSTALL tap native splash Hive + prefs (miss) parallel fetch, ≤12 s route │ │ │ │ │ ▼────────▼────────────────▼────────────────────────▼───────────────▼ FIRST POSTER
dead network → cached feed if any; otherwise the retry screen, which now actually rendersWhat changed, concretely
Section titled “What changed, concretely”| Before | After | |
|---|---|---|
| Blocking round trips to first poster | 6 (5 serial + 1 on home) | 0 signed out · 1 signed in (the profile gate) · 1 parallel batch on first install |
| TCP/TLS handshakes at startup | one per request (~6) | 1, reused (HTTP/2 multiplexed) |
connectTimeout |
unset → OS default (75 s+ on iOS), per call | 8 s |
| Overall startup deadline | none | 10 s per call, 12 s for the whole set |
| API response cache | none (CacheOptions written, never attached) |
Hive api_cache, home feed 15 min · content 6 h |
| Offline second launch | no-internet dialog | lands on the cached home feed |
| Cached settings/nav | persisted, effectively never read | restored on every cold start |
Pre-runApp native budget |
up to 21 s + a permission dialog | Hive only; downloads init moved after first frame |
| Tabs built on reaching home | 4 (+100 players, +5 video downloads) | 1 |
| Home first paint | behind /v2/popup-notifications |
immediate, popup fetched after |
| Splash GIF decode | 250 frames at 512×512 | 250 frames at display size |
| Native launch screen | white flash both platforms | black, seamless |
| Retry screen on failure | unreachable (emit after handler completed) |
works |
Bugs fixed along the way
Section titled “Bugs fixed along the way”These were found while tracing the startup path; each is a real defect, not just a slow path.
emitafter handler completion (checkUpdate) — threwStateError, making the “Something went wrong / Try again” screen unreachable on the main failure path. Fixed by making the version check a synchronous predicate.getIt<List<NavItem>>()served a permanently stale list. GetIt caches the first resolved instance;navItemListGlobalis reassigned on every refresh. Any tab-bar change was invisible until the app was killed.- VPN branch never set
initial = true, so every connectivity event while on a VPN re-ran the entire startup sequence. checkGeoLocation()threw from an unawaited future — an unhandled async error on every failed geo lookup — whilecountry(a query param on every URL) silently stayed at the hardcoded'BD'.- A failed popup-notification fetch blanked the home screen via
emit(HomeScreenError(...)). - Multi-profile accounts were trapped on the profile picker — found
after the first cut of this work shipped, and worth recording because the
cause is subtle. Unifying every route through one
_routeOnwardmeant the profile gate was re-applied after the user picked a profile, and the gate readsuser_profiles, which a switch does not shorten. Fixed by splitting cold-start routing (_routeOnward, gate applied) from post-selection routing (_routeAfterGate, gate skipped). Doc 4 covers it in full. hasReachedDeviceLimit()raced its own data.getCurrentDeviceCount()was fired unawaited and read a few lines later. Now in Phase C, and the gate reads restored state.
How to verify it yourself
Section titled “How to verify it yourself”1. Read the phase timings. _quietly logs a label and elapsed ms for
every startup step:
adb logcat | grep -E '\[boot\]|\[cache\]|\[home\]'Record a baseline on the previous build and compare on the same device and account — the absolute numbers vary far too much between devices to be worth quoting.
2. Airplane mode, second launch. The headline behaviour. Launch once with network to populate the cache, kill the app, enable airplane mode, launch again. It must land on a populated home screen. Previously this showed the no-internet dialog.
3. Network Link Conditioner → “Very Bad Network” (iOS) or a throttled proxy. Must reach home within the budget and never hang. Also confirm the retry screen renders — it used to throw instead.
4. Fresh install, good network. Time to first poster, and confirm no notification-permission dialog appears before the first frame on Android 13+.
5. Logout and profile switch. Confirm api_cache is cleared and the
next user never sees the previous one’s feed. There are unit tests for the
scoping (test/data/datasources/local/api_cache_store_test.dart), but the
end-to-end path is worth checking by hand.
6. Connection reuse. Proxy the app through Charles or Proxyman on a cold start and confirm the startup calls share one connection instead of opening five.
7. The suites.
fvm flutter analyze # 0 errorsfvm flutter test # 381 testsNew coverage: test/data/datasources/local/api_cache_store_test.dart
(round-trip, TTL via the ambient clock, per-account and per-profile
scoping, eviction, corruption, box-unavailable) and
test/data/repositories/home_repository_impl_test.dart (cache-only reads,
stale fallback, CacheFailure vs ServerFailure), and
test/presentation/bloc/splash_screen/splash_screen_routing_test.dart
(the startup destination decision: profile gate, force-update gate,
device-limit gate, and their precedence).
8. Multi-profile accounts, by hand. Cold start → pick an unlocked profile → must land on home. Repeat with a locked profile through the PIN dialog, which reaches the same reload by a different route. Then switch profiles from inside the app. See doc 4 for why this is the one path most likely to break.
Deliberately still open
Section titled “Deliberately still open”- Cache scope is home feed + content pages only. Search, collections, artist pages, shorts and the subscription catalogue still go to the network every time. They are not on the startup path, so they were left out on purpose.
- HTTP/2 is on for every verb, where before it was only on for
PUT/DELETE. It is the highest-behavioural-risk change in this work.ApiClient._useHttp2is a singleconst bool— flip it if the CDN misbehaves. The shared-connection win survives either way, since a sharedDiopools keep-alive connections regardless. - The splash GIF is still 2.9 MB on disk.
cacheWidthbounds the decode, not the asset. Re-encoding it — or moving to Lottie, already a dependency — needs a new asset from design. ShortsScreen.initStatestill allocates 100VideoPlayerControllers and pre-downloads five videos. That is now deferred until the tab is opened rather than fixed; it deserves its own look.ApiResult._reformatstill firesUnauthorizeEventon any 401, including one from a background revalidation. Worth guarding so a failed refresh cannot log a user out mid-session.api_staticvsapi_dynamicis a natural cache boundary already present in the URL structure. Nothing exploits it yet.- The splash’s own retry paths skip the profile gate. The no-internet
retry and the reconnect handler (
splash_screen.dart:52,:147) dispatchSplashScreenReload, which by design does not gate — so a user who hits the offline dialog on the splash and then retries goes straight to home. This predates the cache-first work and is unchanged by it. Closing it would mean distinguishing the two reload sources, e.g.SplashScreenReload({bool postProfileGate = false}).