The index pass belongs to the view model, not to the keystroke that started it
Reported: the index looked recreated on every keystroke, and switching library started it over. Both are the same bug, and it is in the ownership of the pass. searchEverywhere cancels and re-creates searchJob on every keystroke, and loadLibrary does the same with libraryJob. bringIndexCurrent was a suspend function called from inside those jobs, so the walk ran in the job that the next interaction cancelled. A cancelled pass never reaches endVolume, so the volume's stamp stayed old, so the next interaction found the index stale and started again from the first folder. With a 20 000-file volume on a phone, where a walk takes tens of seconds and typing takes milliseconds, the index could not finish while anyone was typing - and the progress counter restarting with every character was the visible half of it. The pass now has its own job on the view model: one at a time, shared by whoever asks, not cancelled by the screen that asked. Search does not wait for it once there is an answer to show (it did before, which queued the UI behind the walk), and when the pass lands it re-answers whatever is on screen - the query or the library - so "I just downloaded it" still works. A rebuild cancels any pass in flight and takes over: it is the one action that recovers from the index being wrong rather than old. Three tests, each standing inside a walk through a gated StorageSource: typing does not start a second pass, switching library does not start a second pass, and a finished pass answers the search again when it finds the file. 430 tests total.
This commit is contained in:
@@ -145,6 +145,17 @@ class BrowserViewModel(
|
||||
*/
|
||||
private val changedFolders = HashMap<StoragePath, MutableSet<StoragePath>>()
|
||||
|
||||
/**
|
||||
* The index pass that is running, if any.
|
||||
*
|
||||
* **Owned by the view model, not by whoever asked for it.** A search cancels and restarts its own job
|
||||
* on every keystroke; a pass started from inside that job was therefore cancelled a few characters
|
||||
* later — before it could stamp the volume — and the next keystroke started another one from the first
|
||||
* folder. Typing "photo" on a phone walked the volume five times, finished none of them, and the
|
||||
* counter on screen restarted with every letter. One job, one pass, whoever asked.
|
||||
*/
|
||||
private var indexJob: Job? = null
|
||||
|
||||
/** The running recursive search, so the next keystroke can supersede it. */
|
||||
private var searchJob: Job? = null
|
||||
|
||||
@@ -898,13 +909,20 @@ class BrowserViewModel(
|
||||
}
|
||||
|
||||
searchJob = viewModelScope.launch {
|
||||
if (searchIndex.stamps().isNotEmpty()) showIndexedResults(query, source)
|
||||
val known = searchIndex.stamps().isNotEmpty()
|
||||
if (known) showIndexedResults(query, source)
|
||||
|
||||
bringIndexCurrent(source)
|
||||
// **Not awaited** when there is already an answer. The pass belongs to the view model, so
|
||||
// typing neither cancels it nor queues behind it; it re-answers this query when it lands.
|
||||
val refresh = bringIndexCurrent(source)
|
||||
|
||||
// Answered again, because the pass may have found the file this search is for —
|
||||
// which is the whole of "I just downloaded it, so why is it not here?".
|
||||
showIndexedResults(query, source)
|
||||
if (!known) {
|
||||
// The first search on a device has nothing to show until a walk has run, so this is the one
|
||||
// case worth waiting for — with the progress row above it saying why.
|
||||
refresh?.join()
|
||||
if (!currentCoroutineContext().isActive) return@launch
|
||||
showIndexedResults(query, source)
|
||||
}
|
||||
|
||||
_state.update { current ->
|
||||
if (current.query != query) {
|
||||
@@ -926,7 +944,33 @@ class BrowserViewModel(
|
||||
* and the preference is part of what a row means. Everything else needs a **catch-up**, which
|
||||
* reads names and nothing else.
|
||||
*/
|
||||
private suspend fun bringIndexCurrent(source: StorageSource) {
|
||||
private fun bringIndexCurrent(source: StorageSource): Job? {
|
||||
// Already running: this caller gets that pass rather than a second one.
|
||||
indexJob?.takeIf { it.isActive }?.let { return it }
|
||||
|
||||
val job = viewModelScope.launch {
|
||||
val (toWalk, toCatchUp) = staleVolumes()
|
||||
if (toWalk.isNotEmpty()) pass(source, toWalk, full = true)
|
||||
if (toCatchUp.isNotEmpty()) pass(source, toCatchUp, full = false)
|
||||
|
||||
// What is on screen is answered again, because the pass may just have found the file the
|
||||
// search is for — which is the whole of "I downloaded it, so why is it not here?".
|
||||
refreshFromIndex()
|
||||
}
|
||||
indexJob = job
|
||||
return job
|
||||
}
|
||||
|
||||
/**
|
||||
* Which volumes need a walk, and which only a catch-up.
|
||||
*
|
||||
* The policy lives here rather than in its callers because search and the library views must agree
|
||||
* about it: a volume that was never built, was built with the other hidden-files preference, or has
|
||||
* not been walked end to end for a week needs a **walk** — a catch-up compares names, so it cannot
|
||||
* see a name that is there both times but is not the file it was, and the preference is part of what
|
||||
* a row means. Everything else needs a **catch-up**, which reads names and nothing else.
|
||||
*/
|
||||
private suspend fun staleVolumes(): Pair<List<StoragePath>, List<StoragePath>> {
|
||||
val volumes = volumes()
|
||||
val stampAt = clock()
|
||||
val stamps = searchIndex.stamps()
|
||||
@@ -938,13 +982,24 @@ class BrowserViewModel(
|
||||
stamp.hiddenIncluded != hidden ||
|
||||
stampAt - stamp.fullAtMillis > INDEX_FULL_PASS_AFTER_MILLIS
|
||||
}
|
||||
if (toWalk.isNotEmpty()) pass(source, toWalk, full = true)
|
||||
|
||||
val toCatchUp = volumes.filterNot(toWalk::contains).filter { volume ->
|
||||
val stamp = stamps[volume.value]
|
||||
stamp == null || stampAt - stamp.indexedAtMillis > INDEX_CATCH_UP_AFTER_MILLIS
|
||||
}
|
||||
if (toCatchUp.isNotEmpty()) pass(source, toCatchUp, full = false)
|
||||
return toWalk to toCatchUp
|
||||
}
|
||||
|
||||
/** Re-answer the view that is on screen from the index, once a pass has landed. */
|
||||
private suspend fun refreshFromIndex() {
|
||||
val source = storage.activeSource() ?: return
|
||||
val current = _state.value
|
||||
when {
|
||||
current.query.isNotBlank() && current.searchRoot != null ->
|
||||
showIndexedResults(current.query, source)
|
||||
current.category != null -> showLibrary(current.category, source)
|
||||
else -> Unit
|
||||
}
|
||||
_state.update { it.copy(indexedAt = searchIndex.indexedAt()) }
|
||||
}
|
||||
|
||||
/** Answer [query] from the index, confirming every candidate against the filesystem. */
|
||||
@@ -1008,7 +1063,10 @@ class BrowserViewModel(
|
||||
*/
|
||||
fun rebuildSearchIndex() {
|
||||
val source = storage.activeSource() ?: return
|
||||
viewModelScope.launch {
|
||||
// The explicit action wins over any pass in flight: it is the one thing that recovers from the index
|
||||
// being *wrong* rather than merely old, so it must not be folded into a catch-up that is nearly done.
|
||||
indexJob?.cancel()
|
||||
indexJob = viewModelScope.launch {
|
||||
pass(source, volumes(), full = true)
|
||||
// Answered again, in whichever view asked for the rebuild: a search re-runs its query,
|
||||
// a library re-reads its rows, and the home screen's counts follow either way.
|
||||
|
||||
@@ -22,6 +22,8 @@ import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
|
||||
import de.softwareapp_hb.filemanager.data.IncomingSource
|
||||
import de.softwareapp_hb.filemanager.data.IndexedFile
|
||||
import de.softwareapp_hb.filemanager.data.SearchIndex
|
||||
import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
import de.softwareapp_hb.filemanager.data.StorageLocation
|
||||
import de.softwareapp_hb.filemanager.data.StorageAccessMode
|
||||
import de.softwareapp_hb.filemanager.data.VolumeInfo
|
||||
import de.softwareapp_hb.filemanager.data.testutil.InMemoryBookmarkStore
|
||||
@@ -36,6 +38,7 @@ import de.softwareapp_hb.filemanager.data.TrashIndex
|
||||
import de.softwareapp_hb.filemanager.domain.testutil.InMemorySearchIndex
|
||||
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
|
||||
import de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -130,8 +133,12 @@ class BrowserViewModelTest {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
// The builder of the thing under test: every collaborator the view model can take, defaulted, so a test
|
||||
// names only the one it is about. Ten parameters is the rule for production code, not for this.
|
||||
@Suppress("LongParameterList")
|
||||
private fun viewModel(
|
||||
deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
|
||||
storageLocation: StorageLocation = location,
|
||||
savedState: SavedStateHandle = SavedStateHandle(),
|
||||
incoming: IncomingSource? = fakeIncoming,
|
||||
apkInspector: ApkInspector = ApkInspector.None,
|
||||
@@ -143,7 +150,7 @@ class BrowserViewModelTest {
|
||||
// assertions in this file are written to read the state immediately after an action.
|
||||
io: CoroutineDispatcher = UnconfinedTestDispatcher(),
|
||||
) = BrowserViewModel(
|
||||
storage = location,
|
||||
storage = storageLocation,
|
||||
notifier = recordingNotifier,
|
||||
deleteRequests = deleteRequests,
|
||||
savedState = savedState,
|
||||
@@ -586,6 +593,91 @@ class BrowserViewModelTest {
|
||||
assertTrue(model.state.value.overlay is Overlay.Trash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `typing while the index is being built does not start it again`() {
|
||||
storage.addFile(downloads.child("photo.jpg"), "x")
|
||||
val index = InMemorySearchIndex()
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val model = viewModel(
|
||||
storageLocation = GatedLocation(location, GatedSource(storage, gate)),
|
||||
searchIndex = index,
|
||||
)
|
||||
|
||||
model.setQuery("pho")
|
||||
// The first search is inside the walk now, waiting at the gate.
|
||||
assertTrue(model.state.value.indexing)
|
||||
|
||||
model.setQuery("phot")
|
||||
model.setQuery("photo")
|
||||
|
||||
// Three keystrokes, one pass. The walk belongs to the view model; a keystroke cancels the *search*
|
||||
// job, and used to cancel the walk with it — which is why the counter restarted with every letter.
|
||||
assertEquals(1, index.beginVolumeCalls)
|
||||
|
||||
gate.complete(Unit)
|
||||
|
||||
assertEquals(1, index.beginVolumeCalls)
|
||||
assertTrue(model.state.value.entries.any { it.name == "photo.jpg" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching library while the index is being built does not start it again`() {
|
||||
val index = InMemorySearchIndex()
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val model = viewModel(
|
||||
storageLocation = GatedLocation(location, GatedSource(storage, gate)),
|
||||
searchIndex = index,
|
||||
)
|
||||
|
||||
model.openCategory(FileCategory.IMAGES)
|
||||
assertTrue(model.state.value.indexing)
|
||||
|
||||
model.openCategory(FileCategory.VIDEOS)
|
||||
model.openCategory(FileCategory.DOCUMENTS)
|
||||
|
||||
assertEquals(1, index.beginVolumeCalls)
|
||||
|
||||
gate.complete(Unit)
|
||||
assertEquals(1, index.beginVolumeCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a search is answered again when the pass finds what it was looking for`() {
|
||||
var now = 1_000_000L
|
||||
val index = InMemorySearchIndex()
|
||||
val model = viewModel(searchIndex = index, clock = { now })
|
||||
// A first pass, so the index has a stamp; then a new file, and enough time for the stamp to age
|
||||
// past the catch-up window.
|
||||
model.openCategory(FileCategory.IMAGES)
|
||||
storage.addFile(downloads.child("photo.jpg"), "x")
|
||||
now += 3L * 60L * 1000L
|
||||
|
||||
model.setQuery("photo")
|
||||
|
||||
// The first answer came from what the index knew; the catch-up behind it found the new file and
|
||||
// answered again — which is what makes "I just downloaded it" work without a walk per keystroke.
|
||||
assertTrue(model.state.value.entries.any { it.name == "photo.jpg" })
|
||||
assertEquals(1, index.beginVolumeCalls)
|
||||
}
|
||||
|
||||
/** A location whose listing waits on [gate], so a test can stand inside a walk. */
|
||||
private class GatedLocation(
|
||||
private val delegate: StorageLocation,
|
||||
private val gated: StorageSource,
|
||||
) : StorageLocation by delegate {
|
||||
override fun activeSource(): StorageSource = gated
|
||||
}
|
||||
|
||||
private class GatedSource(
|
||||
private val delegate: StorageSource,
|
||||
private val gate: CompletableDeferred<Unit>,
|
||||
) : StorageSource by delegate {
|
||||
override suspend fun list(directory: StoragePath): List<FileEntry> {
|
||||
gate.await()
|
||||
return delegate.list(directory)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigating up goes to the parent then to the roots`() {
|
||||
val model = viewModel()
|
||||
|
||||
Reference in New Issue
Block a user