No task waits on a Codex review; the redesign tasks are confirmed on the product owner's Z Fold7; TASK-016's leftovers are on hold; no biometric reauthentication. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
7.7 KiB
Markdown
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 — no Codex review pending (Codex retired, 2026-09-29).
|