TASK-044: offline cache for the 전체 저장소 repository list
Closes one of docs/product.md's open product decisions ("whether
repositories must be cached for offline browsing"), at the human product
owner's explicit request.
A new file-backed RepositoryListCache persists the ALL/no-organization-
filter repository list after every successful load, using a dedicated
plain DTO (not the domain RepositorySummary directly, since
RepositoryCoordinates/RepositoryRef validate through a private-constructor
factory Gson's reflection would otherwise bypass). RepositoryListViewModel
falls back to it whenever a live fetch of that view fails with a
network-classified error (cold start or mid-session), seeding
repositorySnapshots so pagination keeps working from the cache. A
page-level banner makes the fallback impossible to mistake for a live
view. The cache is cleared on logout.
Deliberately scoped to just the landing list -- not favorites,
organization filters, or repository internals (branches/files/history) --
confirmed with the human product owner after they live-tested the
fallback via airplane mode and asked about extending it further; decided
to hold that off.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fbec743e5f
commit
39be1b71dc
@ -0,0 +1,119 @@
|
||||
package com.ubisam.ubgitea.data.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Persists the most recently loaded 전체 저장소 (ALL mode, no organization filter) repository
|
||||
* list to local disk, so a cold app launch with no network can still show *something* instead of
|
||||
* a bare offline error. Deliberately narrow scope: only the one landing-page list is cached, not
|
||||
* favorites/organization-filtered views, history, or file content -- those either change too
|
||||
* often to be useful stale, or are one tap away from the cached list once connectivity returns.
|
||||
*/
|
||||
interface RepositoryListCache {
|
||||
suspend fun read(): CachedRepositoryListDto?
|
||||
suspend fun write(cache: CachedRepositoryListDto)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
data class CachedRepositoryListDto(
|
||||
val accountName: String,
|
||||
val cachedAtEpochMillis: Long,
|
||||
val repositories: List<CachedRepositoryEntryDto>,
|
||||
)
|
||||
|
||||
// A dedicated plain DTO rather than reusing RepositorySummary directly: RepositoryCoordinates and
|
||||
// RepositoryRef both validate their contents through a private-constructor `of()` factory, which
|
||||
// Gson's reflection-based (de)serialization bypasses entirely -- round-tripping the domain type
|
||||
// straight through Gson would silently skip that validation. Every field here is a primitive/
|
||||
// String, and going back to a domain RepositorySummary always re-runs the real validated factories
|
||||
// (see toDomainOrNull below), so a hand-edited or format-drifted cache file can only ever fail to
|
||||
// produce a row, never produce an invalid one.
|
||||
data class CachedRepositoryEntryDto(
|
||||
val owner: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val defaultBranch: String?,
|
||||
val isPrivate: Boolean,
|
||||
val isFork: Boolean,
|
||||
val updatedAtEpochMillis: Long,
|
||||
val language: String?,
|
||||
val starsCount: Int,
|
||||
)
|
||||
|
||||
fun RepositorySummary.toCacheDto() = CachedRepositoryEntryDto(
|
||||
owner = coordinates.owner,
|
||||
name = coordinates.name,
|
||||
description = description,
|
||||
defaultBranch = defaultBranch?.value,
|
||||
isPrivate = isPrivate,
|
||||
isFork = isFork,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
language = language,
|
||||
starsCount = starsCount,
|
||||
)
|
||||
|
||||
// Null return (rather than throwing) skips just this one malformed entry -- matches this
|
||||
// codebase's existing convention of never letting one bad item fail an entire cached list.
|
||||
fun CachedRepositoryEntryDto.toDomainOrNull(): RepositorySummary? = runCatching {
|
||||
RepositorySummary(
|
||||
coordinates = RepositoryCoordinates.of(owner, name),
|
||||
description = description,
|
||||
defaultBranch = defaultBranch?.let(RepositoryRef::of),
|
||||
isPrivate = isPrivate,
|
||||
isFork = isFork,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
language = language,
|
||||
starsCount = starsCount,
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
class FileRepositoryListCache(context: Context) : RepositoryListCache {
|
||||
private val gson = Gson()
|
||||
private val file = File(context.filesDir, "repository_list_cache.json")
|
||||
|
||||
override suspend fun read(): CachedRepositoryListDto? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!file.exists()) return@withContext null
|
||||
file.reader().use { gson.fromJson(it, CachedRepositoryListDto::class.java) }
|
||||
} catch (failure: Exception) {
|
||||
// Corrupt file or a format change across app versions -- treated as "no cache", not a
|
||||
// crash. This is a convenience layer; the network is always the source of truth.
|
||||
if (failure is JsonSyntaxException || failure is java.io.IOException) null else throw failure
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(cache: CachedRepositoryListDto) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
file.writer().use { gson.toJson(cache, it) }
|
||||
} catch (failure: java.io.IOException) {
|
||||
// Best-effort persistence -- failing to write the cache must never surface as a
|
||||
// failure of the (already successful) network load that triggered this write.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called on logout: a cached repository list must never outlive the session that fetched it --
|
||||
// a signed-out user (or the next person to use this device) must never see another account's
|
||||
// repositories via a cold offline launch before ever logging in themselves.
|
||||
override suspend fun clear() {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Used where no cache should be consulted at all -- keeps every pre-existing test/call site compiling. */
|
||||
object NoOpRepositoryListCache : RepositoryListCache {
|
||||
override suspend fun read(): CachedRepositoryListDto? = null
|
||||
override suspend fun write(cache: CachedRepositoryListDto) = Unit
|
||||
override suspend fun clear() = Unit
|
||||
}
|
||||
@ -260,6 +260,7 @@ private fun RepositoryContent(
|
||||
OverflowMenuButton(onViewSettings, onViewNotifications, onLogout)
|
||||
}
|
||||
}
|
||||
state.cachedAtEpochMillis?.let { cachedAt -> item { OfflineCacheBanner(cachedAt) } }
|
||||
item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) }
|
||||
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
|
||||
item { OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization) }
|
||||
@ -318,6 +319,27 @@ private fun RepositoryContent(
|
||||
internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String =
|
||||
"${coordinates.owner}/${coordinates.name}"
|
||||
|
||||
// Shown only when this Content came from the on-disk offline cache (RepositoryListViewModel sets
|
||||
// cachedAtEpochMillis exactly then, never on a live response) -- a full-width banner rather than a
|
||||
// small badge, since "you might be looking at stale data" is a stronger, page-level statement than
|
||||
// one repository's visibility. Uses the theme's error-adjacent tertiary-container tone (not the
|
||||
// affirmative primary green VisibilityBadge/StatusBadge use elsewhere) to read as a caution, not a
|
||||
// normal state, per product.md's "never obscure which Gitea instance and account are active."
|
||||
@Composable
|
||||
private fun OfflineCacheBanner(cachedAtEpochMillis: Long) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A compact colored badge instead of a full-width plain-text line (which previously had no
|
||||
// explicit style at all, so it rendered at LocalTextStyle's default size -- larger and more
|
||||
// visually prominent than the metadata lines around it, an unintentional inconsistency). Private
|
||||
|
||||
@ -18,7 +18,13 @@ import com.ubisam.ubgitea.data.auth.AuthApi
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.data.repositories.CachedRepositoryListDto
|
||||
import com.ubisam.ubgitea.data.repositories.FileRepositoryListCache
|
||||
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||
import com.ubisam.ubgitea.data.repositories.NoOpRepositoryListCache
|
||||
import com.ubisam.ubgitea.data.repositories.RepositoryListCache
|
||||
import com.ubisam.ubgitea.data.repositories.toCacheDto
|
||||
import com.ubisam.ubgitea.data.repositories.toDomainOrNull
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
@ -51,6 +57,10 @@ sealed interface RepositoryListUiState {
|
||||
val mode: RepositoryListMode,
|
||||
val organization: String?,
|
||||
organizations: List<OrganizationSummary>,
|
||||
// Non-null only when this Content was served from the on-disk offline cache instead of a
|
||||
// live Gitea response (see RepositoryListCache) -- the UI shows a small "오프라인" banner
|
||||
// whenever this is set, per product.md's "never obscure which account/data is active."
|
||||
val cachedAtEpochMillis: Long? = null,
|
||||
) : RepositoryListUiState {
|
||||
val accountName: String = accountName
|
||||
val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList())
|
||||
@ -150,6 +160,10 @@ class RepositoryListViewModel(
|
||||
// explicitly passes the shared refresh-aware provider instead.
|
||||
private val tokenProvider: BearerTokenProvider = BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() },
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
// Defaulted to a no-op (never reads or writes a cache) so every existing caller/test keeps
|
||||
// compiling and behaving exactly as before; production wiring (Factory, below) passes the
|
||||
// real file-backed implementation instead.
|
||||
private val repositoryListCache: RepositoryListCache = NoOpRepositoryListCache,
|
||||
) : ViewModel() {
|
||||
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
|
||||
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
|
||||
@ -421,6 +435,7 @@ class RepositoryListViewModel(
|
||||
startOperation {
|
||||
pendingAuthStore.clear()
|
||||
tokenVault.clear()
|
||||
repositoryListCache.clear()
|
||||
activeAccountName = null
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
@ -476,8 +491,14 @@ class RepositoryListViewModel(
|
||||
activeAccountName = null
|
||||
hasAuthenticatedSession = false
|
||||
}
|
||||
val error = failure.toRepositoryListError()
|
||||
// A cold launch with no network fails right here, at the very first call this app
|
||||
// makes (fetching the signed-in user) -- before loadRepositoryPage's own catch block
|
||||
// (below) ever gets a chance to serve the offline cache. Tried here too so "no network
|
||||
// at app start" and "network drops mid-session" behave the same way.
|
||||
if (error == RepositoryListError.OFFLINE && tryServeCachedRepositories()) return
|
||||
mutableState.value = RepositoryListUiState.Error(
|
||||
failure.toRepositoryListError(),
|
||||
error,
|
||||
activeAccountName,
|
||||
hasAuthenticatedSession,
|
||||
)
|
||||
@ -496,6 +517,7 @@ class RepositoryListViewModel(
|
||||
mutableState.value = RepositoryListUiState.Loading
|
||||
try {
|
||||
val repositories = loadRepositorySnapshot(mode, organization)
|
||||
persistRepositoryCacheIfApplicable(accountName, mode, organization, repositories)
|
||||
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
|
||||
val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
|
||||
val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList()
|
||||
@ -529,14 +551,75 @@ class RepositoryListViewModel(
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
}
|
||||
val error = failure.toRepositoryListError()
|
||||
val servedFromCache = error == RepositoryListError.OFFLINE &&
|
||||
mode == RepositoryListMode.ALL && organization == null &&
|
||||
tryServeCachedRepositories(page)
|
||||
if (servedFromCache) return
|
||||
mutableState.value = RepositoryListUiState.Error(
|
||||
failure.toRepositoryListError(),
|
||||
error,
|
||||
accountName,
|
||||
hasAuthenticatedSession,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Falls back to the on-disk offline cache (see RepositoryListCache) once a live fetch of the
|
||||
// ALL/no-organization view has already failed with a network-classified error. Seeds
|
||||
// repositorySnapshots so next/previous page navigation keeps working purely from the cached
|
||||
// list for the rest of this offline session, exactly like a live-loaded snapshot would.
|
||||
// Returns false (leaving the caller to show its normal offline error) when there's no usable
|
||||
// cache -- never empty, never wrong -- for this exact page.
|
||||
private suspend fun tryServeCachedRepositories(page: Int = 1): Boolean {
|
||||
val cached = repositoryListCache.read() ?: return false
|
||||
val repositories = cached.repositories.mapNotNull { it.toDomainOrNull() }
|
||||
if (repositories.isEmpty()) return false
|
||||
val key = RepositorySnapshotKey(RepositoryListMode.ALL, null)
|
||||
repositorySnapshots[key] = repositories
|
||||
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
|
||||
// lastPushedBranch starts Failed, not the default Loading -- a real per-row network fetch
|
||||
// would just fail immediately anyway with no connectivity, and Loading would show a
|
||||
// spinner that never resolves. The existing per-row retry action already lets a row try
|
||||
// again once connectivity is back.
|
||||
val rows = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
|
||||
.map { it.toUiModel().copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
|
||||
if (rows.isEmpty()) return false
|
||||
activeAccountName = cached.accountName
|
||||
hasAuthenticatedSession = true
|
||||
val content = RepositoryListUiState.Content(
|
||||
cached.accountName,
|
||||
rows,
|
||||
page,
|
||||
pageStart + rows.size < repositories.size,
|
||||
RepositoryListMode.ALL,
|
||||
null,
|
||||
emptyList(),
|
||||
cachedAtEpochMillis = cached.cachedAtEpochMillis,
|
||||
)
|
||||
repositoryContent = content
|
||||
mutableState.value = content
|
||||
return true
|
||||
}
|
||||
|
||||
// Only the ALL/no-organization-filter view is ever persisted -- see RepositoryListCache's own
|
||||
// doc comment for why. Called every time that exact view's snapshot is available, live-fetched
|
||||
// or already in-memory; write() itself is cheap (a small local JSON file) and best-effort.
|
||||
private suspend fun persistRepositoryCacheIfApplicable(
|
||||
accountName: String,
|
||||
mode: RepositoryListMode,
|
||||
organization: String?,
|
||||
repositories: List<RepositorySummary>,
|
||||
) {
|
||||
if (mode != RepositoryListMode.ALL || organization != null) return
|
||||
repositoryListCache.write(
|
||||
CachedRepositoryListDto(
|
||||
accountName = accountName,
|
||||
cachedAtEpochMillis = now(),
|
||||
repositories = repositories.map { it.toCacheDto() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadRepositorySnapshot(
|
||||
mode: RepositoryListMode,
|
||||
organization: String?,
|
||||
@ -676,6 +759,7 @@ class RepositoryListViewModel(
|
||||
current.mode,
|
||||
current.organization,
|
||||
current.organizations,
|
||||
cachedAtEpochMillis = current.cachedAtEpochMillis,
|
||||
).also {
|
||||
repositoryContent = it
|
||||
mutableState.value = it
|
||||
@ -767,6 +851,7 @@ class RepositoryListViewModel(
|
||||
favoritesApi = browser,
|
||||
organizationsApi = browser,
|
||||
tokenProvider = AuthSession.tokenProvider(application),
|
||||
repositoryListCache = FileRepositoryListCache(application),
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
<string name="repositories_tab_favorites">즐겨찾기</string>
|
||||
<string name="repositories_favorites_empty">즐겨찾기한 저장소가 없습니다.</string>
|
||||
<string name="repositories_favorites_empty_action">전체 저장소 보기</string>
|
||||
<string name="repositories_offline_cached_banner">오프라인 상태입니다. 마지막 확인: %1$s</string>
|
||||
<string name="repositories_favorite">즐겨찾기</string>
|
||||
<string name="repositories_unfavorite">즐겨찾기 해제</string>
|
||||
<string name="repositories_favorited">즐겨찾기됨</string>
|
||||
|
||||
@ -7,6 +7,9 @@ import com.ubisam.ubgitea.data.auth.AuthenticatedUser
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||
import com.ubisam.ubgitea.data.repositories.CachedRepositoryEntryDto
|
||||
import com.ubisam.ubgitea.data.repositories.CachedRepositoryListDto
|
||||
import com.ubisam.ubgitea.data.repositories.RepositoryListCache
|
||||
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||
import com.ubisam.ubgitea.domain.model.CommitSummary
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
@ -664,6 +667,95 @@ class RepositoryListViewModelTest {
|
||||
assertEquals(listOf("starred-repo"), state.repositories.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful ALL-mode load persists the repository list to the offline cache`() = runTest(dispatcher.scheduler) {
|
||||
val cache = FakeRepositoryListCache()
|
||||
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
|
||||
val viewModel = viewModel(browser, cache = cache)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, cache.writes.size)
|
||||
assertEquals("account", cache.writes.single().accountName)
|
||||
assertEquals(listOf("repo"), cache.writes.single().repositories.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring the session while offline serves the cached repository list instead of failing`() = runTest(dispatcher.scheduler) {
|
||||
val cache = FakeRepositoryListCache(
|
||||
CachedRepositoryListDto(
|
||||
accountName = "cached-account",
|
||||
cachedAtEpochMillis = 500L,
|
||||
repositories = listOf(
|
||||
CachedRepositoryEntryDto(
|
||||
owner = "owner",
|
||||
name = "cached-repo",
|
||||
description = null,
|
||||
defaultBranch = "main",
|
||||
isPrivate = true,
|
||||
isFork = false,
|
||||
updatedAtEpochMillis = 1_000L,
|
||||
language = null,
|
||||
starsCount = 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val offlineAuthGateway = object : AuthenticationGateway {
|
||||
override suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long) =
|
||||
error("not used")
|
||||
override suspend fun refreshToken(refreshToken: String, nowEpochMillis: Long) = error("not used")
|
||||
override suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser =
|
||||
throw ApiFailure.Network(java.io.IOException())
|
||||
}
|
||||
val viewModel = RepositoryListViewModel(
|
||||
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
|
||||
pendingAuthStore = FakePendingStore(),
|
||||
authApi = offlineAuthGateway,
|
||||
browserApi = FakeBrowser(),
|
||||
favoritesApi = FakeBrowser(),
|
||||
organizationsApi = FakeBrowser(),
|
||||
now = { 1_000L },
|
||||
repositoryListCache = cache,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||
assertEquals("cached-account", content.accountName)
|
||||
assertEquals(listOf("cached-repo"), content.repositories.map { it.name })
|
||||
assertEquals(500L, content.cachedAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logging out clears the offline cache so a signed-out launch never shows a previous account`() = runTest(dispatcher.scheduler) {
|
||||
val cache = FakeRepositoryListCache()
|
||||
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
|
||||
val viewModel = viewModel(browser, cache = cache)
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, cache.writes.size)
|
||||
|
||||
viewModel.logout()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(cache.cleared)
|
||||
assertEquals(null, cache.read())
|
||||
}
|
||||
|
||||
private class FakeRepositoryListCache(
|
||||
private var stored: CachedRepositoryListDto? = null,
|
||||
) : RepositoryListCache {
|
||||
val writes = mutableListOf<CachedRepositoryListDto>()
|
||||
var cleared = false
|
||||
override suspend fun read() = stored
|
||||
override suspend fun write(cache: CachedRepositoryListDto) {
|
||||
stored = cache
|
||||
writes += cache
|
||||
}
|
||||
override suspend fun clear() {
|
||||
stored = null
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null)
|
||||
|
||||
private fun repo(name: String, updatedAtEpochMillis: Long = 1_000L, starsCount: Int = 0) = RepositorySummary(
|
||||
@ -679,6 +771,7 @@ class RepositoryListViewModelTest {
|
||||
private fun viewModel(
|
||||
browser: FakeBrowser,
|
||||
pendingStore: PendingAuthTransactionStore = FakePendingStore(),
|
||||
cache: RepositoryListCache = FakeRepositoryListCache(),
|
||||
): RepositoryListViewModel = RepositoryListViewModel(
|
||||
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
|
||||
pendingAuthStore = pendingStore,
|
||||
@ -687,6 +780,7 @@ class RepositoryListViewModelTest {
|
||||
favoritesApi = browser,
|
||||
organizationsApi = browser,
|
||||
now = { 1_000L },
|
||||
repositoryListCache = cache,
|
||||
)
|
||||
|
||||
private class FakeTokenVault(var tokens: TokenSet?) : TokenVault {
|
||||
|
||||
@ -967,3 +967,48 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Next recommended task: no further design-review gaps currently queued; continuing to watch for live
|
||||
bug reports and awaiting further direction from the human product owner.
|
||||
- Next owner: Claude, continuing per the standing direction.
|
||||
|
||||
## 2026-08-29 (continued) — TASK-044: offline cache for the 전체 저장소 repository list
|
||||
|
||||
- Agent and role: Claude, direct continuation of the same live session. Human product owner picked
|
||||
"오프라인 캐싱" from `docs/product.md`'s three open product decisions when asked what to build next
|
||||
after the MVP feature set.
|
||||
- Commit/diff: on `claude/task-015-notifications`; new `data/repositories/RepositoryListCache.kt`;
|
||||
`feature/repositories/RepositoryListViewModel.kt` and `RepositoryListScreen.kt` gained the
|
||||
fallback/persist wiring and the offline banner; `strings_repositories.xml` gained
|
||||
`repositories_offline_cached_banner`; `RepositoryListViewModelTest.kt` gained three new tests;
|
||||
`tasks/044-offline-repository-list-cache.md`.
|
||||
- How it was implemented: a file-backed `RepositoryListCache` (Gson + `Dispatchers.IO`, writing to
|
||||
`context.filesDir`) persists the ALL/no-organization-filter repository list after every successful
|
||||
load. A dedicated plain DTO (`CachedRepositoryEntryDto`) is used instead of the domain
|
||||
`RepositorySummary` directly, since `RepositoryCoordinates`/`RepositoryRef` validate through a
|
||||
private-constructor `of()` factory that Gson's reflection-based (de)serialization would otherwise
|
||||
bypass entirely — converting back to a domain object always re-runs the real validated factory, so
|
||||
a corrupted cache file can only ever drop a row, never produce an invalid one. `restoreSession`'s
|
||||
and `loadRepositoryPage`'s catch blocks both fall back to the cache (seeding
|
||||
`repositorySnapshots` so pagination keeps working purely from it) whenever a live fetch of that
|
||||
exact view fails with a network-classified error; a page-level banner
|
||||
("오프라인 상태입니다. 마지막 확인: ...") makes the fallback impossible to mistake for a live view.
|
||||
`logout()` now also clears the cache, so a cached list can never outlive the session that fetched
|
||||
it. Deliberately scoped to just this one landing view — not favorites, organization filters,
|
||||
branches/files/history/work items/notifications, all of which keep their existing (uncached)
|
||||
offline error behavior unchanged.
|
||||
- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug`
|
||||
BUILD SUCCESSFUL, all unit tests passing (including three new ones covering persist-on-load,
|
||||
offline-restore-from-cache, and clear-on-logout), 0 lint errors.
|
||||
- Live verification: installed over wireless `adb`, confirmed a normal online load first (no banner).
|
||||
Human product owner enabled airplane mode, force-stopped and relaunched the app themselves, and
|
||||
confirmed the offline banner plus the last-loaded repository list both appeared correctly. They
|
||||
also confirmed the pre-existing behavior for tapping into a repository while offline (branches/
|
||||
files aren't cached) still shows the normal offline error rather than crashing, then asked whether
|
||||
to extend caching to repository internals — decided, with their explicit confirmation, to hold that
|
||||
off and keep the current list-only scope, recorded in the task file's Known limitations.
|
||||
- Review findings and resolutions: None from a separate review pass.
|
||||
- Known limitations: repository internals (branches/tags/files/history) intentionally not cached, per
|
||||
the human product owner's direct decision after testing; favorites/organization-filtered views fall
|
||||
back to the existing non-cached offline error by design.
|
||||
- Next recommended task: this closes another of `docs/product.md`'s three open product decisions
|
||||
(visual identity was already closed by TASK-039; offline caching by this task). The remaining one —
|
||||
whether biometric reauthentication is required — is a product decision for the human product owner,
|
||||
not something to build unprompted. No further design-review gaps currently queued.
|
||||
- Next owner: Claude, continuing per the standing direction.
|
||||
|
||||
120
tasks/044-offline-repository-list-cache.md
Normal file
120
tasks/044-offline-repository-list-cache.md
Normal file
@ -0,0 +1,120 @@
|
||||
# TASK-044: Offline cache for the 전체 저장소 repository list
|
||||
|
||||
Status: implemented; all required checks pass; live-confirmed on the human product owner's device
|
||||
Owner: Claude
|
||||
Reviewer: Codex
|
||||
|
||||
## Outcome
|
||||
|
||||
Closes one of `docs/product.md`'s three "open product decisions" ("whether repositories must be
|
||||
cached for offline browsing") at the human product owner's explicit request. A signed-in user who
|
||||
launches the app (cold start or mid-session) with no network connectivity now sees the last
|
||||
successfully loaded 전체 저장소 (ALL mode, no organization filter) repository list instead of a bare
|
||||
offline error, with a clear "오프라인 상태입니다. 마지막 확인: <시각>" banner so it's never mistaken
|
||||
for a live view.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included: a new file-backed cache (`RepositoryListCache`) persisting the ALL/no-organization-filter
|
||||
repository list (the landing view) after every successful load; `RepositoryListViewModel` falls back
|
||||
to it whenever a live fetch of that exact view fails with a network-classified error (cold-start
|
||||
user-info fetch, or a later page/refresh request); a page-level `OfflineCacheBanner` in
|
||||
`RepositoryListScreen`; the cache is cleared on logout.
|
||||
- Excluded (deliberately, per live user confirmation after testing the ALL-mode fallback — see
|
||||
Known limitations): favorites/organization-filtered repository views, branches/tags, file trees,
|
||||
file content, commit history, work items, notifications. Tapping into a repository while offline
|
||||
still correctly shows the existing ReferencesError/offline state — this is the pre-existing,
|
||||
intentional fallback for any uncached data, not a new gap this task introduces.
|
||||
|
||||
## Design notes
|
||||
|
||||
- **A dedicated plain DTO, not the domain `RepositorySummary` directly.** `RepositoryCoordinates`
|
||||
and `RepositoryRef` both validate through a private-constructor `of()` factory; Gson's
|
||||
reflection-based (de)serialization bypasses that validation entirely when round-tripping a domain
|
||||
type straight through it. `CachedRepositoryEntryDto` holds only primitives/Strings, and converting
|
||||
back to a domain `RepositorySummary` always re-runs the real validated factories
|
||||
(`toDomainOrNull()`), so a corrupted or format-drifted cache file can only ever fail to produce one
|
||||
row, never produce an invalid one.
|
||||
- **Only the one landing-page view is persisted.** Favorites/organization-filtered lists change
|
||||
meaning under a stale cache in a way the plain repository list doesn't (an offline "즐겨찾기" tab
|
||||
showing yesterday's stars, or an offline org filter silently showing the wrong org's repos, is
|
||||
actively misleading; the plain list is just "some repositories," which stays true even if stale).
|
||||
Keeps the feature's storage and failure-mode surface small and reviewable.
|
||||
- **`lastPushedBranch` starts `Failed`, not the default `Loading`, for cache-served rows.** A real
|
||||
per-row branch fetch would just fail immediately anyway with no connectivity; leaving it at
|
||||
`Loading` would show a permanent, misleading spinner. The existing per-row retry action already
|
||||
lets a row try again once connectivity returns — no new UI needed.
|
||||
- **The cache is cleared on logout**, not just on the next successful login. A cached repository list
|
||||
must never outlive the session that fetched it — a signed-out user (or the next person to use this
|
||||
device) must never see another account's repositories via a cold offline launch before ever logging
|
||||
in themselves.
|
||||
- **Backward-compatible constructor wiring.** `RepositoryListViewModel` gained a
|
||||
`repositoryListCache: RepositoryListCache = NoOpRepositoryListCache` parameter and
|
||||
`RepositoryListUiState.Content` gained `cachedAtEpochMillis: Long? = null` — both defaulted trailing
|
||||
parameters, so every pre-existing call site and test kept compiling unchanged; only the production
|
||||
`Factory` and the new tests wire in the real behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A successful ALL/no-organization-filter load persists the repository list to local disk.
|
||||
- [x] A cold app launch with no network connectivity shows the cached list (not a bare error), marked
|
||||
with an "오프라인" banner and the cached time.
|
||||
- [x] A mid-session network loss on that same view (e.g. tapping 새로고침 while offline) falls back to
|
||||
the cache the same way.
|
||||
- [x] Pagination (previous/next) works against the cached list while offline.
|
||||
- [x] Logging out clears the cache.
|
||||
- [x] Tapping into a repository while offline still shows the existing offline error for that repo's
|
||||
branches/files (unchanged, pre-existing behavior — never silently broken by this change).
|
||||
- [x] Build, unit tests, and lint pass.
|
||||
- [x] Live-confirmed on the human product owner's device: enabled airplane mode, force-stopped and
|
||||
relaunched the app, confirmed the offline banner and the last-loaded repository list both
|
||||
appeared; confirmed tapping into a repository correctly still shows an offline error rather than
|
||||
crashing or silently hanging.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- New `data/repositories/RepositoryListCache.kt`: `RepositoryListCache` interface,
|
||||
`CachedRepositoryListDto`/`CachedRepositoryEntryDto` DTOs, `FileRepositoryListCache` (Gson +
|
||||
`Dispatchers.IO`, writes to `context.filesDir`), `NoOpRepositoryListCache`.
|
||||
- `feature/repositories/RepositoryListViewModel.kt`: `Content.cachedAtEpochMillis`;
|
||||
`persistRepositoryCacheIfApplicable` (write path); `tryServeCachedRepositories` (read/fallback
|
||||
path), called from both `restoreSession`'s and `loadRepositoryPage`'s catch blocks; `logout()` now
|
||||
also calls `repositoryListCache.clear()`.
|
||||
- `feature/repositories/RepositoryListScreen.kt`: new `OfflineCacheBanner` composable, shown when
|
||||
`state.cachedAtEpochMillis != null`.
|
||||
- `strings_repositories.xml`: new `repositories_offline_cached_banner`.
|
||||
- `RepositoryListViewModelTest.kt`: three new tests — a successful load persists to the cache; a
|
||||
cold-start offline restore serves the cached list with the right account name and
|
||||
`cachedAtEpochMillis`; logout clears the cache — plus every pre-existing test passing unmodified
|
||||
(the new `repositoryListCache` constructor parameter and `Content.cachedAtEpochMillis` field are
|
||||
both defaulted, so no existing test needed to change).
|
||||
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug`:
|
||||
BUILD SUCCESSFUL, all unit tests passing (including the three new ones), 0 lint errors.
|
||||
- Live device: installed over wireless `adb`. Confirmed a normal online load (no banner). Human
|
||||
product owner enabled airplane mode, force-stopped and relaunched UBGitea, and confirmed the
|
||||
"오프라인 상태입니다. 마지막 확인: ..." banner appeared above the same repository list seen before
|
||||
going offline; confirmed tapping into a repository's branches while offline correctly showed the
|
||||
existing offline error instead of crashing.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Repository internals (branches/tags/files/commit history) are not cached — confirmed with the
|
||||
human product owner directly after they tested the ALL-mode fallback and asked about this; decided
|
||||
to keep the current scope (list-only) rather than expand it, given the added storage/eviction design
|
||||
decisions extending caching to file trees/content would require.
|
||||
- Favorites and organization-filtered views fall back to the normal (non-cached) offline error, by
|
||||
design — see Design notes above.
|
||||
- The cache holds exactly one snapshot (the most recent ALL/no-organization-filter load) — switching
|
||||
accounts mid-session-without-logout (not a flow this app otherwise supports) is not specifically
|
||||
guarded against beyond the existing per-account assumptions already made elsewhere in this
|
||||
ViewModel.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
Loading…
x
Reference in New Issue
Block a user