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>
This commit is contained in:
최민영 2026-08-31 11:05:25 +09:00
parent fbec743e5f
commit 39be1b71dc
7 changed files with 488 additions and 2 deletions

View File

@ -0,0 +1,119 @@
package com.ubisam.ubgitea.data.repositories
import android.content.Context
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.RepositorySummary
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Persists the most recently loaded 전체 저장소 (ALL mode, no organization filter) repository
* list to local disk, so a cold app launch with no network can still show *something* instead of
* a bare offline error. Deliberately narrow scope: only the one landing-page list is cached, not
* favorites/organization-filtered views, history, or file content -- those either change too
* often to be useful stale, or are one tap away from the cached list once connectivity returns.
*/
interface RepositoryListCache {
suspend fun read(): CachedRepositoryListDto?
suspend fun write(cache: CachedRepositoryListDto)
suspend fun clear()
}
data class CachedRepositoryListDto(
val accountName: String,
val cachedAtEpochMillis: Long,
val repositories: List<CachedRepositoryEntryDto>,
)
// A dedicated plain DTO rather than reusing RepositorySummary directly: RepositoryCoordinates and
// RepositoryRef both validate their contents through a private-constructor `of()` factory, which
// Gson's reflection-based (de)serialization bypasses entirely -- round-tripping the domain type
// straight through Gson would silently skip that validation. Every field here is a primitive/
// String, and going back to a domain RepositorySummary always re-runs the real validated factories
// (see toDomainOrNull below), so a hand-edited or format-drifted cache file can only ever fail to
// produce a row, never produce an invalid one.
data class CachedRepositoryEntryDto(
val owner: String,
val name: String,
val description: String?,
val defaultBranch: String?,
val isPrivate: Boolean,
val isFork: Boolean,
val updatedAtEpochMillis: Long,
val language: String?,
val starsCount: Int,
)
fun RepositorySummary.toCacheDto() = CachedRepositoryEntryDto(
owner = coordinates.owner,
name = coordinates.name,
description = description,
defaultBranch = defaultBranch?.value,
isPrivate = isPrivate,
isFork = isFork,
updatedAtEpochMillis = updatedAtEpochMillis,
language = language,
starsCount = starsCount,
)
// Null return (rather than throwing) skips just this one malformed entry -- matches this
// codebase's existing convention of never letting one bad item fail an entire cached list.
fun CachedRepositoryEntryDto.toDomainOrNull(): RepositorySummary? = runCatching {
RepositorySummary(
coordinates = RepositoryCoordinates.of(owner, name),
description = description,
defaultBranch = defaultBranch?.let(RepositoryRef::of),
isPrivate = isPrivate,
isFork = isFork,
updatedAtEpochMillis = updatedAtEpochMillis,
language = language,
starsCount = starsCount,
)
}.getOrNull()
class FileRepositoryListCache(context: Context) : RepositoryListCache {
private val gson = Gson()
private val file = File(context.filesDir, "repository_list_cache.json")
override suspend fun read(): CachedRepositoryListDto? = withContext(Dispatchers.IO) {
try {
if (!file.exists()) return@withContext null
file.reader().use { gson.fromJson(it, CachedRepositoryListDto::class.java) }
} catch (failure: Exception) {
// Corrupt file or a format change across app versions -- treated as "no cache", not a
// crash. This is a convenience layer; the network is always the source of truth.
if (failure is JsonSyntaxException || failure is java.io.IOException) null else throw failure
}
}
override suspend fun write(cache: CachedRepositoryListDto) {
withContext(Dispatchers.IO) {
try {
file.writer().use { gson.toJson(cache, it) }
} catch (failure: java.io.IOException) {
// Best-effort persistence -- failing to write the cache must never surface as a
// failure of the (already successful) network load that triggered this write.
}
}
}
// Called on logout: 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.
override suspend fun clear() {
withContext(Dispatchers.IO) {
runCatching { file.delete() }
}
}
}
/** Used where no cache should be consulted at all -- keeps every pre-existing test/call site compiling. */
object NoOpRepositoryListCache : RepositoryListCache {
override suspend fun read(): CachedRepositoryListDto? = null
override suspend fun write(cache: CachedRepositoryListDto) = Unit
override suspend fun clear() = Unit
}

View File

@ -260,6 +260,7 @@ private fun RepositoryContent(
OverflowMenuButton(onViewSettings, onViewNotifications, onLogout) OverflowMenuButton(onViewSettings, onViewNotifications, onLogout)
} }
} }
state.cachedAtEpochMillis?.let { cachedAt -> item { OfflineCacheBanner(cachedAt) } }
item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) } item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) }
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) { if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
item { OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization) } item { OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization) }
@ -318,6 +319,27 @@ private fun RepositoryContent(
internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String = internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String =
"${coordinates.owner}/${coordinates.name}" "${coordinates.owner}/${coordinates.name}"
// Shown only when this Content came from the on-disk offline cache (RepositoryListViewModel sets
// cachedAtEpochMillis exactly then, never on a live response) -- a full-width banner rather than a
// small badge, since "you might be looking at stale data" is a stronger, page-level statement than
// one repository's visibility. Uses the theme's error-adjacent tertiary-container tone (not the
// affirmative primary green VisibilityBadge/StatusBadge use elsewhere) to read as a caution, not a
// normal state, per product.md's "never obscure which Gitea instance and account are active."
@Composable
private fun OfflineCacheBanner(cachedAtEpochMillis: Long) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
// A compact colored badge instead of a full-width plain-text line (which previously had no // A compact colored badge instead of a full-width plain-text line (which previously had no
// explicit style at all, so it rendered at LocalTextStyle's default size -- larger and more // explicit style at all, so it rendered at LocalTextStyle's default size -- larger and more
// visually prominent than the metadata lines around it, an unintentional inconsistency). Private // visually prominent than the metadata lines around it, an unintentional inconsistency). Private

View File

@ -18,7 +18,13 @@ import com.ubisam.ubgitea.data.auth.AuthApi
import com.ubisam.ubgitea.data.auth.AuthenticationGateway import com.ubisam.ubgitea.data.auth.AuthenticationGateway
import com.ubisam.ubgitea.data.auth.TokenVault import com.ubisam.ubgitea.data.auth.TokenVault
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
import com.ubisam.ubgitea.data.repositories.CachedRepositoryListDto
import com.ubisam.ubgitea.data.repositories.FileRepositoryListCache
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
import com.ubisam.ubgitea.data.repositories.NoOpRepositoryListCache
import com.ubisam.ubgitea.data.repositories.RepositoryListCache
import com.ubisam.ubgitea.data.repositories.toCacheDto
import com.ubisam.ubgitea.data.repositories.toDomainOrNull
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositorySummary import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
@ -51,6 +57,10 @@ sealed interface RepositoryListUiState {
val mode: RepositoryListMode, val mode: RepositoryListMode,
val organization: String?, val organization: String?,
organizations: List<OrganizationSummary>, organizations: List<OrganizationSummary>,
// Non-null only when this Content was served from the on-disk offline cache instead of a
// live Gitea response (see RepositoryListCache) -- the UI shows a small "오프라인" banner
// whenever this is set, per product.md's "never obscure which account/data is active."
val cachedAtEpochMillis: Long? = null,
) : RepositoryListUiState { ) : RepositoryListUiState {
val accountName: String = accountName val accountName: String = accountName
val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList()) val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList())
@ -150,6 +160,10 @@ class RepositoryListViewModel(
// explicitly passes the shared refresh-aware provider instead. // explicitly passes the shared refresh-aware provider instead.
private val tokenProvider: BearerTokenProvider = BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() }, private val tokenProvider: BearerTokenProvider = BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() },
private val now: () -> Long = System::currentTimeMillis, private val now: () -> Long = System::currentTimeMillis,
// Defaulted to a no-op (never reads or writes a cache) so every existing caller/test keeps
// compiling and behaving exactly as before; production wiring (Factory, below) passes the
// real file-backed implementation instead.
private val repositoryListCache: RepositoryListCache = NoOpRepositoryListCache,
) : ViewModel() { ) : ViewModel() {
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config) private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config) private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
@ -421,6 +435,7 @@ class RepositoryListViewModel(
startOperation { startOperation {
pendingAuthStore.clear() pendingAuthStore.clear()
tokenVault.clear() tokenVault.clear()
repositoryListCache.clear()
activeAccountName = null activeAccountName = null
hasAuthenticatedSession = false hasAuthenticatedSession = false
repositoryContent = null repositoryContent = null
@ -476,8 +491,14 @@ class RepositoryListViewModel(
activeAccountName = null activeAccountName = null
hasAuthenticatedSession = false hasAuthenticatedSession = false
} }
val error = failure.toRepositoryListError()
// A cold launch with no network fails right here, at the very first call this app
// makes (fetching the signed-in user) -- before loadRepositoryPage's own catch block
// (below) ever gets a chance to serve the offline cache. Tried here too so "no network
// at app start" and "network drops mid-session" behave the same way.
if (error == RepositoryListError.OFFLINE && tryServeCachedRepositories()) return
mutableState.value = RepositoryListUiState.Error( mutableState.value = RepositoryListUiState.Error(
failure.toRepositoryListError(), error,
activeAccountName, activeAccountName,
hasAuthenticatedSession, hasAuthenticatedSession,
) )
@ -496,6 +517,7 @@ class RepositoryListViewModel(
mutableState.value = RepositoryListUiState.Loading mutableState.value = RepositoryListUiState.Loading
try { try {
val repositories = loadRepositorySnapshot(mode, organization) val repositories = loadRepositorySnapshot(mode, organization)
persistRepositoryCacheIfApplicable(accountName, mode, organization, repositories)
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE) val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList() val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList()
@ -529,14 +551,75 @@ class RepositoryListViewModel(
hasAuthenticatedSession = false hasAuthenticatedSession = false
repositoryContent = null repositoryContent = null
} }
val error = failure.toRepositoryListError()
val servedFromCache = error == RepositoryListError.OFFLINE &&
mode == RepositoryListMode.ALL && organization == null &&
tryServeCachedRepositories(page)
if (servedFromCache) return
mutableState.value = RepositoryListUiState.Error( mutableState.value = RepositoryListUiState.Error(
failure.toRepositoryListError(), error,
accountName, accountName,
hasAuthenticatedSession, hasAuthenticatedSession,
) )
} }
} }
// Falls back to the on-disk offline cache (see RepositoryListCache) once a live fetch of the
// ALL/no-organization view has already failed with a network-classified error. Seeds
// repositorySnapshots so next/previous page navigation keeps working purely from the cached
// list for the rest of this offline session, exactly like a live-loaded snapshot would.
// Returns false (leaving the caller to show its normal offline error) when there's no usable
// cache -- never empty, never wrong -- for this exact page.
private suspend fun tryServeCachedRepositories(page: Int = 1): Boolean {
val cached = repositoryListCache.read() ?: return false
val repositories = cached.repositories.mapNotNull { it.toDomainOrNull() }
if (repositories.isEmpty()) return false
val key = RepositorySnapshotKey(RepositoryListMode.ALL, null)
repositorySnapshots[key] = repositories
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
// lastPushedBranch starts Failed, not the default Loading -- a real per-row network fetch
// would just fail immediately anyway with no connectivity, and Loading would show a
// spinner that never resolves. The existing per-row retry action already lets a row try
// again once connectivity is back.
val rows = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
.map { it.toUiModel().copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
if (rows.isEmpty()) return false
activeAccountName = cached.accountName
hasAuthenticatedSession = true
val content = RepositoryListUiState.Content(
cached.accountName,
rows,
page,
pageStart + rows.size < repositories.size,
RepositoryListMode.ALL,
null,
emptyList(),
cachedAtEpochMillis = cached.cachedAtEpochMillis,
)
repositoryContent = content
mutableState.value = content
return true
}
// Only the ALL/no-organization-filter view is ever persisted -- see RepositoryListCache's own
// doc comment for why. Called every time that exact view's snapshot is available, live-fetched
// or already in-memory; write() itself is cheap (a small local JSON file) and best-effort.
private suspend fun persistRepositoryCacheIfApplicable(
accountName: String,
mode: RepositoryListMode,
organization: String?,
repositories: List<RepositorySummary>,
) {
if (mode != RepositoryListMode.ALL || organization != null) return
repositoryListCache.write(
CachedRepositoryListDto(
accountName = accountName,
cachedAtEpochMillis = now(),
repositories = repositories.map { it.toCacheDto() },
),
)
}
private suspend fun loadRepositorySnapshot( private suspend fun loadRepositorySnapshot(
mode: RepositoryListMode, mode: RepositoryListMode,
organization: String?, organization: String?,
@ -676,6 +759,7 @@ class RepositoryListViewModel(
current.mode, current.mode,
current.organization, current.organization,
current.organizations, current.organizations,
cachedAtEpochMillis = current.cachedAtEpochMillis,
).also { ).also {
repositoryContent = it repositoryContent = it
mutableState.value = it mutableState.value = it
@ -767,6 +851,7 @@ class RepositoryListViewModel(
favoritesApi = browser, favoritesApi = browser,
organizationsApi = browser, organizationsApi = browser,
tokenProvider = AuthSession.tokenProvider(application), tokenProvider = AuthSession.tokenProvider(application),
repositoryListCache = FileRepositoryListCache(application),
) as T ) as T
} }
} }

View File

@ -42,6 +42,7 @@
<string name="repositories_tab_favorites">즐겨찾기</string> <string name="repositories_tab_favorites">즐겨찾기</string>
<string name="repositories_favorites_empty">즐겨찾기한 저장소가 없습니다.</string> <string name="repositories_favorites_empty">즐겨찾기한 저장소가 없습니다.</string>
<string name="repositories_favorites_empty_action">전체 저장소 보기</string> <string name="repositories_favorites_empty_action">전체 저장소 보기</string>
<string name="repositories_offline_cached_banner">오프라인 상태입니다. 마지막 확인: %1$s</string>
<string name="repositories_favorite">즐겨찾기</string> <string name="repositories_favorite">즐겨찾기</string>
<string name="repositories_unfavorite">즐겨찾기 해제</string> <string name="repositories_unfavorite">즐겨찾기 해제</string>
<string name="repositories_favorited">즐겨찾기됨</string> <string name="repositories_favorited">즐겨찾기됨</string>

View File

@ -7,6 +7,9 @@ import com.ubisam.ubgitea.data.auth.AuthenticatedUser
import com.ubisam.ubgitea.data.auth.AuthenticationGateway import com.ubisam.ubgitea.data.auth.AuthenticationGateway
import com.ubisam.ubgitea.data.auth.TokenSet import com.ubisam.ubgitea.data.auth.TokenSet
import com.ubisam.ubgitea.data.auth.TokenVault import com.ubisam.ubgitea.data.auth.TokenVault
import com.ubisam.ubgitea.data.repositories.CachedRepositoryEntryDto
import com.ubisam.ubgitea.data.repositories.CachedRepositoryListDto
import com.ubisam.ubgitea.data.repositories.RepositoryListCache
import com.ubisam.ubgitea.domain.model.BranchSummary import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.Page import com.ubisam.ubgitea.domain.model.Page
@ -664,6 +667,95 @@ class RepositoryListViewModelTest {
assertEquals(listOf("starred-repo"), state.repositories.map { it.name }) assertEquals(listOf("starred-repo"), state.repositories.map { it.name })
} }
@Test
fun `a successful ALL-mode load persists the repository list to the offline cache`() = runTest(dispatcher.scheduler) {
val cache = FakeRepositoryListCache()
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
val viewModel = viewModel(browser, cache = cache)
advanceUntilIdle()
assertEquals(1, cache.writes.size)
assertEquals("account", cache.writes.single().accountName)
assertEquals(listOf("repo"), cache.writes.single().repositories.map { it.name })
}
@Test
fun `restoring the session while offline serves the cached repository list instead of failing`() = runTest(dispatcher.scheduler) {
val cache = FakeRepositoryListCache(
CachedRepositoryListDto(
accountName = "cached-account",
cachedAtEpochMillis = 500L,
repositories = listOf(
CachedRepositoryEntryDto(
owner = "owner",
name = "cached-repo",
description = null,
defaultBranch = "main",
isPrivate = true,
isFork = false,
updatedAtEpochMillis = 1_000L,
language = null,
starsCount = 0,
),
),
),
)
val offlineAuthGateway = object : AuthenticationGateway {
override suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long) =
error("not used")
override suspend fun refreshToken(refreshToken: String, nowEpochMillis: Long) = error("not used")
override suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser =
throw ApiFailure.Network(java.io.IOException())
}
val viewModel = RepositoryListViewModel(
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
pendingAuthStore = FakePendingStore(),
authApi = offlineAuthGateway,
browserApi = FakeBrowser(),
favoritesApi = FakeBrowser(),
organizationsApi = FakeBrowser(),
now = { 1_000L },
repositoryListCache = cache,
)
advanceUntilIdle()
val content = viewModel.state.value as RepositoryListUiState.Content
assertEquals("cached-account", content.accountName)
assertEquals(listOf("cached-repo"), content.repositories.map { it.name })
assertEquals(500L, content.cachedAtEpochMillis)
}
@Test
fun `logging out clears the offline cache so a signed-out launch never shows a previous account`() = runTest(dispatcher.scheduler) {
val cache = FakeRepositoryListCache()
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
val viewModel = viewModel(browser, cache = cache)
advanceUntilIdle()
assertEquals(1, cache.writes.size)
viewModel.logout()
advanceUntilIdle()
assertTrue(cache.cleared)
assertEquals(null, cache.read())
}
private class FakeRepositoryListCache(
private var stored: CachedRepositoryListDto? = null,
) : RepositoryListCache {
val writes = mutableListOf<CachedRepositoryListDto>()
var cleared = false
override suspend fun read() = stored
override suspend fun write(cache: CachedRepositoryListDto) {
stored = cache
writes += cache
}
override suspend fun clear() {
stored = null
cleared = true
}
}
private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null) private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null)
private fun repo(name: String, updatedAtEpochMillis: Long = 1_000L, starsCount: Int = 0) = RepositorySummary( private fun repo(name: String, updatedAtEpochMillis: Long = 1_000L, starsCount: Int = 0) = RepositorySummary(
@ -679,6 +771,7 @@ class RepositoryListViewModelTest {
private fun viewModel( private fun viewModel(
browser: FakeBrowser, browser: FakeBrowser,
pendingStore: PendingAuthTransactionStore = FakePendingStore(), pendingStore: PendingAuthTransactionStore = FakePendingStore(),
cache: RepositoryListCache = FakeRepositoryListCache(),
): RepositoryListViewModel = RepositoryListViewModel( ): RepositoryListViewModel = RepositoryListViewModel(
tokenVault = FakeTokenVault(TokenSet("access", null, null)), tokenVault = FakeTokenVault(TokenSet("access", null, null)),
pendingAuthStore = pendingStore, pendingAuthStore = pendingStore,
@ -687,6 +780,7 @@ class RepositoryListViewModelTest {
favoritesApi = browser, favoritesApi = browser,
organizationsApi = browser, organizationsApi = browser,
now = { 1_000L }, now = { 1_000L },
repositoryListCache = cache,
) )
private class FakeTokenVault(var tokens: TokenSet?) : TokenVault { private class FakeTokenVault(var tokens: TokenSet?) : TokenVault {

View File

@ -967,3 +967,48 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Next recommended task: no further design-review gaps currently queued; continuing to watch for live - Next recommended task: no further design-review gaps currently queued; continuing to watch for live
bug reports and awaiting further direction from the human product owner. bug reports and awaiting further direction from the human product owner.
- Next owner: Claude, continuing per the standing direction. - Next owner: Claude, continuing per the standing direction.
## 2026-08-29 (continued) — TASK-044: offline cache for the 전체 저장소 repository list
- Agent and role: Claude, direct continuation of the same live session. Human product owner picked
"오프라인 캐싱" from `docs/product.md`'s three open product decisions when asked what to build next
after the MVP feature set.
- Commit/diff: on `claude/task-015-notifications`; new `data/repositories/RepositoryListCache.kt`;
`feature/repositories/RepositoryListViewModel.kt` and `RepositoryListScreen.kt` gained the
fallback/persist wiring and the offline banner; `strings_repositories.xml` gained
`repositories_offline_cached_banner`; `RepositoryListViewModelTest.kt` gained three new tests;
`tasks/044-offline-repository-list-cache.md`.
- How it was implemented: a file-backed `RepositoryListCache` (Gson + `Dispatchers.IO`, writing to
`context.filesDir`) persists the ALL/no-organization-filter repository list after every successful
load. A dedicated plain DTO (`CachedRepositoryEntryDto`) is used instead of the domain
`RepositorySummary` directly, since `RepositoryCoordinates`/`RepositoryRef` validate through a
private-constructor `of()` factory that Gson's reflection-based (de)serialization would otherwise
bypass entirely — converting back to a domain object always re-runs the real validated factory, so
a corrupted cache file can only ever drop a row, never produce an invalid one. `restoreSession`'s
and `loadRepositoryPage`'s catch blocks both fall back to the cache (seeding
`repositorySnapshots` so pagination keeps working purely from it) whenever a live fetch of that
exact view fails with a network-classified error; a page-level banner
("오프라인 상태입니다. 마지막 확인: ...") makes the fallback impossible to mistake for a live view.
`logout()` now also clears the cache, so a cached list can never outlive the session that fetched
it. Deliberately scoped to just this one landing view — not favorites, organization filters,
branches/files/history/work items/notifications, all of which keep their existing (uncached)
offline error behavior unchanged.
- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug`
BUILD SUCCESSFUL, all unit tests passing (including three new ones covering persist-on-load,
offline-restore-from-cache, and clear-on-logout), 0 lint errors.
- Live verification: installed over wireless `adb`, confirmed a normal online load first (no banner).
Human product owner enabled airplane mode, force-stopped and relaunched the app themselves, and
confirmed the offline banner plus the last-loaded repository list both appeared correctly. They
also confirmed the pre-existing behavior for tapping into a repository while offline (branches/
files aren't cached) still shows the normal offline error rather than crashing, then asked whether
to extend caching to repository internals — decided, with their explicit confirmation, to hold that
off and keep the current list-only scope, recorded in the task file's Known limitations.
- Review findings and resolutions: None from a separate review pass.
- Known limitations: repository internals (branches/tags/files/history) intentionally not cached, per
the human product owner's direct decision after testing; favorites/organization-filtered views fall
back to the existing non-cached offline error by design.
- Next recommended task: this closes another of `docs/product.md`'s three open product decisions
(visual identity was already closed by TASK-039; offline caching by this task). The remaining one —
whether biometric reauthentication is required — is a product decision for the human product owner,
not something to build unprompted. No further design-review gaps currently queued.
- Next owner: Claude, continuing per the standing direction.

View File

@ -0,0 +1,120 @@
# 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.