From 39be1b71dc65266a9516a1f2797b99ca759466dd Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 31 Aug 2026 11:05:25 +0900 Subject: [PATCH] =?UTF-8?q?TASK-044:=20offline=20cache=20for=20the=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=A0=80=EC=9E=A5=EC=86=8C=20repository?= =?UTF-8?q?=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../data/repositories/RepositoryListCache.kt | 119 +++++++++++++++++ .../repositories/RepositoryListScreen.kt | 22 ++++ .../repositories/RepositoryListViewModel.kt | 89 ++++++++++++- .../main/res/values/strings_repositories.xml | 1 + .../RepositoryListViewModelTest.kt | 94 ++++++++++++++ docs/development-log.md | 45 +++++++ tasks/044-offline-repository-list-cache.md | 120 ++++++++++++++++++ 7 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListCache.kt create mode 100644 tasks/044-offline-repository-list-cache.md diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListCache.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListCache.kt new file mode 100644 index 0000000..762b61d --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListCache.kt @@ -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, +) + +// 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 +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index 4b91bc5..5336408 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -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 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt index ec4ac37..5ee58de 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt @@ -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, + // 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 = 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, + ) { + 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 } } diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml index a516e2d..c843c90 100644 --- a/app/src/main/res/values/strings_repositories.xml +++ b/app/src/main/res/values/strings_repositories.xml @@ -42,6 +42,7 @@ 즐겨찾기 즐겨찾기한 저장소가 없습니다. 전체 저장소 보기 + 오프라인 상태입니다. 마지막 확인: %1$s 즐겨찾기 즐겨찾기 해제 즐겨찾기됨 diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt index b0a4cb9..9630a48 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt @@ -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() + 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 { diff --git a/docs/development-log.md b/docs/development-log.md index e04153b..7ebe4fa 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/tasks/044-offline-repository-list-cache.md b/tasks/044-offline-repository-list-cache.md new file mode 100644 index 0000000..e14debf --- /dev/null +++ b/tasks/044-offline-repository-list-cache.md @@ -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.