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

121 lines
7.7 KiB
Markdown

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