Skip to content

3. Cache-first boot

The fastest request is the one you do not make. This doc covers the disk cache and the repository pattern built on it.

What is cached, and what deliberately is not

Section titled “What is cached, and what deliberately is not”
CACHED (disk, survives process death)
├── home feed, first page, per nav tab GET /v2/{tab} TTL 15 min
└── content / detail pages GET /v1/{route} TTL 6 hours
ALREADY PERSISTED (SharedPreferences, pre-existing — now actually used)
├── /v2/settings payload → app config + nav items + min app version
├── current_user → the signed-in session
├── user_profiles → profile list
└── geo_country → NEW: the resolved country
NEVER CACHED (correctness beats speed)
├── continue-watching changes every time you watch anything
├── device / stream counts an entitlement gate
├── popup notifications campaign-timed
└── feed page 2+ only page 1 is needed to paint a cold start,
and paginated pages would evict it

Settings and nav items are not a new cache. SettingsDataSource.saveSettings has always written the whole /v2/settings payload to SharedPreferences. It was simply never read back in a way that could skip the network — the guard was analytics_matomo_site_id == null. That guard is now “have we loaded settings at all this session”, and getCacheSettings() returns the nav items the payload already contained.

lib/data/datasources/local/api_cache_store.dart — a plain Hive Box<String> holding a JSON envelope.

Hive box "api_cache"
┌──────────────────────────────────────────────────────────────────┐
│ key value │
├──────────────────────────────────────────────────────────────────┤
│ "u42:3|https://…/v2/?country=BD&…" {"v":1, │
│ "savedAtMs":1756…, │
│ "payload":{…raw body…}} │
│ "u42:3|https://…/v1/some-film?…" {…} │
│ "anon|https://…/v2/?country=BD&…" {…} │
│ "__meta" {"key":savedAtMs, …} │
└──────────────────────────────────────────────────────────────────┘

Why Box<String> and not a @HiveType? No build_runner step, and no new typeId to collide with the taken ones (1 = CacheContentEntity, 4 = CacheSubtitleEntity).

Why the __meta index? Eviction needs each entry’s age. Decoding 150 stored feed payloads to find the oldest would be absurd, and Hive’s string keys iterate lexicographically rather than by insertion, so key order tells you nothing. One small {key: savedAtMs} map, rewritten on each write, makes trimming cheap.

The key is scoped, and that is a correctness requirement

Section titled “The key is scoped, and that is a correctness requirement”
key = "<scope>|<resolved url>"
scope = "anon" logged out
= "<userId>:<profileId>" logged in

Profiles carry a maturity level. A kids profile must never be served the parent profile’s cached feed, and scoping the key is what guarantees that — not a check somewhere that could be forgotten. There is a test for exactly this (entries are scoped per profile within one account).

On top of that, clearAll() runs whenever the signed-in identity goes away — UserAuthDataSource.removeUser() (logout) and ProfileManagementDataSource.removeUser() (called by profile switch). The scoping already makes cross-user reads impossible; the wipe stops a shared device from keeping the previous account’s content on disk.

Every operation degrades to a no-op if the box is unavailable, and a corrupt entry is deleted and read as a miss:

Box<String>? get _box =>
Hive.isBoxOpen(boxName) ? Hive.box<String>(boxName) : null;

A cache is an optimisation. It must never be the reason a request fails.

HomeLocalDataSource and ContentLocalDataSource are read-only twins of their remote siblings. They build the same URL via the shared builders from doc 2, decode the envelope, and hand back the same model type — so the repository can treat both sides interchangeably.

maxAge: null means “any age”, used once the network has already failed.

The write-through lives in the remote datasource, not the repository:

HomeDataSource.getHomePageData
final _response = await getApiV2(category);
final model = HomePageModel.fromJson(_response.data['result']);
unawaited(cache.write(apiV2Url(category), _response.data));
return model;

That is the only layer that knows the resolved URL the cache is keyed on, and unawaited because the caller already has its data and must not wait on a disk write.

This is the part that matters architecturally. The datasources are transport; the repository owns when to trust what. It mirrors the getProfilesFromLocal() precedent that already existed in ProfileManagementRepositoryImpl, rather than inventing a new shape.

┌──────────────────────── HomeRepositoryImpl ────────────────────────┐
│ │
│ getHomePageFromCache(cat, {maxAge}) ← disk only, never network │
│ ├─ hit → Right(HomePage) │
│ └─ miss → Left(CacheFailure) │
│ │
│ getHomePage(cat) ← network, with a net │
│ ├─ ok → Right(fresh) … and the datasource wrote it back │
│ └─ threw → readStale(cat) │
│ ├─ hit → Right(stale) ← yesterday's feed │
│ └─ miss → Left(ServerFailure) │
└─────────────────────────────────────────────────────────────────────┘

CacheFailure is new, and it exists to keep two very different situations apart:

Failure Means Caller should
CacheFailure nothing has ever been stored go to the network and wait
ServerFailure the server said no, and no stale copy exists show the error screen

Before this change, the whole body was catch(e) { return Left(ServerFailure()); } — which is why a single flaky request produced an empty app rather than the feed the user saw ten minutes ago.

restoreLocal() refresh() (behind the user)
│ │
▼ ▼
getHomePageFromCache('/') getHomePage('/')
│ │
▼ ▼
HomeLocalDataSource HomeDataSource
│ │ ── network ──►
▼ ▼
ApiCacheStore.readStale() write-through ──► ApiCacheStore
│ │
▼ ▼
homePageListGlobal = [page] homePageListGlobal = [fresh]
│ │
▼ ▼
HOME PAINTS (t ≈ 0.4 s) home_feed_revision.value++
│
▼
HomeScreenBloc adds
HomeFeedRefreshed → re-emits
→ fresh sections swap in

home_feed_revision is a ValueNotifier<int> in const.dart, matching the app’s existing global-notifier style. It is the wire that turns “cache-first” into “cache-first and current” rather than “cache-first and stale until you kill the app”.


Next: The bootstrap and the splash — who calls all of this, in what order, and with what deadline.