Ubisam-MobileGit/tasks/044-offline-repository-list-cache.md
choiminyeong 39be1b71dc 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>
2026-08-31 11:05:25 +09:00

7.7 KiB

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

  • A successful ALL/no-organization-filter load persists the repository list to local disk.
  • A cold app launch with no network connectivity shows the cached list (not a bare error), marked with an "오프라인" banner and the cached time.
  • A mid-session network loss on that same view (e.g. tapping 새로고침 while offline) falls back to the cache the same way.
  • Pagination (previous/next) works against the cached list while offline.
  • Logging out clears the cache.
  • 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).
  • Build, unit tests, and lint pass.
  • 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

./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.