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>
7.7 KiB
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;RepositoryListViewModelfalls 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-levelOfflineCacheBannerinRepositoryListScreen; 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
RepositorySummarydirectly.RepositoryCoordinatesandRepositoryRefboth validate through a private-constructorof()factory; Gson's reflection-based (de)serialization bypasses that validation entirely when round-tripping a domain type straight through it.CachedRepositoryEntryDtoholds only primitives/Strings, and converting back to a domainRepositorySummaryalways 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.
lastPushedBranchstartsFailed, not the defaultLoading, for cache-served rows. A real per-row branch fetch would just fail immediately anyway with no connectivity; leaving it atLoadingwould 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.
RepositoryListViewModelgained arepositoryListCache: RepositoryListCache = NoOpRepositoryListCacheparameter andRepositoryListUiState.ContentgainedcachedAtEpochMillis: Long? = null— both defaulted trailing parameters, so every pre-existing call site and test kept compiling unchanged; only the productionFactoryand 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:RepositoryListCacheinterface,CachedRepositoryListDto/CachedRepositoryEntryDtoDTOs,FileRepositoryListCache(Gson +Dispatchers.IO, writes tocontext.filesDir),NoOpRepositoryListCache. feature/repositories/RepositoryListViewModel.kt:Content.cachedAtEpochMillis;persistRepositoryCacheIfApplicable(write path);tryServeCachedRepositories(read/fallback path), called from bothrestoreSession's andloadRepositoryPage's catch blocks;logout()now also callsrepositoryListCache.clear().feature/repositories/RepositoryListScreen.kt: newOfflineCacheBannercomposable, shown whenstate.cachedAtEpochMillis != null.strings_repositories.xml: newrepositories_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 andcachedAtEpochMillis; logout clears the cache — plus every pre-existing test passing unmodified (the newrepositoryListCacheconstructor parameter andContent.cachedAtEpochMillisfield 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.