RizTech Academy logo
RizTech Academy
NetworkingLesson 6 of 635 min

Offline-first, and syncing when you can

Module 8 built the half of offline-first that lives on the phone: the database is the single source of truth, screens only ever read it, and a failed refresh changes nothing on screen. This lesson builds the half that talks to the network. Arjun's train leaves Nashik on Sunday morning with Saturday's prices saved; what happens when Sunday's arrive? What does the app say in the cutting, and does it notice by itself when the signal returns at Igatpuri? Three things, each with a real bug or a real measurement behind it: a new day's prices, a connection that comes and goes, and a list of everything that was checked on the emulator before this module was called finished.

A new day: the bug

Every price has an id that includes its date: lasalgaon-onion-2026-11-07. Module 8's refresh upserted by id, which was right while a refresh only ever brought the same day's prices again. A new day brings new ids. A scratch build kept the plain upsert, served the API's 7 November file as prices.json, cleared the app and opened it, then served the 8 November file and tapped "Refresh". With the Onion chip selected, TalkBack's descriptions of the cards were:

Onion at Lasalgaon APMC, 2,580 rupees per quintal. Range 1,250 to 2,950 rupees. 8 November 2026.
Onion at Nashik APMC, 2,560 rupees per quintal. Range 1,100 to 2,700 rupees. 7 November 2026.
Onion at Nashik APMC, 2,540 rupees per quintal. Range 1,150 to 2,750 rupees. 8 November 2026.
Onion at Lasalgaon APMC, 2,525 rupees per quintal. Range 1,200 to 2,900 rupees. 7 November 2026.
Onion at Pune (Gultekdi) APMC, 2,300 rupees per quintal. Range 1,100 to 2,850 rupees. 8 November 2026.
Onion at Pune (Gultekdi) APMC, 2,200 rupees per quintal. Range 1,000 to 2,800 rupees. 7 November 2026.

Six onion cards for three markets, yesterday's mixed in with today's, sorted by price so the eye cannot tell which is which. Module 8 promised this lesson would deal with it: week() assumed one price per commodity and market in prices, and the history's newest day was supposed to be yesterday's price, which was sitting in the wrong table.

A new day: the fix

The rule: prices holds today's price and only today's; when a newer day arrives, the old row's modal price moves into price_history, and the old row goes. In PriceDao, inside the transaction that saves a download:

val incoming = prices.associateBy { it.market to it.commodity }
val replaced = before.filter { old ->
    val new = incoming[old.market to old.commodity]
    new != null && new.date > old.date
}
upsertHistory(replaced.map { PriceHistoryEntity(it.market, it.commodity, it.date, it.modalPrice) })
deletePrices(replaced.map { it.id })
upsertHistory(history)
upsertPrices(prices)

where deletePrices is @Query("DELETE FROM prices WHERE id IN (:ids)"). The whole of saveDownload is one @Transaction, so a screen never sees the moment between the delete and the insert. Three decisions in it:

  • Only a newer date replaces. A market that did not report today keeps yesterday's price in prices. Real APMC data has exactly this shape, as module 7's best-market rule already allows for: small markets report irregularly.
  • The server's history is written after the move. The phone may have saved a morning price; the day closed at another. Where both have a day, the server's closing price wins. A DAO test, theServersHistoryWinsOverThePriceThePhoneLastSaw, saves the morning's ₹2,450 and then a history row of ₹2,525 for the same day, and finds ₹2,525.
  • The history is downloaded only when it is needed. A first launch has nothing to build a week from, so the repository fetches history.json too. After that, each new day's arrival moves one day into the history, which is all the week needs. If the phone was away long enough that days are missing, the history is fetched again:
val newestSaved = priceDao.newestDate()
val newestIncoming = latest.prices.maxOfOrNull { it.date }
val history = if (newestIncoming != null && (newestSaved == null || newestSaved < newestIncoming.minusDays(1))) {
    retryingTransientFailures { network.fetchHistory() }
} else {
    emptyList()
}

Saturday over Saturday, no history; Sunday over Saturday, no history; Wednesday over Sunday, history, says the repository test theHistoryIsDownloadedOnlyWhenThePhoneHasAGap. 8,676 bytes saved on most days, which on a cheap data pack and a weak signal is not nothing.

On the emulator, the real app, the 7 November file served first and then the 8 November one: three onion cards, all dated 8 November, and Lasalgaon onion's week read "Sunday 8 November, 2,580 rupees; Saturday 7 November, 2,525 rupees; Friday 6 November, 2,420 rupees" down the detail screen, with "₹55 up on yesterday" above. The saved 2,525 had become yesterday, and the log showed one request, prices.json, 30 ms, no history. The detail screen for lasalgaon-onion-2026-11-07, if it was open, now says "This price is no longer available.", module 6's case, because that id is gone.

Knowing whether the phone is online

Module 8's fake network asked ConnectivityManager for NET_CAPABILITY_INTERNET, and said that means a network is set up to reach the internet, not that it does; NET_CAPABILITY_VALIDATED means Android has checked. Lesson 4 needed that answer to tell "no connection" from "server down", and this lesson needs it to change. data/NetworkMonitor.kt:

override val isOnline: Flow<Boolean> = callbackFlow {
    val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
            trySend(capabilities.isValidated())
        }

        override fun onLost(network: Network) {
            trySend(false)
        }
    }
    trySend(isOnlineNow())
    connectivity.registerDefaultNetworkCallback(callback)
    // Unregistered as soon as nobody collects, so the app does not keep
    // a callback with the system while no screen is looking.
    awaitClose { connectivity.unregisterNetworkCallback(callback) }
}.distinctUntilChanged().conflate()

callbackFlow is how a callback API becomes a flow: register in the block, send each answer with trySend, and unregister in awaitClose, which runs when the collector goes away. A callback that is never unregistered is a leak with the system, so the flow does it for you, every time. The default network's validated state is the question that matters: the phone may have Wi-Fi with a sign-in page and dead mobile data, and only one of them carries your requests.

NetworkMonitor is an interface, so the ViewModel tests use a FakeNetworkMonitor whose online property a test can flip.

Syncing when the connection returns

PricesViewModel watches it as part of the screen's state, and does one more thing:

private val isOnline = networkMonitor.isOnline.onEach { online ->
    if (online && refreshStatus.value.failure == RefreshFailure.NoConnection) refresh()
}

combined into uiState with the rest, so it runs only while the screen collects, lesson 2's lesson. Back online after a refresh that failed for want of a connection: try again, once, without a tap. Not after a 404 or bad data, which a connection does not fix; not when nothing failed, which would spend data on every tunnel; not in the background, which is module 10's job. PricesUiState.isOnline puts "Offline. Showing the prices saved on this phone." above the "Updated" line, in bold, so the farmer knows before she taps.

On the emulator, the real app with Sunday's prices saved:

adb shell cmd connectivity airplane-mode enable

Four seconds later the screen said "Offline. Showing the prices saved on this phone." over "Updated today at 10:47 PM" and the same prices. "Refresh" made one attempt, which failed in 27 ms with ConnectException, and the snackbar said "No connection. Showing the prices saved on this phone." with "Retry". Then:

adb shell cmd connectivity airplane-mode disable

and nobody touched the phone:

11-08 22:48:08.705 I/OkHttp  (22059): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:48:08.712 I/OkHttp  (22059): <-- 200 OK http://10.0.2.2:8765/api/v1/prices.json (6ms, 3535-byte body)

The offline line went, and the label became "Updated today at 10:48 PM". A first launch with airplane mode on works the same way. A cleared app said "No connection. Today's prices will download when the phone is back online." in the body, above "Try again"; a few seconds after airplane mode went off, prices.json and history.json arrived, 23 ms between them, and the list filled with nobody touching it. If the user taps "Refresh" at the very moment the connection returns, lesson 2's single-flight refresh makes it one download.

One more airplane-mode check, the strictest: force-stop the app, airplane mode on, cold start. The first frame had the prices, the offline line, the "Updated" time and every card's picture, from Room and Coil's disk cache, in TotalTime: 1451 ms, with no line in the OkHttp log at all. Nothing on the way to the first screen waits for the network. That is what offline-first means: the network is an improvement to what the app already shows, never a condition for showing it.

What syncing when you can does not cover

Need Covered here?
Show saved prices with no connection Yes, module 8 and this lesson
Download on first launch, and say so while slow Yes, lesson 4
Refresh by itself when the connection returns, while the screen is open Yes
Refresh every morning before the farmer opens the app No: module 10, a daily WorkManager job that calls the same repository.refresh()
Survive the app being closed in the middle of a download No: the application scope dies with the process; module 10's worker does not
Send the farmer's own changes to a server, and resolve conflicts No: Mandi Bhav only reads prices; module 10's price alerts are saved on the phone

The second row is the one people try to do here, with a loop or a timer, and lesson 2 showed what a loop in the background does. Module 10 does it with the scheduler built for it, and the repository will not need to change: the same single-flight refresh(), the same retry policy, the same transaction.

Everything checked on the emulator

Before this module's code was committed, the debug build ran against the local server on the emulator:

Check Result
First launch, empty database, server waiting 7 s per file Spinner, then the slow-connection sentence, then nine prices after 14 s
Rotation Same prices, no request
Process death (Home, adb shell am kill, reopen) Same prices, TotalTime: 898, no request
A new day Three onion cards, all 8 November; the week ends 2,580 after 2,525
Airplane mode, refresh, then off Offline line, "No connection" snackbar, then a refresh with no tap
Server answering 500 Three tries, 1 s and 2 s apart, then "error 500" with "Retry"
Server sending half a file "…sent prices this app cannot read…" over the saved prices
Server stopped "Could not reach the prices server", three tries
Empty database, server answering 503 The failure in the body and "Try again"; with the server fixed, one tap brought the prices
Airplane mode, cold start Prices and pictures in the first frame, no request

Check your work

Why a new day put six onion cards on screen. The id carries the date, so the upsert added new rows beside yesterday's.

What happens to yesterday's price now. Its modal price moves into price_history and its row is deleted, in one transaction.

When history.json is downloaded. On first launch, or when the newest saved day is more than a day before the incoming one.

Why NET_CAPABILITY_VALIDATED. It means Android checked the network reaches the internet; INTERNET alone only means it is meant to.

Why the callback is registered inside callbackFlow. So awaitClose unregisters it when nobody is collecting.

When the app refreshes by itself. When the connection returns after a refresh failed with NoConnection, and only while the Prices screen is collecting its state.

Practice 5. On the emulator: no request at all in the thirty seconds after airplane mode went off, and a request within a second of the app being reopened. While the app was in the background, uiState had stopped five seconds after the screen did and the connection callback was unregistered with it. The failure was still in the ViewModel's state, so when the screen collected again, the first value from isOnline was true and the refresh ran then.

Practice

  1. Add saveDownload with the move into the history, the history rule in the repository, NetworkMonitor and its binding, the sync on reconnect and the offline line. Run the 117 tests.
  2. Reproduce the six onion cards: remove the move from saveDownload, serve days/2026-11-07.json as prices.json, clear the app, open it, then serve the 8 November file and refresh.
  3. With the fix back, open Lasalgaon onion's detail screen on the 7 November prices, then refresh to 8 November with it open. Say what the screen shows and why.
  4. Turn airplane mode on and off with adb shell cmd connectivity airplane-mode enable and disable, and time how long after disable the request appears in the log. Leave airplane mode off afterwards.
  5. Refresh in airplane mode, press Home, turn airplane mode off, wait thirty seconds, and reopen. Predict when the request happens, then check.
  6. Harder: Mandi Bhav will add price alerts that a farmer creates on the phone. Design how one would reach a server if alerts were shared between phones: what is saved locally first, what is sent, what happens when sending fails, and what the screen says while it is waiting. Write it as a table like the one above.

Official documentation

Next: module 10, Work That Outlives the Screen.


That is module 9. You can now:

  • call a typed JSON API with Retrofit and kotlinx.serialization, keep the server's names in the network layer, choose library versions and pin a family with a BOM, and develop against a server on your own computer without shipping cleartext;
  • say where each step of a network call runs, keep suspend functions main-safe, tie work to the right scope, and find the two cancellation bugs that hide in real code: work that carries on in the background, and a catch that swallows the cancellation;
  • recognise a blocked main thread by its ANR, turn StrictMode on and read what it finds, and measure what a slow response does to the frames on screen;
  • turn every way a request can fail into a name, a sentence for the user and a retry policy, test each one against MockWebServer, and decide what loading looks like when the source is slow;
  • load remote pictures at the size they are shown, and measure the difference in memory and in data;
  • keep an app useful with no connection, move each day's prices into the history as the next arrives, and sync by itself when the connection returns.

Mandi Bhav now downloads real files from a real server, through Retrofit, into the tables module 8 built. A first launch downloads the week; a refresh brings the newest day; a server failure, a timeout or a bad file each say what happened, and the saved prices stay on screen; and when the train leaves the cutting, the app notices and refreshes. It still only refreshes while somebody is looking at it. Module 10, Work That Outlives the Screen, adds the daily refresh that happens before the farmer opens the app, with WorkManager, through the same repository, and then the price alerts that tell her when onion at Lasalgaon crosses her number.

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