diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index bbffae54..8b391ba0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -63,6 +63,7 @@ data class SessionListResponse( val total: Int = 0, val limit: Int = 0, val offset: Int = 0, + val storage: Map = emptyMap(), ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 3f00fcb2..5ea87d77 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 30c20414..87363b65 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -81,6 +81,7 @@ data class SessionsUiState( val total: Int = 0, val hasMore: Boolean = false, val errorMessage: String? = null, + val corruptStorageProfiles: Set = 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 = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6ca654c..7fcfe1eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1785,4 +1785,5 @@ Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update? The server did not provide capability details. Accept and update + 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. diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 43cbe479..2194e827 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -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( + """{"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( + """{"sessions":[]}""", + ).storage + .isEmpty(), + ) + } + @Test fun `wire paging metadata drives a second request without changing history paging`() { val first =