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.PluralsRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory 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 * [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 * as labels — the root list is built from `VolumeInfo` so "Internal storage" can be
* localised. * 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( class BrowserViewModel(
private val storage: StorageLocation, private val storage: StorageLocation,
private val notifier: OperationNotifier = OperationNotifier.None, private val notifier: OperationNotifier = OperationNotifier.None,
private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
private val savedState: SavedStateHandle = SavedStateHandle(),
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(BrowserUiState()) private val _state = MutableStateFlow(restoredViewState())
val state: StateFlow<BrowserUiState> = _state.asStateFlow() val state: StateFlow<BrowserUiState> = _state.asStateFlow()
/** The unfiltered listing of the current directory; [recompute] derives what is shown. */ /** The unfiltered listing of the current directory; [recompute] derives what is shown. */
@@ -115,6 +122,7 @@ class BrowserViewModel(
) )
} }
recompute() recompute()
persistViewState()
} }
} }
@@ -134,6 +142,7 @@ class BrowserViewModel(
) )
} }
recompute() 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 // ------------------------------------------------------- view preferences
fun setQuery(query: String) { fun setQuery(query: String) {
@@ -192,6 +246,7 @@ class BrowserViewModel(
fun setSort(key: SortKey) { fun setSort(key: SortKey) {
_state.update { it.copy(sort = it.sort.copy(key = key)) } _state.update { it.copy(sort = it.sort.copy(key = key)) }
recompute() recompute()
persistViewState()
} }
fun toggleSortDirection() { fun toggleSortDirection() {
@@ -204,15 +259,18 @@ class BrowserViewModel(
it.copy(sort = it.sort.copy(direction = flipped)) it.copy(sort = it.sort.copy(direction = flipped))
} }
recompute() recompute()
persistViewState()
} }
fun toggleShowHidden() { fun toggleShowHidden() {
_state.update { it.copy(showHidden = !it.showHidden) } _state.update { it.copy(showHidden = !it.showHidden) }
recompute() recompute()
persistViewState()
} }
fun setViewMode(viewMode: ViewMode) { fun setViewMode(viewMode: ViewMode) {
_state.update { it.copy(viewMode = viewMode) } _state.update { it.copy(viewMode = viewMode) }
persistViewState()
} }
/** Called after the system folder picker returns, with whether the grant was kept. */ /** Called after the system folder picker returns, with whether the grant was kept. */
@@ -600,6 +658,12 @@ class BrowserViewModel(
} }
companion object { 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 { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
@@ -608,6 +672,7 @@ class BrowserViewModel(
storage = app.storage, storage = app.storage,
notifier = app.notifier, notifier = app.notifier,
deleteRequests = app.deleteRequests, deleteRequests = app.deleteRequests,
savedState = createSavedStateHandle(),
) )
} }
} }
@@ -3,8 +3,10 @@ package de.softwareapp_hb.filemanager.ui.browser
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.lifecycle.SavedStateHandle
import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry 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.SortKey
import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
@@ -86,7 +88,8 @@ class BrowserViewModelTest {
private fun viewModel( private fun viewModel(
deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
) = BrowserViewModel(location, recordingNotifier, deleteRequests) savedState: SavedStateHandle = SavedStateHandle(),
) = BrowserViewModel(location, recordingNotifier, deleteRequests, savedState)
@Test @Test
fun `starts on the root list`() { fun `starts on the root list`() {
@@ -369,4 +372,72 @@ class BrowserViewModelTest {
assertTrue(focus.entries.any { it.path == downloads }) assertTrue(focus.entries.any { it.path == downloads })
assertTrue(focus.entries.any { it.name == "top.txt" }) 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)
}
} }