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.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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 β
βββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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 | 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) |
βββββββββββββββββ
App Start βββΆβ NotSynced ββββββββββββββββββββββββ
βββββββββ¬ββββββββ β
β β
syncCacheSuspending() deleteCache()
β β
βΌ β
βββββββββββββββββ β
β Syncing ββββββββββββββββββββββββ€
βββββββββ¬ββββββββ β
β β
onFirstBatchReady() β
β β
βΌ β
βββββββββββββββββ β
β Synced ββββββββββββββββββββββββ
βββββββββββββββββ
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:
- Content takes precedence - If Room has data, show it immediately regardless of sync state
- Loading only when empty AND syncing - Don't flash loading if cache exists
- Empty only when sync confirms - Never show empty until sync says "done with 0 items"
- Eagerly started - Flow subscription starts immediately to catch first emissions
// 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)// 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 changeRecommendation: Use Option A because:
getSavedArticlesFlowmergesfreshItemsFlowfor instant updates- No
cachedInmeans no stale cache issues on auth change PagingData.from()converts List to PagingData for LazyPagingItems compatibility
| 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 |
// 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()
}
}// 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)
}// 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) }
}
}// 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)
}
}
}
}
}
}- 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)
- 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
- 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
- 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
| 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 |