# 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 — no Codex review pending (Codex retired, 2026-09-29).