SPEAKER NOTES:
Welcome to Week 11. Last week we tackled async programming — coroutines, dispatchers, Flow. Today we apply those concepts to a concrete problem: how does an app persist user preferences and data across sessions? This is something every real app does, and Android has gone through a significant evolution in how it's done.
SPEAKER NOTES:
Let students sit with this question. Every app they use "remembers" something. How? The answer is persistence — writing data to a storage medium that survives the app being closed or the device being rebooted. Today's session covers the right way to do this in modern Android.
SPEAKER NOTES:
Draw the spectrum on the board. SharedPreferences is the old way — still works, but has serious problems. DataStore is the current recommended replacement. Room handles complex structured data. Files handle raw binary. Today's session focuses on DataStore because it's what students will use for settings, preferences, and lightweight persistence in their projects.
SPEAKER NOTES:
Before teaching DataStore, establish why SharedPreferences is insufficient. Students may have used it before. Let's understand its failure modes so DataStore's design decisions make sense.
SPEAKER NOTES:
This is what most students have probably seen before. It looks harmless — and for tiny, trivial use cases, it works. But let's look at what's actually happening under the hood when this code runs.
SPEAKER NOTES:
This one has caused real ANRs in production apps. Every call to apply() schedules a disk write that happens synchronously in onStop() — on the main thread. A slow disk write means a dropped frame or a frozen screen during navigation.
SPEAKER NOTES:
If a write fails — disk full, file corrupt, permissions issue — SharedPreferences doesn't tell you. The error is swallowed silently. Users lose settings with no indication anything went wrong.
SPEAKER NOTES:
The key is just a String. You stored a Boolean under "enabled" but called getString()? Compiles fine. Crashes at runtime. SharedPreferences has no way to enforce that reads and writes use matching types.
SPEAKER NOTES:
SharedPreferences has a listener API, but it's clunky — register/unregister manually, no structured concurrency. You can't observe a preference as a Flow. Every reactive update requires manual wiring.
SPEAKER NOTES:
These aren't edge cases — they're properties of the API. Google recognized all of this and built DataStore as a deliberate redesign. It's built on coroutines and Flow from the ground up, which is why last week's content was a prerequisite.
SPEAKER NOTES:
Preferences DataStore is the direct replacement for SharedPreferences. Same concept — key-value pairs — but built on coroutines, Flow, and safe off-main-thread I/O.
SPEAKER NOTES:
Same concept as SharedPreferences — key-value pairs. The difference is entirely in how it's implemented.
SPEAKER NOTES:
Built on coroutines and Flow from the ground up — not bolted on. Every read is a Flow, every write is a suspend function.
SPEAKER NOTES:
The compiler enforces this. suspend functions cannot be called on the main thread without a coroutine scope. No more accidental blocking I/O.
SPEAKER NOTES:
Errors propagate through the Flow — you can catch them with the catch() operator. Atomic reads and writes mean you can never observe a half-written state. The conceptual model is identical to SharedPreferences — the implementation is entirely different.
SPEAKER NOTES:
Just one dependency. Note: if you also need Proto DataStore, that's a separate artifact. For Preferences DataStore — the one we're starting with — this single line is everything.
SPEAKER NOTES:
This is idiomatic Kotlin. The preferencesDataStore delegate handles creating and managing the DataStore singleton — you never instantiate it yourself. The extension on Context means you can access it from any Activity, Fragment, or wherever Context is available. The name just names the underlying file.
SPEAKER NOTES:
This is the first major improvement over SharedPreferences. Keys carry type information. getString() vs putBoolean() confusion is impossible — the compiler simply won't allow it. Group keys in an object so they're easy to find. The name string is what's stored on disk; the variable name is what you use in code.
SPEAKER NOTES:
Reading from DataStore returns a Flow — a stream of values that emits the current state and re-emits whenever the value changes. This is the reactive model that connects naturally to ViewModels and Compose.
SPEAKER NOTES:
dataStore.data emits the full Preferences object as a Flow — it re-emits every time any preference changes.
SPEAKER NOTES:
map() lets you project out just the one key you care about — no unnecessary recomposition for unrelated changes.
SPEAKER NOTES:
Unlike a one-shot function call, the flow emits right away with the stored value — no waiting.
SPEAKER NOTES:
This is where DataStore really shines. You don't ask "what is the theme?" once — you observe the theme. Whenever it changes (from any screen, any coroutine), every observer gets notified automatically. No manual refresh, no polling. This is exactly the same reactive model from Room's Flow queries that we'll see next week.
SPEAKER NOTES:
The ViewModel converts the cold Flow from the repository into a hot StateFlow that the UI can observe. This is the same stateIn() pattern from Week 10. The ViewModel is the bridge between the DataStore (source of truth) and the composable (consumer).
SPEAKER NOTES:
The composable is completely unaware of DataStore. It just observes a StateFlow and renders the value. This composable would render correctly whether the theme comes from DataStore, a test fake, or a hard-coded value. Clean separation.
SPEAKER NOTES:
Writing to DataStore is a suspend function — it must be called from a coroutine. This enforces that writes happen off the main thread. No more silent background writes that can corrupt state.
SPEAKER NOTES:
The suspend keyword means the compiler prevents main-thread writes. You cannot call edit {} outside a coroutine — it's a compile error.
SPEAKER NOTES:
The MutablePreferences block is the transaction boundary — you can modify multiple keys inside one edit block.
SPEAKER NOTES:
If you modify three keys in one edit block, either all three are written or none are. No partial writes.
SPEAKER NOTES:
The edit block is transactional. If you modify three keys inside one edit block, either all three are written or none of them are. No more partial writes. The write automatically notifies all active Flow collectors — the UI updates automatically without any additional wiring.
SPEAKER NOTES:
This is where atomicity really matters. If the user taps "Reset to Defaults," you want either all settings reset or none — not a half-reset state where theme is light but notifications are still the old value. One edit block = one atomic transaction.
SPEAKER NOTES:
The ViewModel wraps the suspend calls in viewModelScope.launch. The composable calls these regular functions — it doesn't know or care that they launch coroutines. This is the standard ViewModel pattern for any async operation.
SPEAKER NOTES:
Let's put all the pieces together in a production-quality repository. This is the complete pattern that Assignment 2 will use.
SPEAKER NOTES:
The catch() operator handles I/O errors — if the preferences file is corrupt or missing, emit emptyPreferences() (a blank slate with all defaults) rather than crashing. This is the production-grade pattern. In a real app you'd factor the catch() into a helper to avoid repetition.
SPEAKER NOTES:
Clean, focused methods. Each write targets one key. The ViewModel orchestrates combinations (e.g., when the user changes theme AND saves a tournament ID). The repository stays simple — it's just a gateway to DataStore.
SPEAKER NOTES:
Many Android codebases still use SharedPreferences. DataStore has a built-in migration path — you can switch without losing any existing user data. Let's see how.
SPEAKER NOTES:
No manual migration code. DataStore detects on first access whether the preferences_pb file exists yet. If it doesn't, the migration runs.
SPEAKER NOTES:
No manual key mapping. DataStore matches by string key name — which is why the new preferencesKey names must match the old string names exactly.
SPEAKER NOTES:
After migration, the old SharedPreferences XML file is deleted. DataStore is now the sole source of truth.
SPEAKER NOTES:
This is one of the most elegant parts of the DataStore design. You don't write migration logic — you just declare that a migration exists. DataStore detects whether migration has happened and runs it once. After that, SharedPreferences is gone. Users never lose data.
SPEAKER NOTES:
The most common migration mistake: renaming keys during migration. If your old code stored "theme" but your new key is named "app_theme", DataStore skips it silently and the user loses their setting. Match the string names exactly during migration, then you can rename if needed after the migration completes.
SPEAKER NOTES:
Preferences DataStore handles key-value pairs. Proto DataStore handles typed objects — full Kotlin data classes persisted with type safety. It requires more setup but gives you richer data models.
SPEAKER NOTES:
Both are in the same Jetpack library. For simple settings (theme, notifications, last-selected item), Preferences DataStore is the right choice. For complex app state that maps naturally to data classes — user profiles, complex configuration objects — Proto DataStore fits better. The assignment uses Preferences DataStore. I want you aware Proto exists so you know when to reach for it.
SPEAKER NOTES:
Proto DataStore wraps a full Kotlin object rather than a bag of key-value pairs. You read the whole object, copy it with changes, and write it back. The serializer handles converting to/from bytes. This is cleaner when your settings form a natural unit. The trade-off is the serializer boilerplate — shown on the next slide.
SPEAKER NOTES:
This is the boilerplate cost of Proto DataStore. You implement Serializer<T> once per data type. defaultValue is what DataStore returns if the file doesn't exist yet — no null checks. readFrom deserializes bytes to your object; writeTo serializes your object to bytes. The CorruptionException is DataStore's contract for unreadable data — it resets to defaultValue rather than crashing. With kotlinx.serialization, readFrom and writeTo are essentially one-liners.
SPEAKER NOTES:
Let's connect DataStore to our client scenario. What user preferences would the Hope Foundation golf scoring app actually need to persist?
SPEAKER NOTES:
Walk through each one: scorecard view (does the user prefer a compact card or full scorecard?), dark mode (obvious), default tournament (which tournament should pre-populate when the app opens?), player name (pre-fill entry forms), par colors (should scores be colored red/green vs par?). Assignment 2 will implement a subset of this.
SPEAKER NOTES:
Draw this on the board. This is the full reactive loop. The critical insight: when the user toggles dark mode on the Settings screen, every composable in the app that observes the theme — the top-level MaterialTheme wrapper, any component that reads it directly — automatically recomposes. No event bus, no callbacks, no manual "apply theme." The Flow does all the wiring.
SPEAKER NOTES:
Let's build the ViewModel that bridges DataStore and the Compose UI. Open Android Studio and start from the Week 11 project.
SPEAKER NOTES:
We'll implement two preferences: a boolean (dark mode toggle) and a string (tournament ID picker). That covers the two most common patterns. Students can extend to the other keys on their own for the assignment.
SPEAKER NOTES:
Type this live. Point out the consistent structure: each Flow uses catch() for error handling, maps to extract one value, and provides a default with ?:. Each suspend function uses a focused edit block. This is the template students will follow for any DataStore repository.
SPEAKER NOTES:
The ViewModel is thin — it converts Flows to StateFlows and wraps suspend calls in launch. This is intentional. No business logic lives here: the repository holds the data contract, the ViewModel provides a stable interface for the UI. If you needed to validate the tournament ID, that logic would go in the repository.
SPEAKER NOTES:
The composable is declarative and clean. The Switch reflects isDark (from DataStore) and calls toggleDarkMode when toggled. The ViewModel handles the write. The Flow handles the update. The composable just renders. Run the app: flip the switch, close and reopen the app — the setting persists. That's DataStore working.
SPEAKER NOTES:
Blocking I/O, no type safety, no Flow, silent error swallowing. Still works, but DataStore replaces it in all new code.
SPEAKER NOTES:
Built on coroutines and Flow. Async, safe, type-checked. Direct replacement for SharedPreferences.
SPEAKER NOTES:
Keys carry type information. The compiler prevents reading a Boolean key as a String — a whole class of runtime crashes eliminated.
SPEAKER NOTES:
Reading returns a Flow — it emits the current value immediately, then re-emits on every change. No polling, no callbacks.
SPEAKER NOTES:
Writing is always a suspend function — off the main thread, atomic, triggers all active Flow collectors automatically.
SPEAKER NOTES:
Repository owns the DataStore. ViewModel converts Flow to StateFlow. Composable observes StateFlow. Each layer knows nothing about the layers below it.
SPEAKER NOTES:
This is the vocabulary for the assignment. Assignment 2 builds a full settings screen — students implement DataStore persistence for multiple preferences. Wednesday's demo extends this to file storage: exporting data as CSV, reading back, and caching images.
SPEAKER NOTES:
Assignment 2 is due Friday — this is the main deliverable of the week. Start today. The Wednesday demo fills in file handling, which is a bonus: students who want to export tournament data as CSV or cache course photos will have the tools. The core DataStore content from today is what the assignment requires.