5 files changed
+118
-4
No files matched your search
@@ -63,6 +63,7 @@ data class SessionListResponse(
|
||||
val total: Int = 0,
|
||||
val limit: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val storage: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -711,6 +711,17 @@ fun SessionsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
state.sessions.isEmpty() && state.corruptStorageProfiles.isNotEmpty() -> {
|
||||
ErrorState(
|
||||
message =
|
||||
stringResource(
|
||||
R.string.sessions_storage_corrupt,
|
||||
state.corruptStorageProfiles.sorted().joinToString(", "),
|
||||
),
|
||||
onRetry = { viewModel.loadSessions(forceRefresh = true) },
|
||||
)
|
||||
}
|
||||
|
||||
state.sessions.isEmpty() -> {
|
||||
EmptyState(
|
||||
title =
|
||||
@@ -752,6 +763,18 @@ fun SessionsScreen(
|
||||
|
||||
else -> {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.corruptStorageProfiles.isNotEmpty()) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.sessions_storage_corrupt,
|
||||
state.corruptStorageProfiles.sorted().joinToString(", "),
|
||||
),
|
||||
color = statusColors.warning,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
)
|
||||
}
|
||||
// ── Stats row ───────────────────────────────────────
|
||||
SessionsStatsRow(
|
||||
total = state.total,
|
||||
|
||||
@@ -81,6 +81,7 @@ data class SessionsUiState(
|
||||
val total: Int = 0,
|
||||
val hasMore: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val corruptStorageProfiles: Set<String> = emptySet(),
|
||||
val stats: SessionStats = SessionStats(),
|
||||
val isLoadingStats: Boolean = false,
|
||||
val statsError: String? = null,
|
||||
@@ -197,6 +198,7 @@ class SessionsViewModel(
|
||||
val (requestScope, capturedSection) = scopeAndSection
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration == generation && currentScope == requestScope) {
|
||||
if (handleStorageHealth(data)) return@refreshOnChange
|
||||
val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
@@ -244,6 +246,7 @@ class SessionsViewModel(
|
||||
selectedIds = emptySet(),
|
||||
isSelecting = false,
|
||||
errorMessage = null,
|
||||
corruptStorageProfiles = emptySet(),
|
||||
searchResults = emptyList(),
|
||||
searchError = null,
|
||||
searchLoadMoreError = null,
|
||||
@@ -342,6 +345,7 @@ class SessionsViewModel(
|
||||
total = 0,
|
||||
hasMore = false,
|
||||
errorMessage = null,
|
||||
corruptStorageProfiles = emptySet(),
|
||||
isSelecting = false,
|
||||
selectedIds = emptySet(),
|
||||
isSearching = false,
|
||||
@@ -352,6 +356,26 @@ class SessionsViewModel(
|
||||
if (query.isBlank()) loadSessions() else setSearchQuery(query)
|
||||
}
|
||||
|
||||
// #1286: a partial/empty damaged-store page is not an authoritative replacement.
|
||||
private fun handleStorageHealth(data: SessionListResponse): Boolean {
|
||||
val corruptProfiles = data.storage.filterValues { it == "corrupt" }.keys
|
||||
_uiState.update { state ->
|
||||
if (corruptProfiles.isEmpty()) {
|
||||
state.copy(corruptStorageProfiles = emptySet())
|
||||
} else {
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
errorMessage = null,
|
||||
corruptStorageProfiles = corruptProfiles,
|
||||
sessions = (state.sessions + data.sessions).distinctBy { it.id },
|
||||
hasMore = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
return corruptProfiles.isNotEmpty()
|
||||
}
|
||||
|
||||
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||
fun loadSessions(forceRefresh: Boolean = false) {
|
||||
val requestGeneration = generation
|
||||
@@ -360,10 +384,7 @@ class SessionsViewModel(
|
||||
val localKey = "${section.name}:${section.source}:${section.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
if (forceRefresh) {
|
||||
if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey)
|
||||
if (persistentKey != null) SessionListCacheStore.remove(persistentKey)
|
||||
}
|
||||
// #1286: keep the last good cache until a healthy refresh replaces it.
|
||||
val cached =
|
||||
if (!forceRefresh && inMemoryKey != null && persistentKey != null) {
|
||||
sessionsPageCache.get(inMemoryKey)
|
||||
@@ -418,6 +439,7 @@ class SessionsViewModel(
|
||||
onSuccess = { data ->
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
||||
if (handleStorageHealth(data)) return@safeLaunchLoad
|
||||
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
||||
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
@@ -495,6 +517,7 @@ class SessionsViewModel(
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
if (handleStorageHealth(data)) return@launch
|
||||
rawPaginationOffset = data.nextOffset(offset)
|
||||
_uiState.update {
|
||||
val newSessions =
|
||||
|
||||
@@ -1785,4 +1785,5 @@
|
||||
<string name="plugins_update_consent_text">Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update?</string>
|
||||
<string name="plugins_update_consent_missing">The server did not provide capability details.</string>
|
||||
<string name="plugins_update_accept">Accept and update</string>
|
||||
<string name="sessions_storage_corrupt">Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh.</string>
|
||||
</resources>
|
||||
@@ -99,6 +99,72 @@ class SessionsViewModelTest {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt refresh retains history and recovery replaces it`() {
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returnsMany
|
||||
listOf(
|
||||
Response.success(SessionListResponse(listOf(SessionInfo("cached")), total = 1)),
|
||||
Response.success(
|
||||
SessionListResponse(emptyList(), storage = mapOf("default" to "corrupt")),
|
||||
),
|
||||
Response.success(SessionListResponse(emptyList())),
|
||||
)
|
||||
val vm = createViewModel()
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
vm.loadSessions(forceRefresh = true)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(
|
||||
listOf("cached"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals(setOf("default"), vm.uiState.value.corruptStorageProfiles)
|
||||
assertFalse(vm.uiState.value.isLoading)
|
||||
assertFalse(vm.uiState.value.hasMore)
|
||||
vm.loadSessions(forceRefresh = true)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertTrue(
|
||||
vm.uiState.value.sessions
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.corruptStorageProfiles
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt first page retains partial rows and flags empty history`() {
|
||||
val response =
|
||||
kotlinx.serialization.json.Json.decodeFromString<SessionListResponse>(
|
||||
"""{"sessions":[],"storage":{"work":"corrupt"}}""",
|
||||
)
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns Response.success(response)
|
||||
val vm = createViewModel()
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles)
|
||||
assertFalse(vm.uiState.value.isLoading)
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns
|
||||
Response.success(response.copy(sessions = listOf(SessionInfo("partial"))))
|
||||
vm.loadSessions(forceRefresh = true)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(
|
||||
listOf("partial"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles)
|
||||
assertTrue(
|
||||
kotlinx.serialization.json.Json
|
||||
.decodeFromString<SessionListResponse>(
|
||||
"""{"sessions":[]}""",
|
||||
).storage
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wire paging metadata drives a second request without changing history paging`() {
|
||||
val first =
|
||||
|
||||
Reference in new issue
Block a user