Skip to content

Commit ea83471

Browse files
committed
Refactor Android progress surfaces
1 parent 06a9af5 commit ea83471

13 files changed

Lines changed: 2519 additions & 2165 deletions

apps/android/data/local/src/main/java/com/flashcardsopensourceapp/data/local/repository/LocalProgressRepository.kt

Lines changed: 74 additions & 1105 deletions
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.flashcardsopensourceapp.data.local.repository
2+
3+
internal class ProgressLocalCacheReadinessCoordinator(
4+
private val localProgressCacheStore: LocalProgressCacheStore,
5+
private val timeProvider: TimeProvider
6+
) {
7+
private val localCacheRebuildCoordinator = ProgressLocalCacheRebuildCoordinator()
8+
9+
suspend fun ensureLocalCacheReady(
10+
timeZone: String
11+
) {
12+
// If another caller is already rebuilding the cache for this timezone, await its result
13+
// instead of returning early. Returning early would let a follow-up refresh see
14+
// isLocalCacheReady = false and silently bail, dropping the user-initiated refresh.
15+
val lease = localCacheRebuildCoordinator.acquireRebuildLease(timeZone = timeZone)
16+
when (lease) {
17+
is ProgressLocalCacheRebuildLease.Waiter -> {
18+
lease.inFlight.await()
19+
}
20+
is ProgressLocalCacheRebuildLease.Owner -> {
21+
runOwnedLocalCacheRebuild(timeZone = timeZone, lease = lease)
22+
}
23+
}
24+
}
25+
26+
private suspend fun runOwnedLocalCacheRebuild(
27+
timeZone: String,
28+
lease: ProgressLocalCacheRebuildLease.Owner
29+
) {
30+
var failure: Throwable? = null
31+
try {
32+
localProgressCacheStore.rebuildTimeZoneCache(
33+
timeZone = timeZone,
34+
updatedAtMillis = timeProvider.currentTimeMillis()
35+
)
36+
} catch (error: Throwable) {
37+
failure = error
38+
throw error
39+
} finally {
40+
// completeRebuild is invoked in the finally block so concurrent waiters always
41+
// observe completion. If the rebuild failed or was cancelled, the same throwable
42+
// propagates to waiters so they do not silently see an empty cache.
43+
localCacheRebuildCoordinator.completeRebuild(
44+
timeZone = timeZone,
45+
completion = lease.completion,
46+
error = failure
47+
)
48+
}
49+
}
50+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.flashcardsopensourceapp.data.local.repository
2+
3+
import com.flashcardsopensourceapp.data.local.cloud.CloudPreferencesStore
4+
import com.flashcardsopensourceapp.data.local.database.AppDatabase
5+
import com.flashcardsopensourceapp.data.local.database.OutboxEntryEntity
6+
import com.flashcardsopensourceapp.data.local.database.ProgressLocalCacheStateEntity
7+
import com.flashcardsopensourceapp.data.local.database.ProgressLocalDayCountEntity
8+
import com.flashcardsopensourceapp.data.local.database.ProgressReviewHistoryStateEntity
9+
import com.flashcardsopensourceapp.data.local.database.ProgressReviewScheduleCacheEntity
10+
import com.flashcardsopensourceapp.data.local.database.ProgressReviewScheduleCardDueEntity
11+
import com.flashcardsopensourceapp.data.local.database.ProgressSeriesCacheEntity
12+
import com.flashcardsopensourceapp.data.local.database.ProgressSummaryCacheEntity
13+
import com.flashcardsopensourceapp.data.local.database.SyncStateEntity
14+
import com.flashcardsopensourceapp.data.local.database.WorkspaceEntity
15+
import com.flashcardsopensourceapp.data.local.model.CloudSettings
16+
import com.flashcardsopensourceapp.data.local.model.SyncStatusSnapshot
17+
import kotlinx.coroutines.flow.Flow
18+
import kotlinx.coroutines.flow.combine
19+
20+
internal data class ProgressObservedInputs(
21+
val cloudSettings: CloudSettings,
22+
val workspaces: List<WorkspaceEntity>,
23+
val localDayCounts: List<ProgressLocalDayCountEntity>,
24+
val reviewHistoryStates: List<ProgressReviewHistoryStateEntity>,
25+
val localCacheStates: List<ProgressLocalCacheStateEntity>,
26+
val reviewScheduleCards: List<ProgressReviewScheduleCardDueEntity>,
27+
val pendingReviewOutboxEntries: List<OutboxEntryEntity>,
28+
val pendingCardUpsertOutboxEntries: List<OutboxEntryEntity>,
29+
val syncStates: List<SyncStateEntity>,
30+
val syncStatus: SyncStatusSnapshot,
31+
val summaryCaches: List<ProgressSummaryCacheEntity>,
32+
val seriesCaches: List<ProgressSeriesCacheEntity>,
33+
val reviewScheduleCaches: List<ProgressReviewScheduleCacheEntity>
34+
)
35+
36+
private data class ProgressObservedBaseInputs(
37+
val cloudSettings: CloudSettings,
38+
val workspaces: List<WorkspaceEntity>,
39+
val localDayCounts: List<ProgressLocalDayCountEntity>,
40+
val reviewHistoryStates: List<ProgressReviewHistoryStateEntity>,
41+
val localCacheStates: List<ProgressLocalCacheStateEntity>,
42+
val reviewScheduleCards: List<ProgressReviewScheduleCardDueEntity>,
43+
val pendingReviewOutboxEntries: List<OutboxEntryEntity>,
44+
val pendingCardUpsertOutboxEntries: List<OutboxEntryEntity>,
45+
val syncStates: List<SyncStateEntity>,
46+
val syncStatus: SyncStatusSnapshot
47+
)
48+
49+
private data class ProgressLocalCacheObservedInputs(
50+
val localDayCounts: List<ProgressLocalDayCountEntity>,
51+
val reviewHistoryStates: List<ProgressReviewHistoryStateEntity>,
52+
val localCacheStates: List<ProgressLocalCacheStateEntity>
53+
)
54+
55+
private data class ProgressPrimaryObservedInputs(
56+
val cloudSettings: CloudSettings,
57+
val workspaces: List<WorkspaceEntity>,
58+
val localCacheInputs: ProgressLocalCacheObservedInputs,
59+
val reviewScheduleCards: List<ProgressReviewScheduleCardDueEntity>
60+
)
61+
62+
private data class ProgressSyncObservedInputs(
63+
val pendingReviewOutboxEntries: List<OutboxEntryEntity>,
64+
val pendingCardUpsertOutboxEntries: List<OutboxEntryEntity>,
65+
val syncStates: List<SyncStateEntity>,
66+
val syncStatus: SyncStatusSnapshot
67+
)
68+
69+
private data class ProgressObservedSummaryInputs(
70+
val baseInputs: ProgressObservedBaseInputs,
71+
val summaryCaches: List<ProgressSummaryCacheEntity>
72+
)
73+
74+
private data class ProgressObservedSeriesInputs(
75+
val summaryInputs: ProgressObservedSummaryInputs,
76+
val seriesCaches: List<ProgressSeriesCacheEntity>
77+
)
78+
79+
internal fun observeProgressInputs(
80+
database: AppDatabase,
81+
preferencesStore: CloudPreferencesStore,
82+
syncRepository: SyncRepository
83+
): Flow<ProgressObservedInputs> {
84+
val localCacheDao = database.progressLocalCacheDao()
85+
val remoteCacheDao = database.progressRemoteCacheDao()
86+
val localCacheInputsFlow = combine(
87+
localCacheDao.observeProgressLocalDayCounts(),
88+
localCacheDao.observeProgressReviewHistoryStates(),
89+
localCacheDao.observeProgressLocalCacheStates()
90+
) { localDayCounts, reviewHistoryStates, localCacheStates ->
91+
ProgressLocalCacheObservedInputs(
92+
localDayCounts = localDayCounts,
93+
reviewHistoryStates = reviewHistoryStates,
94+
localCacheStates = localCacheStates
95+
)
96+
}
97+
val primaryInputsFlow = combine(
98+
preferencesStore.observeCloudSettings(),
99+
database.workspaceDao().observeWorkspaces(),
100+
localCacheInputsFlow,
101+
database.cardDao().observeProgressReviewScheduleCardDueDates()
102+
) { cloudSettings, workspaces, localCacheInputs, reviewScheduleCards ->
103+
ProgressPrimaryObservedInputs(
104+
cloudSettings = cloudSettings,
105+
workspaces = workspaces,
106+
localCacheInputs = localCacheInputs,
107+
reviewScheduleCards = reviewScheduleCards
108+
)
109+
}
110+
val syncInputsFlow = combine(
111+
database.outboxDao().observePendingReviewEventOutboxEntries(),
112+
database.outboxDao().observePendingReviewScheduleCardUpsertOutboxEntries(),
113+
database.syncStateDao().observeSyncStates(),
114+
syncRepository.observeSyncStatus()
115+
) { pendingReviewOutboxEntries, pendingCardUpsertOutboxEntries, syncStates, syncStatus ->
116+
ProgressSyncObservedInputs(
117+
pendingReviewOutboxEntries = pendingReviewOutboxEntries,
118+
pendingCardUpsertOutboxEntries = pendingCardUpsertOutboxEntries,
119+
syncStates = syncStates,
120+
syncStatus = syncStatus
121+
)
122+
}
123+
val baseInputsFlow = combine(
124+
primaryInputsFlow,
125+
syncInputsFlow
126+
) { primaryInputs, syncInputs ->
127+
ProgressObservedBaseInputs(
128+
cloudSettings = primaryInputs.cloudSettings,
129+
workspaces = primaryInputs.workspaces,
130+
localDayCounts = primaryInputs.localCacheInputs.localDayCounts,
131+
reviewHistoryStates = primaryInputs.localCacheInputs.reviewHistoryStates,
132+
localCacheStates = primaryInputs.localCacheInputs.localCacheStates,
133+
reviewScheduleCards = primaryInputs.reviewScheduleCards,
134+
pendingReviewOutboxEntries = syncInputs.pendingReviewOutboxEntries,
135+
pendingCardUpsertOutboxEntries = syncInputs.pendingCardUpsertOutboxEntries,
136+
syncStates = syncInputs.syncStates,
137+
syncStatus = syncInputs.syncStatus
138+
)
139+
}
140+
val summaryInputsFlow = baseInputsFlow.combine(
141+
remoteCacheDao.observeProgressSummaryCaches()
142+
) { baseInputs, summaryCaches ->
143+
ProgressObservedSummaryInputs(
144+
baseInputs = baseInputs,
145+
summaryCaches = summaryCaches
146+
)
147+
}
148+
149+
val seriesInputsFlow = summaryInputsFlow.combine(
150+
remoteCacheDao.observeProgressSeriesCaches()
151+
) { summaryInputs, seriesCaches ->
152+
ProgressObservedSeriesInputs(
153+
summaryInputs = summaryInputs,
154+
seriesCaches = seriesCaches
155+
)
156+
}
157+
158+
return seriesInputsFlow.combine(
159+
remoteCacheDao.observeProgressReviewScheduleCaches()
160+
) { seriesInputs, reviewScheduleCaches ->
161+
ProgressObservedInputs(
162+
cloudSettings = seriesInputs.summaryInputs.baseInputs.cloudSettings,
163+
workspaces = seriesInputs.summaryInputs.baseInputs.workspaces,
164+
localDayCounts = seriesInputs.summaryInputs.baseInputs.localDayCounts,
165+
reviewHistoryStates = seriesInputs.summaryInputs.baseInputs.reviewHistoryStates,
166+
localCacheStates = seriesInputs.summaryInputs.baseInputs.localCacheStates,
167+
reviewScheduleCards = seriesInputs.summaryInputs.baseInputs.reviewScheduleCards,
168+
pendingReviewOutboxEntries = seriesInputs.summaryInputs.baseInputs.pendingReviewOutboxEntries,
169+
pendingCardUpsertOutboxEntries = seriesInputs.summaryInputs.baseInputs.pendingCardUpsertOutboxEntries,
170+
syncStates = seriesInputs.summaryInputs.baseInputs.syncStates,
171+
syncStatus = seriesInputs.summaryInputs.baseInputs.syncStatus,
172+
summaryCaches = seriesInputs.summaryInputs.summaryCaches,
173+
seriesCaches = seriesInputs.seriesCaches,
174+
reviewScheduleCaches = reviewScheduleCaches
175+
)
176+
}
177+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.flashcardsopensourceapp.data.local.repository
2+
3+
import com.flashcardsopensourceapp.data.local.model.CloudAccountState
4+
import kotlinx.coroutines.CancellationException
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Job
7+
import kotlinx.coroutines.launch
8+
9+
internal enum class ProgressRefreshReason {
10+
MISSING_SERVER_BASE,
11+
LOCAL_CONTEXT_CHANGED,
12+
SYNC_COMPLETED_WITH_REVIEW_HISTORY_CHANGE,
13+
MANUAL
14+
}
15+
16+
internal class ProgressBackgroundLauncher(
17+
private val appScope: CoroutineScope
18+
) {
19+
// Single entry point for progress appScope launches. It re-throws
20+
// CancellationException to keep structured concurrency intact, and swallows any
21+
// other Exception after a structured warning. Errors bubble up to AppGraph's
22+
// CoroutineExceptionHandler.
23+
fun launchAndLogFailure(
24+
event: String,
25+
fields: List<Pair<String, String?>>,
26+
block: suspend () -> Unit
27+
): Job {
28+
return appScope.launch {
29+
try {
30+
block()
31+
} catch (error: CancellationException) {
32+
throw error
33+
} catch (error: Exception) {
34+
logProgressRepositoryWarning(
35+
event = event,
36+
fields = fields,
37+
error = error
38+
)
39+
}
40+
}
41+
}
42+
}
43+
44+
internal fun supportsServerRefresh(
45+
cloudState: CloudAccountState
46+
): Boolean {
47+
return cloudState == CloudAccountState.GUEST || cloudState == CloudAccountState.LINKED
48+
}
49+
50+
internal fun createProgressRemoteRefreshSyncMode(
51+
refreshReason: ProgressRefreshReason
52+
): ProgressRemoteRefreshSyncMode {
53+
return if (refreshReason == ProgressRefreshReason.SYNC_COMPLETED_WITH_REVIEW_HISTORY_CHANGE) {
54+
ProgressRemoteRefreshSyncMode.SKIP_SYNC
55+
} else {
56+
ProgressRemoteRefreshSyncMode.SYNC_BEFORE_REMOTE_LOAD
57+
}
58+
}

0 commit comments

Comments
 (0)