Skip to content

Instantly share code, notes, and snippets.

@shadow7
Created February 19, 2026 17:01
Show Gist options
  • Select an option

  • Save shadow7/cd8886eed12b0f9baea2c56e30d8bf48 to your computer and use it in GitHub Desktop.

Select an option

Save shadow7/cd8886eed12b0f9baea2c56e30d8bf48 to your computer and use it in GitHub Desktop.
SavedManager Architecture: Room as Single Source of Truth

SavedManager Architecture Documentation

Executive Summary

This document defines the optimal architecture for saved articles sync and display in android-phoenix, addressing parallel batch downloading, Room persistence, Kotlin coroutines state management, and Compose loading patterns across multiple cache layers.


Flow Charts

1. Sync Flow: GraphQL β†’ Room β†’ UI

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          SYNC FLOW                                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   UI Layer   β”‚     β”‚   SavedManager   β”‚     β”‚    AssetSynchronizer        β”‚
β”‚              │────▢│                  │────▢│                             β”‚
β”‚ syncCache()  β”‚     β”‚ syncMutex.lock   β”‚     β”‚ mutex.lock                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚                             β”‚
                              β–Ό                             β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ _syncState =    β”‚         β”‚ 1. ops.deleteQueuedItems  β”‚
                    β”‚   Syncing       β”‚         β”‚ 2. ops.addPendingSaves    β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚ 3. ops.removePendingUnsaveβ”‚
                                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                              β”‚
                                                              β–Ό
                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                              β”‚     GraphQLReadingListRepository               β”‚
                              β”‚     downloadFromReadingList()                  β”‚
                              β”‚                                                β”‚
                              β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
                              β”‚  β”‚ Page 1      │───▢│ Page 2                │──┼──▢ ...
                              β”‚  β”‚ (50 items)  β”‚    β”‚ (50 items)            β”‚  β”‚
                              β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
                              β”‚                                                β”‚
                              β”‚  Handle 304 Not Modified:                      β”‚
                              β”‚    return (skipDelete=true, emptyList)         β”‚
                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                          β”‚
                                                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     PARALLEL BATCH DOWNLOAD                                 β”‚
β”‚                                                                             β”‚
β”‚   downloadedAssetIndexList.chunked(BATCH_SIZE=10)                          β”‚
β”‚                                                                             β”‚
β”‚   Semaphore(MAX_CONCURRENT_BATCHES=3)                                       β”‚
β”‚                                                                             β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                 β”‚
β”‚   β”‚ Batch 1  β”‚  β”‚ Batch 2  β”‚  β”‚ Batch 3  β”‚  ... (parallel with semaphore)  β”‚
β”‚   β”‚ 10 items β”‚  β”‚ 10 items β”‚  β”‚ 10 items β”‚                                 β”‚
β”‚   β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜                                 β”‚
β”‚        β”‚             β”‚             β”‚                                        β”‚
β”‚        β–Ό             β–Ό             β–Ό                                        β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
β”‚   β”‚ ops.downloadAssets(batch) ──▢ repository.fetchListAndSave   β”‚          β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                                β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         DUAL PERSISTENCE                                    β”‚
β”‚                                                                             β”‚
β”‚   ops.addAllFromReadingList(assetList, savedDates)                         β”‚
β”‚                                                                             β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”‚
β”‚   β”‚     Room DB        β”‚          β”‚    FreshItemsFlow          β”‚           β”‚
β”‚   β”‚                    β”‚          β”‚    (Instant emission)      β”‚           β”‚
β”‚   β”‚ savedArticleDao    β”‚          β”‚                            β”‚           β”‚
β”‚   β”‚ .insertOrReplaceAllβ”‚          β”‚ _freshItemsFlow.emit(items)β”‚           β”‚
β”‚   β”‚                    β”‚          β”‚                            β”‚           β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚
β”‚             β”‚                                   β”‚                           β”‚
β”‚             β”‚  merge(freshFlow, roomFlow)       β”‚                           β”‚
β”‚             β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                           β”‚
β”‚             β”‚  .distinctUntilChanged()                                      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                              UI UPDATE                                      β”‚
β”‚                                                                             β”‚
β”‚   getSavedArticlesFlow() merges:                                           β”‚
β”‚     - freshItemsFlow (instant, bypasses Room delay)                        β”‚
β”‚     - roomFlow (source of truth, ~16ms delay)                              β”‚
β”‚                                                                             β”‚
β”‚   UI receives items immediately when batch completes                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. Auth Flow: Login β†’ Logout

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          LOGIN FLOW                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

User Login ──▢ hasMeaningfullyChangedFlow emits
                     β”‚
                     β–Ό
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚ ActivityGridViewModel   β”‚
          β”‚ loadingCoordinator      β”‚
          β”‚ .configureAuth()        β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      β”‚
                      β–Ό
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚ handleAuthChange()                                  β”‚
          β”‚                                                     β”‚
          β”‚ if (!isUserRegistered) reset()  ──▢ Clear state    β”‚
          β”‚ else tryLoad(LoadType.FULL)     ──▢ Start sync     β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      β”‚
                      β–Ό
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚ SavedManager.syncCacheSuspending()                  β”‚
          β”‚                                                     β”‚
          β”‚  syncMutex.withLock {                              β”‚
          β”‚    _syncState = Syncing                             β”‚
          β”‚    assetSynchronizer.sync(                          β”‚
          β”‚      onFirstBatchReady = { _syncState = Synced }   β”‚
          β”‚    )                                                β”‚
          β”‚  }                                                  β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      β”‚
                      β–Ό
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚ Room auto-emits via InvalidationTracker             β”‚
          β”‚ OR freshItemsFlow emits immediately                 β”‚
          β”‚                                                     β”‚
          β”‚ combine(savedItemsFlow, syncState) emits new state  β”‚
          β”‚                                                     β”‚
          β”‚ UI: Loading ──▢ Content                             β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          LOGOUT FLOW                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

User Logout ──▢ hasMeaningfullyChangedFlow emits
                      β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚                                            β”‚
        β–Ό                                            β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Clear ETag Cache          β”‚          β”‚ SavedManager.deleteCache()       β”‚
β”‚                           β”‚          β”‚                                  β”‚
β”‚ SubauthListener clears    β”‚          β”‚ 1. ops.deleteCache()             β”‚
β”‚ ProgramHeadersHolder_*    β”‚          β”‚ 2. savedArticleDao.clearAll()    β”‚
β”‚ from SharedPreferences    β”‚          β”‚ 3. flyWeight.deleteFile()        β”‚
β”‚                           β”‚          β”‚ 4. _syncState = NotSynced        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

3. Multi-Layer Cache Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    4-LAYER CACHE ARCHITECTURE                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LAYER 1: Apollo HTTP Cache (ETag/304)                                       β”‚
β”‚ ══════════════════════════════════════                                      β”‚
β”‚                                                                             β”‚
β”‚ Purpose: Bandwidth optimization - skip download if server data unchanged    β”‚
β”‚                                                                             β”‚
β”‚ Request: If-None-Match: <etag>                                              β”‚
β”‚ Response: 304 Not Modified (no body) OR 200 + new data                      β”‚
β”‚                                                                             β”‚
β”‚ Storage: SharedPreferences (ProgramHeadersHolder_*)                         β”‚
β”‚ Cleared: SubauthListener.onLogout()                                         β”‚
β”‚                                                                             β”‚
β”‚ ⚠️ PROBLEM: If not cleared on logout, next login gets 304 with empty cache  β”‚
β”‚    SOLUTION: Clear ETag headers atomically with local cache                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                    β”‚
                                    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LAYER 2: Room Database (SQLite)                                             β”‚
β”‚ ═══════════════════════════════                                             β”‚
β”‚                                                                             β”‚
β”‚ Purpose: Persistent storage, fast queries, auto-invalidation                β”‚
β”‚                                                                             β”‚
β”‚ Table: saved_articles                                                       β”‚
β”‚ Columns: url (PK), uri, saved_date, title, image_url, summary, etc.        β”‚
β”‚                                                                             β”‚
β”‚ Key Methods:                                                                β”‚
β”‚   - pagingSource(): PagingSource<Int, SavedArticleEntity>  (for pagination)β”‚
β”‚   - getSavedArticlesFlow(limit): Flow<List<SavedArticleEntity>> (reactive) β”‚
β”‚                                                                             β”‚
β”‚ Auto-invalidation: Room's InvalidationTracker notifies Flows (~16ms delay) β”‚
β”‚ Cleared: savedArticleDao.clearAll()                                         β”‚
β”‚                                                                             β”‚
β”‚ βœ… BENEFIT: Source of truth, survives process death                         β”‚
β”‚ ⚠️ CONCERN: InvalidationTracker delay causes brief "empty" flash            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                    β”‚
                                    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LAYER 3: FreshItemsFlow (In-Memory MutableSharedFlow)                       β”‚
β”‚ ════════════════════════════════════════════════════                        β”‚
β”‚                                                                             β”‚
β”‚ Purpose: Bypass Room delay, instant UI updates on batch insert              β”‚
β”‚                                                                             β”‚
β”‚ Implementation:                                                             β”‚
β”‚   private val _freshItemsFlow = MutableSharedFlow<List<SavedArticleEntity>>(β”‚
β”‚       replay = 1,                                                           β”‚
β”‚       extraBufferCapacity = 10                                              β”‚
β”‚   )                                                                         β”‚
β”‚                                                                             β”‚
β”‚ Emission: Called immediately after savedArticleDao.insertOrReplaceAll()    β”‚
β”‚                                                                             β”‚
β”‚ Merge Pattern:                                                              β”‚
β”‚   merge(freshItemsFlow, roomFlow).distinctUntilChanged()                   β”‚
β”‚                                                                             β”‚
β”‚ βœ… BENEFIT: UI sees data instantly, no 16ms delay                           β”‚
β”‚ βœ… BENEFIT: Room still source of truth once InvalidationTracker fires       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                    β”‚
                                    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LAYER 4: SavedListFlyWeight (In-Memory + JSON File)                         β”‚
β”‚ ═══════════════════════════════════════════════════                         β”‚
β”‚                                                                             β”‚
β”‚ Purpose: Track pending operations (add/delete queues) for offline support   β”‚
β”‚                                                                             β”‚
β”‚ State: syncedItems, itemsToAdd, itemsToDelete, itemsToDeleteQueue          β”‚
β”‚ Persistence: JSON file on disk (survives process death)                     β”‚
β”‚ Cleared: flyWeight.deleteFile()                                             β”‚
β”‚                                                                             β”‚
β”‚ βœ… BENEFIT: Tracks operations that haven't synced to server yet             β”‚
β”‚ ⚠️ NOTE: Being phased out - Room should be single source of truth          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    CACHE INVALIDATION SEQUENCE                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

On Logout - ALL LAYERS MUST CLEAR ATOMICALLY:

  1. ETag Headers    ──▢ SubauthListener clears SharedPreferences
  2. Room Database   ──▢ savedArticleDao.clearAll()
  3. FreshItemsFlow  ──▢ Emit empty list
  4. FlyWeight       ──▢ flyWeight.deleteFile()
  5. SyncState       ──▢ _syncState = NotSynced

  If ANY layer retains data while others clear ──▢ CACHE INCOHERENCE BUG

Component Responsibilities

Component Layer Responsibility
SavedManager Facade Public API, sync state machine, exposes Flows
AssetSynchronizer Sync Engine Parallel batch download, progress tracking, mutex protection
LowLevelOperations Operations CRUD, bridges repositories, emits to freshItemsFlow
GraphQLReadingListRepository Network Paginated GraphQL, 304/ETag handling
SavedArticleDao Persistence Room queries, auto-invalidating Flows/PagingSource
SavedListFlyWeight Queue Pending operation tracking (legacy, being deprecated)

State Management Architecture

Sync State Machine

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       App Start ──▢│   NotSynced   │◀─────────────────────┐
                    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                      β”‚
                            β”‚                              β”‚
                    syncCacheSuspending()            deleteCache()
                            β”‚                              β”‚
                            β–Ό                              β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                      β”‚
                    β”‚    Syncing    │───────────────────────
                    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                      β”‚
                            β”‚                              β”‚
                  onFirstBatchReady()                      β”‚
                            β”‚                              β”‚
                            β–Ό                              β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                      β”‚
                    β”‚    Synced     β”‚β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

UI State Derivation Pattern

sealed class SavedScreenState {
    data object Loading : SavedScreenState()
    data class Content(val items: List<UserStoredStoryListItem>) : SavedScreenState()
    data object Empty : SavedScreenState()
}

val screenState: StateFlow<SavedScreenState> = combine(
    savedManager.getSavedArticlesFlow(limit),
    savedManager.syncState
) { items, syncState ->
    when {
        items.isNotEmpty() -> SavedScreenState.Content(items.map { it.toUI() })
        syncState != SavedSyncState.Synced -> SavedScreenState.Loading
        else -> SavedScreenState.Empty
    }
}.stateIn(viewModelScope, SharingStarted.Eagerly, SavedScreenState.Loading)

Key Decisions:

  1. Content takes precedence - If Room has data, show it immediately regardless of sync state
  2. Loading only when empty AND syncing - Don't flash loading if cache exists
  3. Empty only when sync confirms - Never show empty until sync says "done with 0 items"
  4. Eagerly started - Flow subscription starts immediately to catch first emissions

Display Patterns

ActivityGrid (24-item batch display)

// ActivityGridViewModel
val savedGridItemsFlow: StateFlow<List<ActivityGridItem>> =
    savedManager.getSavedArticlesFlow(ACTIVITY_GRID_DISPLAY_LIMIT) // 24
        .map { entities -> entities.map { it.toActivityGridItem() } }
        .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())

val savedGridDisplayState: StateFlow<SavedGridDisplayState> = combine(
    savedGridItemsFlow,
    savedManager.syncState
) { items, syncState ->
    when {
        items.isNotEmpty() -> SavedGridDisplayState.Content(items)
        syncState != SavedSyncState.Synced -> SavedGridDisplayState.Loading
        else -> SavedGridDisplayState.Empty
    }
}.stateIn(viewModelScope, SharingStarted.Lazily, SavedGridDisplayState.Loading)

SavedContent (Full pagination)

// SavedViewModel - Option A: Manual PagingData (Recommended)
val savedItems: StateFlow<PagingData<UserStoredStoryListItem>> =
    savedManager.getSavedArticlesFlow(SAVED_ITEMS_LIMIT) // 500
        .map { entities ->
            PagingData.from(entities.map { it.toUserStoredStoryListItem() })
        }
        .stateIn(viewModelScope, SharingStarted.Eagerly, PagingData.empty())

// SavedViewModel - Option B: Room PagingSource (Has timing issues)
val savedItems = savedManager.getSavedArticlePager(PAGE_SIZE)
    .flow
    .map { pagingData -> pagingData.map { it.toUserStoredStoryListItem() } }
    .cachedIn(viewModelScope)  // ⚠️ cachedIn can cause stale cache on auth change

Recommendation: Use Option A because:

  • getSavedArticlesFlow merges freshItemsFlow for instant updates
  • No cachedIn means no stale cache issues on auth change
  • PagingData.from() converts List to PagingData for LazyPagingItems compatibility

Decision Rationale

Decision Problem It Solves How It Helps
Merge freshItemsFlow + roomFlow Room InvalidationTracker has ~16ms delay UI sees data instantly when batch completes
Semaphore-limited parallel batches Network congestion, memory pressure BATCH_SIZE=10, MAX_CONCURRENT=3 balances speed vs resources
NonCancellable sync context User navigates away mid-sync Sync completes even if ViewModel cleared
onFirstBatchReady callback Users wait too long for first content Shows partial data while rest downloads
StateFlow with Eagerly Race condition: subscribe after first emit Collector active before data arrives
Clear ETag on logout 304 response with empty local cache Fresh fetch for new user session
PagingData.from() instead of PagingSource PagingSource timing inconsistent with freshItemsFlow Both patterns use same merged Flow
combine(items, syncState) for screen state Multiple Flows cause state flickering Single derived state prevents intermediate states

Code Patterns

Pattern 1: Parallel Batch Download with Progress

// AssetSynchronizer.kt
internal suspend fun syncAssets(
    downloadedAssetIndexList: List<SavedAssetIndex>,
    onFirstBatchReady: () -> Unit = {}
) {
    val assetsToDownload = downloadedAssetIndexList - savedArticleList.syncedItems
    if (assetsToDownload.isEmpty()) {
        onFirstBatchReady()
        return
    }

    val batches = assetsToDownload.chunked(BATCH_SIZE)
    val semaphore = Semaphore(MAX_CONCURRENT_BATCHES)
    val totalDownloaded = AtomicInteger(0)
    val firstBatchFired = AtomicBoolean(false)

    coroutineScope {
        batches.mapIndexed { index, batch ->
            async {
                semaphore.withPermit {
                    val (assetList, success) = ops.downloadAssets(batch)
                    if (success && assetList.isNotEmpty()) {
                        // Write to Room + emit to freshItemsFlow
                        ops.addAllFromReadingList(assetList, batch.map { it.savedDate })

                        // Track progress
                        val downloaded = totalDownloaded.addAndGet(assetList.size)
                        publishProgress(downloaded, assetsToDownload.size)

                        // Signal first batch ready (UI can start showing content)
                        if (firstBatchFired.compareAndSet(false, true)) {
                            onFirstBatchReady()
                        }
                    }
                }
            }
        }.awaitAll()
    }
}

Pattern 2: Merged Flow for Instant Updates

// SavedManager.kt
fun getSavedArticlesFlow(limit: Int): Flow<List<SavedArticleEntity>> {
    // Instant updates from batch inserts (bypasses Room delay)
    val freshFlow: Flow<List<SavedArticleEntity>> = ops.freshItemsFlow
        .map { items -> items.take(limit) }

    // Source of truth from Room (has ~16ms delay)
    val roomFlow: Flow<List<SavedArticleEntity>> = savedArticleDao.getSavedArticlesFlow(limit)

    // Merge: UI gets whichever emits first, dedup prevents double-render
    return merge(freshFlow, roomFlow).distinctUntilChanged()
}

// LowLevelOperations.kt
suspend fun addAllFromReadingList(assets: List<Asset>, savedDates: List<String>) {
    val roomEntities = assets.mapIndexed { index, asset ->
        asset.toSavedArticleEntity(savedDates[index])
    }

    // Write to Room
    savedArticleDao.insertOrReplaceAll(roomEntities)

    // IMMEDIATELY emit to freshItemsFlow (UI sees data now, not after 16ms)
    _freshItemsFlow.emit(roomEntities)
}

Pattern 3: Atomic Cache Invalidation

// SavedManager.kt
suspend fun deleteCache() {
    withContext(ioDispatcher) {
        // 1. Clear all layers atomically
        ops.deleteCache()              // Asset files + FlyWeight
        savedArticleDao.clearAll()     // Room
        ops.emitFreshItems(emptyList()) // Clear freshItemsFlow

        // 2. Update state LAST (UI transitions to NotSynced)
        _syncState.value = SavedSyncState.NotSynced
    }
}

// SubauthListener (separate component, must coordinate)
override fun onLogout(logoutSource: LogoutSource) {
    // Clear ETag headers so next login gets 200, not 304
    val keysToRemove = sharedPreferences.all.keys.filter {
        it.startsWith("ProgramHeadersHolder_")
    }
    sharedPreferences.edit {
        keysToRemove.forEach { remove(it) }
    }
}

Pattern 4: Compose State Collection

// SavedContent.kt
@Composable
fun SavedContent(viewModel: SavedViewModel = hiltViewModel()) {
    val screenState by viewModel.screenState.collectAsState()
    val savedItems = viewModel.savedItems.collectAsLazyPagingItems()

    when (val state = screenState) {
        is SavedScreenState.Loading -> {
            CircularProgressIndicator()
        }
        is SavedScreenState.Empty -> {
            EmptyState("You have no saved content yet")
        }
        is SavedScreenState.Content -> {
            // Use LazyPagingItems for efficient list rendering
            LazyColumn {
                items(
                    count = savedItems.itemCount,
                    key = savedItems.itemKey { it.uri }
                ) { index ->
                    savedItems[index]?.let { item ->
                        SavedItemRow(item)
                    }
                }
            }
        }
    }
}

Verification Checklist

Sync Flow

  • Login triggers sync automatically via auth subscription
  • First batch appears within 2 seconds of login
  • Progress indicator shows during multi-batch sync
  • All batches download (not just first 10-12 items)

Cache Coherence

  • Logout clears all 4 cache layers atomically
  • Login after logout gets HTTP 200 (not 304)
  • Different user login shows different user's data
  • Same user re-login shows cached data instantly

UI States

  • Content shows immediately if Room has data
  • Loading shows only when syncing AND no cache
  • Empty shows only when sync complete with 0 items
  • Pull-to-refresh triggers sync and shows indicator

Edge Cases

  • App kill during sync β†’ resumes correctly on restart
  • Network failure during batch β†’ partial data visible, retry works
  • Rapid login/logout cycles don't cause race conditions

Files Reference

File Purpose
saved-manager/.../SavedManager.kt Public API, state machine
saved-manager/.../AssetSynchronizer.kt Batch download orchestration
saved-manager/.../LowLevelOperations.kt CRUD, freshItemsFlow emission
saved-manager/.../GraphQLReadingListRepository.kt Network, 304 handling
saved-manager/.../db/SavedArticleDao.kt Room queries
you-tab/.../SavedViewModel.kt UI state derivation
you-tab/.../ActivityGridViewModel.kt Grid display state
you-tab/.../SavedContent.kt Compose UI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment