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:
parent
fbec743e5f
commit
39be1b71dc
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
120
tasks/044-offline-repository-list-cache.md
Normal file
120
tasks/044-offline-repository-list-cache.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user