RizTech Academy logo
RizTech Academy
Storing Data on DeviceLesson 4 of 535 min

Caching, and an app that works on a bad train

Arjun is on the Panchavati Express from Nashik to Mumbai, and the signal comes and goes with the cuttings. He opens Mandi Bhav to check Vashi before he calls a buyer. Three things decide whether the app is any use to him: whether it shows him the prices it already has, at once, without waiting for a network it may not get; whether it tells him how old they are; and what it does when he taps "Refresh" in a tunnel. Lessons 2 and 3 gave the app the first. This lesson gives it the other two, measures what the database costs at start-up, and states the rule the whole module has been building towards.

The rule: one source of truth, and it is the database

Since lesson 2 the app's data moves one way:

 ┌──────────────┐  refresh()   ┌──────────────┐  Flow queries  ┌──────────────┐
 │   network    │ ───────────→ │   database   │ ─────────────→ │   screens    │
 │ (module 9)   │   writes     │    (Room)    │   watch        │  ViewModels  │
 └──────────────┘              └──────────────┘                └──────────────┘

Screens never read the network. The network never writes to a screen. A refresh writes rows, and every screen that is watching a query sees them because the table changed. Module 7 called the repository the single source of truth; now the truth has a place on disk, and the repository is the only door to it.

The alternative most apps start with is "fetch, then show what came back, and cache it on the side". It looks equivalent until the fetch fails: then the screen has nothing, or worse, shows the cache on one screen and the network's answer on another. Google's offline-first guide calls the local database the canonical source for exactly this reason. If the screen only ever shows the database, a failed refresh changes nothing on screen, and that is the behaviour you want on a train. What it costs: every piece of data goes through a table, so every new field is an entity change and a migration; and the screen can show stale data, so you must say how old it is.

A fake network that is honest about airplane mode

The demo network from lesson 2 returns the closing prices after a pause, whatever the phone's connection. Turn on airplane mode on the emulator and tap "Refresh": "Prices are up to date." A lie, and a dangerous one to test against, because the app has never seen a failure. Module 9's real network fails with an IOException when there is no connection, so the fake should too. DemoPriceNetworkDataSource now asks the system:

class DemoPriceNetworkDataSource @Inject constructor(
    @ApplicationContext private val context: Context,
) : PriceNetworkDataSource {

    override suspend fun fetchLatestPrices(): List<CommodityPrice> {
        delay(1_000)
        if (!isConnected()) throw IOException("No network connection")
        return SamplePrices.latest
    }

    private fun isConnected(): Boolean {
        val connectivity = context.getSystemService(ConnectivityManager::class.java)
        val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) ?: return false
        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }
}

Reading the network state needs a normal permission, granted at install without asking the user, in AndroidManifest.xml:

<!-- Lets the app ask whether there is a connection before a refresh. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

Leave it out and lintDebug fails the build: "Missing permissions required by ConnectivityManager.getActiveNetwork: android.permission.ACCESS_NETWORK_STATE [MissingPermission]".

Do not mistake this check for a way to avoid errors in real code. NET_CAPABILITY_INTERNET means a network is set up to reach the internet, not that it does; Google's connectivity page points to NET_CAPABILITY_VALIDATED for that, and says even a validated network can lose its signal. A phone can report a network and still fail every request, on a train more than anywhere. Module 9 makes the request and handles its failure, which covers both cases; this check exists only to make the fake fail the way the real thing will.

The trap: nobody catches the failure

With the honest fake and module 7's refresh() unchanged, airplane mode on, "Refresh":

E/AndroidRuntime(17547): FATAL EXCEPTION: main
E/AndroidRuntime(17547): java.io.IOException: No network connection
E/AndroidRuntime(17547): 	at com.riztechacademy.mandibhav.data.DemoPriceNetworkDataSource.fetchLatestPrices(PriceNetworkDataSource.kt:30)
E/AndroidRuntime(17547): 	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@e8ff817, Dispatchers.Main.immediate]

Module 7 wrote try { … } finally { … } so that a failure would not leave the progress bar spinning, and said module 9's network would make failure possible. It is possible now, and finally does not catch: the exception goes on to viewModelScope, which has no handler, and the app dies. The farmer on the train loses the screen that was showing perfectly good prices a second earlier. PricesViewModel.refresh() gains one branch:

} catch (e: IOException) {
    // No connection. The saved prices are still on screen and
    // still correct for when they were fetched; say so plainly.
    refreshStatus.update { it.copy(userMessage = "No connection. Showing the prices saved on this phone.") }
} finally {

It catches IOException only. A bug, an IllegalStateException from a broken query say, should still crash in testing, where you will see it. Module 9 adds the other failures a server brings, a 500 or a timeout, and the retry; this is the one failure the app can have today.

Nothing else is needed, because of the rule. RoomPriceRepository.refresh() fetches first and writes second, so when the fetch throws, nothing has been written; the screens are still watching the same rows. A repository test checks exactly that: refresh once, go offline with different prices waiting, refresh again, and the ₹2,525 and the time it was fetched are both untouched.

How old are these prices?

Showing saved prices without their age is the other half of the lie. A price from yesterday's close is useful to Arjun if he knows it is yesterday's. So each row records when it was fetched. That is a new column, so a new schema version, 3:

/** When this row was last written by a refresh; null for the seed data. */
val fetchedAt: Instant? = null,

in PriceEntity, with CommodityPrice.asEntity(fetchedAt: Instant? = null) passing it through and asModel() leaving it out, because no screen shows a price's fetch time on its card. This is the mapping layer from lesson 2 earning its keep: the database has a column the model does not. Instant needs a converter, beside the date's:

/** A moment is stored as milliseconds since 1970 UTC, the same on every phone. */
@ColumnTypeConverter
fun instantToEpochMilli(instant: Instant?): Long? = instant?.toEpochMilli()

@ColumnTypeConverter
fun epochMilliToInstant(epochMilli: Long?): Instant? = epochMilli?.let(Instant::ofEpochMilli)

Store a moment, not a local time: "4:05 pm" means nothing without a zone, and the phone's zone can change between writing and reading.

This migration Room can write. Adding one nullable column is a change Room can work out from the version 2 and version 3 schemas, so the database declares an automatic migration:

@Database(
    entities = [PriceEntity::class, PriceHistoryEntity::class, MarketEntity::class],
    version = 3,
    autoMigrations = [
        // 2 to 3 adds one nullable column, prices.fetchedAt: Room can work
        // that out from the two exported schemas.
        AutoMigration(from = 2, to = 3),
    ],
)

Room generates the ALTER TABLE from the two JSON files, which is one more reason they are in git. Two new migration tests: a version 2 database becomes version 3 with its rows kept and fetchedAt empty, and a version 1 phone reaches version 3 in one open, through MIGRATION_1_2 and then the automatic one. The first test run could not find 3.json: in the same build that changed the version, the assets had been merged before KSP wrote the new file. The second run passed. Build once after bumping the version, and commit the new JSON with the change.

Versions only go up. While checking this lesson, a version 2 build was installed over a test install that already held a version 3 database, and it crashed on launch with "A migration from 3 to 2 was required but not found". You will do the same when you switch to an older branch with the app installed. Clear the app's storage, or uninstall it; fallbackToDestructiveMigrationOnDowngrade() exists for apps that must allow downgrades, at lesson 3's price.

The refresh stamps every row it writes with the time, from a Clock that Hilt provides as Clock.systemDefaultZone() and a test replaces with a fixed one:

/**
 * Network first, then one write. If the fetch fails, nothing has been
 * written, and every screen keeps showing what the database has.
 */
override suspend fun refresh(): Int {
    val latest = network.fetchLatestPrices()
    val now = clock.instant()
    return priceDao.upsertAndCountChanges(latest.map { it.asEntity(fetchedAt = now) })
}

and the DAO answers "when were prices last fetched" with one aggregate, as a flow, so the label updates the moment a refresh lands:

/** When the newest price was fetched; null if no refresh has ever succeeded. */
@Query("SELECT MAX(fetchedAt) FROM prices")
fun observeLastFetched(): Flow<Instant?>

exposed by the repository as lastUpdated(): Flow<Instant?>, a new function on the interface that FakePriceRepository implements too.

The bug the new column caused

With the column added and nothing else changed, the repository tests failed:

FAIL refreshWritesTheClosingPricesOnce java.lang.AssertionError: expected:<3> but was:<9>
FAIL aRefreshThatChangesNoPriceStillCountsNone java.lang.AssertionError: expected:<0> but was:<9>

upsertAndCountChanges counted a row as changed if it differed from the stored one, and now every row differs, by its fetch time. Every refresh would have said "9 prices updated." over prices that had not moved, and the double-tap guard from module 7 would have been guarding a message that was always wrong. A price has changed if anything except the moment it was fetched is different:

// A price is changed if anything but the time it was fetched differs.
return prices.count { before[it.id]?.copy(fetchedAt = it.fetchedAt) != it }

Saying it on screen

PricesUiState gains lastUpdated: Instant?, and PricesViewModel would have six inputs again. Lesson 1's grouping grows instead, into everything that is saved on the phone:

/** What is saved on the phone: the prices, when they came, and the market to put first. */
private data class SavedData(
    val prices: List<CommodityPrice>,
    val homeMarket: String?,
    val lastUpdated: Instant?,
)

private val savedData = combine(
    repository.todaysPrices(), preferences.homeMarket, repository.lastUpdated(),
) { prices, home, lastUpdated ->
    SavedData(prices, home, lastUpdated)
}

The words are a plain function, ui/UpdatedLabel.kt, with the time, zone and locale as parameters so a test can fix them:

fun updatedLabel(
    lastUpdated: Instant?,
    now: Instant,
    zone: ZoneId,
    locale: Locale,
): UpdatedLabel {
    if (lastUpdated == null) {
        // Null is not "never": rows saved before version 3 have no time either.
        return UpdatedLabel("Not known when these prices were fetched. Tap Refresh.", isStale = true)
    }
    val time = DateTimeFormatter.ofPattern("h:mm a", locale).format(lastUpdated.atZone(zone))
    val day = lastUpdated.atZone(zone).toLocalDate()
    val text = if (day == now.atZone(zone).toLocalDate()) {
        "Updated today at $time"
    } else {
        "Updated ${DateTimeFormatter.ofPattern("d MMM", locale).format(day)} at $time"
    }
    return UpdatedLabel(text, isStale = Duration.between(lastUpdated, now) > STALE_AFTER)
}

STALE_AFTER is 12 hours: a morning check of yesterday's close is flagged, an afternoon check of the 4 pm prices is not. The screen draws it in the error colour when stale, adding "May be out of date.". The tests fix 4:05 pm in Pune as Instant.parse("2026-11-07T10:35:00Z") and check that Locale.US writes "Updated today at 4:05 PM" while Indian English, en-IN, writes "4:05 pm": the phone's own locale decides, which is why the screen passes LocalConfiguration.current.locales[0] rather than hard-coding a format.

The first version of that null branch said "Sample prices. Not refreshed yet." It was true for a new install, where the rows come from the seed. Then the version 2 build with refreshed prices was replaced by version 3 on the emulator, and the automatic migration gave every existing row an empty fetchedAt. The screen told a farmer who had refreshed yesterday that she never had. A NULL added by a migration means "not known", not "never", and the words now say only what the app knows.

One more case the destructive fallback in lesson 3 exposed: an empty database is not "no results". PricesUiState gains noSavedPrices, set when the database has no prices at all, noResults becomes !isLoading && !noSavedPrices && prices.isEmpty(), and the screen says "No prices saved on this phone yet. Tap Refresh to download today's." instead of blaming the filters. With the seed it cannot happen today; in module 9, a first launch on a train is exactly this.

On the emulator

Version 2 with refreshed prices, then version 3 installed over it: "Not known when these prices were fetched. Tap Refresh." above ₹2,560 and ₹2,525. "Refresh": "Updated today at 9:58 PM", and "Prices are up to date.", the honest count. Airplane mode on with adb shell cmd connectivity airplane-mode enable, "Refresh": the progress bar for a second, then "No connection. Showing the prices saved on this phone." over the same prices and the same time. Force stop, and a cold start with airplane mode still on: the prices and "Updated today at 9:58 PM" on the first screen, TotalTime: 848 ms. Airplane mode off again with disable.

What the database costs at start-up

Reading from disk is slower than reading from memory, so the honest question is how much later the prices reach the screen. A temporary log in PricesRoute recorded the first frame drawn with prices, as milliseconds after the process started, over seven cold starts each on the emulator:

Build First frame (TotalTime), median Prices on screen, median
Module 7, prices in memory 824 ms 732 ms
Module 8, database already saved 808 ms 803 ms
Module 8, storage cleared before each start: create and seed 855 ms 820 ms

About 70 ms more to get the prices from a table than from a list in memory, on a start-up that takes 800 ms anyway, with the prices arriving with the first frame. Creating and seeding a new file adds under 20 ms more, once per install. In exchange, module 7's build shows the morning's prices after every process death and module 8's shows what the farmer last fetched. On a cheap phone every number is larger; measure there before you decide anything is fast.

Cache policies, and the one Mandi Bhav uses

Policy Means Costs
Show saved data, refresh on request (Mandi Bhav now) Instant screen, no network unless asked; flag age Prices can be old until the user acts
Refresh on open when older than N hours Fresh more often, with no tap Data use and a progress bar on every stale open, including on the train
Refresh in the background on a schedule Fresh before the user looks Battery and Android's background limits; module 10
Expire and delete after N days Bounded size The data is gone exactly when there is no network to replace it
Network only, no cache Always current when it works Nothing at all on a bad connection

Mandi Bhav shows what it has, says how old it is, and refreshes when asked. Module 10 adds a daily background refresh with WorkManager, writing to the same tables through the same repository, and nothing on screen will need to change.

./gradlew testDebugUnitTest runs 85: three new repository tests for the fetch time and the failed refresh, two ViewModel tests for the offline message and the empty database, four for the label, and the two migration tests.

Check your work

What "the database is the single source of truth" means in this app. Screens show only what is in the database; the network only writes to it.

Why the demo network now checks the connection. So that the app meets a failure before module 9's real network gives it one; it used to report success in airplane mode.

Why the app crashed in airplane mode before the catch. finally runs and rethrows; the IOException reached viewModelScope, which had no handler.

Why a failed refresh leaves the screen correct. The repository fetches before it writes, so nothing is written, and the screens are watching unchanged rows.

What the 2 to 3 migration does, and why Room could write it. Adds a nullable fetchedAt column; Room derives it from the two exported schemas.

Why every refresh said "9 prices updated". The fetch time made every row differ; the comparison now ignores it.

Why "Not refreshed yet" was wrong. The migration left fetchedAt empty on rows that had been refreshed; NULL meant "not known".

What the database costs at a cold start, here. About 70 ms more than memory before prices show; under 20 ms more to create and seed.

Practice 5. The prices and the label do not change, and the snackbar says "No connection. Showing the prices saved on this phone." Then, on a cold start with airplane mode still on, the app opens straight to the same prices and the same "Updated" line: nothing in start-up touches the network.

Practice

  1. Add the connection check and the permission, the catch, fetchedAt with its converter and the automatic migration, the Clock, observeLastFetched, lastUpdated, the label with its tests, SavedData and noSavedPrices. Build twice, then run the 85 tests.
  2. Reproduce the crash: remove the catch, turn on airplane mode, tap "Refresh", read the trace. Put it back, and turn airplane mode off.
  3. Remove .copy(fetchedAt = it.fetchedAt) from the change count and run the repository tests. Read both failures.
  4. Measure your own cold start with and without a saved database, using the log line from this lesson or adb shell am start -W. Remove the log afterwards.
  5. With airplane mode on, refresh, then force-stop and reopen. Predict what each screen says before you look.
  6. Harder: implement "refresh on open when older than 12 hours" in PricesViewModel, reusing STALE_AFTER. Write the tests first with FakePriceRepository and a fixed clock. Then decide whether Mandi Bhav should ship it before module 10, and argue it from the table above.

Official documentation

Next: files, what belongs in one, and where secrets go.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship