5 files changed
+118
-4
No files matched your search
@@ -63,6 +63,7 @@ data class SessionListResponse(
|
|||||||
val total: Int = 0,
|
val total: Int = 0,
|
||||||
val limit: Int = 0,
|
val limit: Int = 0,
|
||||||
val offset: Int = 0,
|
val offset: Int = 0,
|
||||||
|
val storage: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@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() -> {
|
state.sessions.isEmpty() -> {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title =
|
title =
|
||||||
@@ -752,6 +763,18 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
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 ───────────────────────────────────────
|
// ── Stats row ───────────────────────────────────────
|
||||||
SessionsStatsRow(
|
SessionsStatsRow(
|
||||||
total = state.total,
|
total = state.total,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ data class SessionsUiState(
|
|||||||
val total: Int = 0,
|
val total: Int = 0,
|
||||||
val hasMore: Boolean = false,
|
val hasMore: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
|
val corruptStorageProfiles: Set<String> = emptySet(),
|
||||||
val stats: SessionStats = SessionStats(),
|
val stats: SessionStats = SessionStats(),
|
||||||
val isLoadingStats: Boolean = false,
|
val isLoadingStats: Boolean = false,
|
||||||
val statsError: String? = null,
|
val statsError: String? = null,
|
||||||
@@ -197,6 +198,7 @@ class SessionsViewModel(
|
|||||||
val (requestScope, capturedSection) = scopeAndSection
|
val (requestScope, capturedSection) = scopeAndSection
|
||||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||||
if (requestGeneration == generation && currentScope == requestScope) {
|
if (requestGeneration == generation && currentScope == requestScope) {
|
||||||
|
if (handleStorageHealth(data)) return@refreshOnChange
|
||||||
val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}"
|
val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}"
|
||||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||||
val persistentKey = requestScope?.persistentKey(localKey)
|
val persistentKey = requestScope?.persistentKey(localKey)
|
||||||
@@ -244,6 +246,7 @@ class SessionsViewModel(
|
|||||||
selectedIds = emptySet(),
|
selectedIds = emptySet(),
|
||||||
isSelecting = false,
|
isSelecting = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
|
corruptStorageProfiles = emptySet(),
|
||||||
searchResults = emptyList(),
|
searchResults = emptyList(),
|
||||||
searchError = null,
|
searchError = null,
|
||||||
searchLoadMoreError = null,
|
searchLoadMoreError = null,
|
||||||
@@ -342,6 +345,7 @@ class SessionsViewModel(
|
|||||||
total = 0,
|
total = 0,
|
||||||
hasMore = false,
|
hasMore = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
|
corruptStorageProfiles = emptySet(),
|
||||||
isSelecting = false,
|
isSelecting = false,
|
||||||
selectedIds = emptySet(),
|
selectedIds = emptySet(),
|
||||||
isSearching = false,
|
isSearching = false,
|
||||||
@@ -352,6 +356,26 @@ class SessionsViewModel(
|
|||||||
if (query.isBlank()) loadSessions() else setSearchQuery(query)
|
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. */
|
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||||
fun loadSessions(forceRefresh: Boolean = false) {
|
fun loadSessions(forceRefresh: Boolean = false) {
|
||||||
val requestGeneration = generation
|
val requestGeneration = generation
|
||||||
@@ -360,10 +384,7 @@ class SessionsViewModel(
|
|||||||
val localKey = "${section.name}:${section.source}:${section.excludeSources}"
|
val localKey = "${section.name}:${section.source}:${section.excludeSources}"
|
||||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||||
val persistentKey = requestScope?.persistentKey(localKey)
|
val persistentKey = requestScope?.persistentKey(localKey)
|
||||||
if (forceRefresh) {
|
// #1286: keep the last good cache until a healthy refresh replaces it.
|
||||||
if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey)
|
|
||||||
if (persistentKey != null) SessionListCacheStore.remove(persistentKey)
|
|
||||||
}
|
|
||||||
val cached =
|
val cached =
|
||||||
if (!forceRefresh && inMemoryKey != null && persistentKey != null) {
|
if (!forceRefresh && inMemoryKey != null && persistentKey != null) {
|
||||||
sessionsPageCache.get(inMemoryKey)
|
sessionsPageCache.get(inMemoryKey)
|
||||||
@@ -418,6 +439,7 @@ class SessionsViewModel(
|
|||||||
onSuccess = { data ->
|
onSuccess = { data ->
|
||||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||||
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
||||||
|
if (handleStorageHealth(data)) return@safeLaunchLoad
|
||||||
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
||||||
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
||||||
rawPaginationOffset = data.nextOffset(0)
|
rawPaginationOffset = data.nextOffset(0)
|
||||||
@@ -495,6 +517,7 @@ class SessionsViewModel(
|
|||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val data = result.data
|
val data = result.data
|
||||||
|
if (handleStorageHealth(data)) return@launch
|
||||||
rawPaginationOffset = data.nextOffset(offset)
|
rawPaginationOffset = data.nextOffset(offset)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
val newSessions =
|
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_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_consent_missing">The server did not provide capability details.</string>
|
||||||
<string name="plugins_update_accept">Accept and update</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>
|
</resources>
|
||||||
@@ -99,6 +99,72 @@ class SessionsViewModelTest {
|
|||||||
unmockkAll()
|
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
|
@Test
|
||||||
fun `wire paging metadata drives a second request without changing history paging`() {
|
fun `wire paging metadata drives a second request without changing history paging`() {
|
||||||
val first =
|
val first =
|
||||||
|
|||||||
Reference in new issue
Block a user