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.
This commit is contained in:
2026-10-15 08:58:50 +02:00
parent ba92cf392d
commit 77acb80f17
2 changed files with 138 additions and 2 deletions
@@ -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<BrowserUiState> = _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<String>(KEY_SORT_KEY)
val savedDirection = savedState.get<String>(KEY_SORT_DIRECTION)
val savedViewMode = savedState.get<String>(KEY_VIEW_MODE)
return BrowserUiState(
path = savedState.get<String>(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<Boolean>(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(),
)
}
}