SPEAKER NOTES:
Today's demo is hands-on and practical. Monday covered DataStore for preferences — that's your go-to for key-value settings. Today we go further: writing actual files, reading them back, and loading images efficiently. These are common real-world requirements that your project will likely need.
SPEAKER NOTES:
A practical question that covers three distinct problems: writing structured data to a file (CSV export), loading images from a URL (Coil), and caching those images so they're fast. These are patterns students will reuse constantly in their careers.
SPEAKER NOTES:
Frame the session before diving in. These aren't abstract concepts — they're features the golf app needs: an export button for the tournament director, and photos of each hole so players know what they're looking at. Both problems have standard Android solutions that students will reuse in their projects.
SPEAKER NOTES:
Draw the boundary on the board. Internal storage is your app's private sandbox — think of it as your app's home directory. External storage is the shared disk — the Downloads folder, the gallery. The permission model reflects this: internal needs nothing, external needs user consent. Most app-internal data belongs in internal storage.
SPEAKER NOTES:
Let's start with internal storage — it's simpler and covers the most common use cases. We'll build a CSV export for tournament scores.
SPEAKER NOTES:
Two internal directories. filesDir is permanent (relative to app install). cacheDir is expendable — Android may delete cache files if the device is running low on storage. Use filesDir for exports the user might want to retrieve later. Use cacheDir for files you can recreate (downloaded images, temporary processing results).
SPEAKER NOTES:
Two things to point out: withContext(Dispatchers.IO) puts this on the IO thread pool where blocking calls are safe. The .use {} block closes the writer automatically — equivalent to try-with-resources in Java. The function returns the File object so the caller can share it or display a confirmation. Walk through the CSV format: header row, then one data row per score.
SPEAKER NOTES:
Always check file.exists() before reading — the file might not exist yet (first run, or cleared). useLines gives a lazy sequence — efficient for large files. drop(1) skips the header we wrote. In a real implementation you'd parse each line into a domain object rather than returning raw strings.
SPEAKER NOTES:
The API is symmetric: text uses Reader/Writer, binary uses InputStream/OutputStream. The Bitmap.compress() call encodes to JPEG at 90% quality. In practice, you'd use Coil (which we'll see shortly) to handle image loading and caching — you rarely need to manage bitmap files manually. This example shows the raw capability underneath those libraries.
SPEAKER NOTES:
If you want to share a file — open it in another app, let the user email it, view it in a PDF reader — you can't just pass a raw File path. Android requires a content URI for security.
SPEAKER NOTES:
FileProvider is an AndroidX component that vends content:// URIs for files in your app's directories. The other app can read the file via the URI without needing to know the real path. exported=false means other apps can't access the provider directly — they can only access specific files you share with them via Intents.
SPEAKER NOTES:
The file_paths.xml declares which directories FileProvider is allowed to share. files-path maps to context.filesDir. cache-path maps to context.cacheDir. The getUriForFile() call converts your internal File into a content:// URI that other apps can safely access. You then put this URI in an Intent.
SPEAKER NOTES:
The FLAG is critical. Without it, the other app (Gmail, Drive, Files) can't read your content:// URI — they'd get a SecurityException. The flag tells Android to grant temporary read access to the receiving app for this specific URI. createChooser wraps the intent in a system sheet showing compatible apps.
SPEAKER NOTES:
Now the most practical part: loading images from URLs with automatic caching. You almost never want to manage this yourself — the Coil library handles it with a single composable.
SPEAKER NOTES:
Just getting the bytes: thread management, network timeouts, connection pooling. Already non-trivial.
SPEAKER NOTES:
HTTP redirects, 4xx/5xx errors, retry logic, exponential backoff. Still just the networking layer.
SPEAKER NOTES:
JPEG, PNG, WebP, GIF — each format has its own decoder. Decoding must happen off the main thread.
SPEAKER NOTES:
A full-resolution photo might be 4MB. You need a 64x64 thumbnail. Sampling down requires knowing the target size before decoding — which requires measuring the composable first.
SPEAKER NOTES:
Memory cache: if the user scrolls away and back, the image should be instant. Requires a size-bounded LRU cache to avoid OOM.
SPEAKER NOTES:
Disk cache: after the app restarts, images should load from disk rather than re-downloading. Requires cache invalidation, eviction policies, and size limits.
SPEAKER NOTES:
If the user navigates away while an image is loading, the request must be cancelled — otherwise you decode and cache an image nobody will ever see. Requires integrating with the composable lifecycle.
SPEAKER NOTES:
This list is not exhaustive — production image loading is genuinely complex. Coil (Coroutine Image Loader) is built on Kotlin coroutines, integrates natively with Compose lifecycle, and handles every item on this list. It's the standard library for image loading in Compose apps. Glide and Picasso are alternatives in the View system but Coil is the Compose-native choice.
SPEAKER NOTES:
One dependency. Coil includes the OkHttp client, disk cache, memory cache, and Compose integration out of the box. For HTTP (not HTTPS) URLs you'd need to configure the network security policy, but most APIs use HTTPS.
SPEAKER NOTES:
AsyncImage is Coil's Compose composable. Pass a URL, and it handles everything. ContentScale.Crop fills the bounds while maintaining aspect ratio — the standard for photo thumbnails. The image is cached in memory and on disk automatically. On second load it appears instantly from the memory cache. After app restart it loads from disk cache — no network request.
SPEAKER NOTES:
Production-ready image loading always handles these three states. placeholder prevents a blank space while loading — use a low-resolution tint of the app's brand color or a skeleton. error tells the user something went wrong — a broken image icon. fallback handles null URLs gracefully. These are three lines of code that make the app feel polished.
SPEAKER NOTES:
The ImageRequest.Builder gives you control over every aspect of the load. crossfade adds a 300ms fade animation instead of a jarring instant appearance. clip(CircleShape) makes the composable circular — common for avatars and profile pictures. Coil applies the clip before caching the transformed result, so the circular version is what's cached.
SPEAKER NOTES:
Draw this on the board. The cache hierarchy is what makes Coil so fast in practice. First launch: network. Second launch (same session): memory cache — instant. Third launch (new session): disk cache — fast. The defaults are reasonable for most apps. You can tune both sizes in the Coil configuration if needed.
SPEAKER NOTES:
Now let's put the pieces together. We'll build the "Export Scorecard" feature for the Hope Foundation app — write scores to CSV, display the file path, and share it.
SPEAKER NOTES:
Walk through the flow before writing code. The key is the Dispatchers.IO requirement for file I/O — it belongs in the repository, not the ViewModel. The ViewModel launches the coroutine and handles the result. The composable triggers the action and observes the state.
SPEAKER NOTES:
Type this live. Note System.currentTimeMillis() in the filename — prevents collisions if the user exports multiple times. The .use {} block handles closing the writer even if an exception occurs mid-write. withContext(Dispatchers.IO) ensures the blocking file write doesn't touch the main thread.
SPEAKER NOTES:
The ViewModel emits the URI as a one-shot event via StateFlow. After the composable handles the share (launches the Intent), it calls onShareHandled() to reset the URI to null. This is the single-event StateFlow pattern — clean way to model "do this once."
SPEAKER NOTES:
LaunchedEffect(shareUri) re-runs whenever shareUri changes. When it's non-null, launch the Intent and call onShareHandled(). This keeps side effects (Intent launch) in a controlled location, not inside a click lambda. Run the demo: tap Export → CSV is written → system share sheet appears.
SPEAKER NOTES:
Second demo: loading photos of each golf hole in the scorecard. We'll add Coil to the project and wire it into the hole list.
SPEAKER NOTES:
Real domain modeling: nullable photoUrl. Some holes might have photos in the database, others won't. Coil's fallback parameter handles the null case gracefully.
SPEAKER NOTES:
A complete hole list item with thumbnail. The AsyncImage is 64x64 with rounded corners — a standard thumbnail size. The same placeholder is used for loading, error, and fallback since we have one icon. Run this with a list of holes: the images load progressively, cached after first load, instant on scroll back. Compare to what it would take to implement this manually.
SPEAKER NOTES:
Preloading improves perceived performance: images are in the disk/memory cache before the user scrolls to them, so they appear instantly rather than loading as the user scrolls down. The imageLoader.enqueue() fires off background downloads without blocking the UI or waiting for the result. This is an optional optimization — Coil handles lazy loading naturally without it.
SPEAKER NOTES:
Before we wrap up, a few guidelines that prevent common mistakes.
SPEAKER NOTES:
This table is the decision tree students should internalize. Every piece of data has a natural home. Putting scores in SharedPreferences or settings in Room is technically possible but wrong — each tool is optimized for its use case. When in doubt, ask: "Is this user-facing content?" → External. "Is this internal app data?" → Internal filesDir or Room depending on whether it's structured.
SPEAKER NOTES:
The same rule as network calls. File operations can block for arbitrarily long — especially on slower storage, fragmented filesystems, or when reading large files. Dispatchers.IO is the right dispatcher for all file operations. withContext() makes this a one-liner addition to any existing coroutine.
SPEAKER NOTES:
Cache files are cleaned by the OS only when storage is critically low. Don't rely on that — actively clean up your own files. This example deletes scorecard exports older than 7 days. In a production app you'd run this in a PeriodicWorkRequest (from Week 10) so it happens weekly regardless of whether the user opens settings.
SPEAKER NOTES:
Two clean summaries side by side. Students who remember just these bullets can implement the demo on their own. The FileProvider footnote is the most commonly forgotten piece — don't share raw File paths, always use content:// URIs.