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:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user