From 77acb80f17c0dc97e54ece4582e70a45a34b7caa Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Thu, 15 Oct 2026 08:58:50 +0200 Subject: [PATCH] Keep the browsed folder and view settings across process death Wires SavedStateHandle into BrowserViewModel: the folder being browsed plus the sort key, direction, view mode and hidden-files setting are restored on relaunch, so a process death no longer drops the user back at the top of the tree. Deliberately excluded, and documented as such: the search query (restoring one would hide the folder contents on launch with no visible cause), the selection, and the clipboard (a paste that survived a reboot would be a nasty surprise). Four tests cover the restored state and each exclusion. --- .../ui/browser/BrowserViewModel.kt | 67 ++++++++++++++++- .../ui/browser/BrowserViewModelTest.kt | 73 ++++++++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index 36f8b91..189ece5 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -2,8 +2,10 @@ package de.softwareapp_hb.filemanager.ui.browser import androidx.annotation.PluralsRes import androidx.annotation.StringRes +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory @@ -47,14 +49,19 @@ import kotlinx.coroutines.launch * [BrowserUiState.conflict] for the user to answer), and it never shows raw storage paths * as labels — the root list is built from `VolumeInfo` so "Internal storage" can be * localised. + * + * [savedState] carries the folder being browsed plus the sort, view mode and hidden-files + * settings across process death; see [restoredViewState] for what is deliberately left + * out. */ class BrowserViewModel( private val storage: StorageLocation, private val notifier: OperationNotifier = OperationNotifier.None, private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, + private val savedState: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { - private val _state = MutableStateFlow(BrowserUiState()) + private val _state = MutableStateFlow(restoredViewState()) val state: StateFlow = _state.asStateFlow() /** The unfiltered listing of the current directory; [recompute] derives what is shown. */ @@ -115,6 +122,7 @@ class BrowserViewModel( ) } recompute() + persistViewState() } } @@ -134,6 +142,7 @@ class BrowserViewModel( ) } recompute() + persistViewState() } } @@ -182,6 +191,51 @@ class BrowserViewModel( } } + // --------------------------------------------------- persisted view state + + /** + * The view state that outlives the process: where the user was, and how they were + * looking at it. + * + * Deliberately narrow. A folder is worth coming back to — losing your place deep in a + * tree is the most annoying thing an Android file manager can do — and a sort order or + * view mode that resets on every process death is just noise. Three things are left + * out on purpose: the **search query**, because restoring one would hide the folder's + * contents on launch with no visible cause; the **selection**, which is transient; and + * the **clipboard**, because a paste that survived a reboot would be a nasty surprise. + */ + private fun restoredViewState(): BrowserUiState { + val savedSortKey = savedState.get(KEY_SORT_KEY) + val savedDirection = savedState.get(KEY_SORT_DIRECTION) + val savedViewMode = savedState.get(KEY_VIEW_MODE) + return BrowserUiState( + path = savedState.get(KEY_PATH)?.let { StoragePath.of(it) }, + sort = SortSpec( + key = SortKey.entries.firstOrNull { it.name == savedSortKey } ?: SortKey.NAME, + direction = SortDirection.entries.firstOrNull { it.name == savedDirection } + ?: SortDirection.ASCENDING, + ), + viewMode = ViewMode.entries.firstOrNull { it.name == savedViewMode } ?: ViewMode.LIST, + showHidden = savedState.get(KEY_SHOW_HIDDEN) ?: false, + ) + } + + /** + * Snapshot the restorable view state. + * + * Called wherever one of those fields changes rather than collected from the state + * flow, so what is persisted stays an explicit, greppable list rather than "whatever + * happens to be in the state". + */ + private fun persistViewState() { + val current = _state.value + savedState[KEY_PATH] = current.path?.value + savedState[KEY_SORT_KEY] = current.sort.key.name + savedState[KEY_SORT_DIRECTION] = current.sort.direction.name + savedState[KEY_VIEW_MODE] = current.viewMode.name + savedState[KEY_SHOW_HIDDEN] = current.showHidden + } + // ------------------------------------------------------- view preferences fun setQuery(query: String) { @@ -192,6 +246,7 @@ class BrowserViewModel( fun setSort(key: SortKey) { _state.update { it.copy(sort = it.sort.copy(key = key)) } recompute() + persistViewState() } fun toggleSortDirection() { @@ -204,15 +259,18 @@ class BrowserViewModel( it.copy(sort = it.sort.copy(direction = flipped)) } recompute() + persistViewState() } fun toggleShowHidden() { _state.update { it.copy(showHidden = !it.showHidden) } recompute() + persistViewState() } fun setViewMode(viewMode: ViewMode) { _state.update { it.copy(viewMode = viewMode) } + persistViewState() } /** Called after the system folder picker returns, with whether the grant was kept. */ @@ -600,6 +658,12 @@ class BrowserViewModel( } companion object { + private const val KEY_PATH = "browser.path" + private const val KEY_SORT_KEY = "browser.sortKey" + private const val KEY_SORT_DIRECTION = "browser.sortDirection" + private const val KEY_VIEW_MODE = "browser.viewMode" + private const val KEY_SHOW_HIDDEN = "browser.showHidden" + val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] @@ -608,6 +672,7 @@ class BrowserViewModel( storage = app.storage, notifier = app.notifier, deleteRequests = app.deleteRequests, + savedState = createSavedStateHandle(), ) } } diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index 73df5eb..aaa1525 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -3,8 +3,10 @@ package de.softwareapp_hb.filemanager.ui.browser import android.app.PendingIntent import android.content.Intent import androidx.activity.result.IntentSenderRequest +import androidx.lifecycle.SavedStateHandle import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.SortDirection import de.softwareapp_hb.filemanager.core.SortKey import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.data.DeleteRequestFactory @@ -86,7 +88,8 @@ class BrowserViewModelTest { private fun viewModel( deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, - ) = BrowserViewModel(location, recordingNotifier, deleteRequests) + savedState: SavedStateHandle = SavedStateHandle(), + ) = BrowserViewModel(location, recordingNotifier, deleteRequests, savedState) @Test fun `starts on the root list`() { @@ -369,4 +372,72 @@ class BrowserViewModelTest { assertTrue(focus.entries.any { it.path == downloads }) assertTrue(focus.entries.any { it.name == "top.txt" }) } + + // ------------------------------------------------------- saved view state + + @Test + fun `the browsed folder and view settings survive a restart`() { + val saved = SavedStateHandle() + viewModel(savedState = saved).apply { + openDirectory(downloads) + setSort(SortKey.SIZE) + toggleSortDirection() + toggleShowHidden() + setViewMode(ViewMode.GRID) + } + + // A second ViewModel over the same handle is exactly what a relaunch looks like. + val relaunched = viewModel(savedState = saved) + val state = relaunched.state.value + + assertEquals(downloads, state.path) + assertEquals(SortKey.SIZE, state.sort.key) + assertEquals(SortDirection.DESCENDING, state.sort.direction) + assertTrue(state.showHidden) + assertEquals(ViewMode.GRID, state.viewMode) + + // The listing really was reloaded, in the restored order: by size descending, with + // the hidden file now included — "second-longer" > "secret" > "first". + assertEquals(listOf("b.txt", ".hidden", "a.txt"), state.entries.map { it.name }) + } + + @Test + fun `a search query is not restored, so the folder is not hidden on launch`() { + val saved = SavedStateHandle() + viewModel(savedState = saved).apply { + openDirectory(downloads) + setQuery("a.txt") + } + + val relaunched = viewModel(savedState = saved) + + assertEquals("", relaunched.state.value.query) + assertEquals(listOf("a.txt", "b.txt"), relaunched.state.value.entries.map { it.name }) + } + + @Test + fun `a selection and a clipboard do not survive a restart`() { + val saved = SavedStateHandle() + viewModel(savedState = saved).apply { + openDirectory(downloads) + toggleSelection(downloads.child("a.txt")) + copyToClipboard(listOf(downloads.child("a.txt")), cut = false) + } + + val relaunched = viewModel(savedState = saved) + + assertFalse(relaunched.state.value.isSelecting) + assertNull(relaunched.state.value.clipboard) + } + + @Test + fun `restarting on the roots screen stays on the roots screen`() { + val saved = SavedStateHandle() + viewModel(savedState = saved).apply { + openDirectory(downloads) + navigateUp() + navigateUp() + } + assertTrue(viewModel(savedState = saved).state.value.isRootView) + } }