From d71b74f77da974126d8df63d99730e68b7c14bb9 Mon Sep 17 00:00:00 2001 From: Muhamad Syafii Date: Tue, 11 Aug 2026 11:10:25 +0700 Subject: [PATCH] search: Move indexed MediaStore query off main thread Indexed search previously created its MediaStore cursor before the search coroutine was launched, so ContentResolver.query could run on the UI thread when triggered from SearchView. Move cursor creation into the indexed search background work and keep result publication through the existing LiveData path. Fixes #4646. --- .../searchfilesystem/IndexedSearch.kt | 21 +++- .../ui/activities/MainActivityViewModel.kt | 20 ++-- .../searchfilesystem/IndexedSearchTest.kt | 108 +++++++++++++++++- 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearch.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearch.kt index 56a1cc63f3..aec832f774 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearch.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearch.kt @@ -20,6 +20,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem +import android.content.ContentResolver import android.database.Cursor import android.provider.MediaStore import com.amaze.filemanager.filesystem.RootHelper @@ -31,9 +32,25 @@ class IndexedSearch( query: String, path: String, searchParameters: SearchParameters, - private val cursor: Cursor, + private val contentResolver: ContentResolver, ) : FileSearch(query, path, searchParameters) { override suspend fun search(filter: SearchFilter) { + val projection = + arrayOf( + MediaStore.Files.FileColumns.DATA, + MediaStore.Files.FileColumns.DISPLAY_NAME, + ) + contentResolver + .query(MediaStore.Files.getContentUri("external"), projection, null, null, null) + ?.use { cursor -> + searchCursor(cursor, filter) + } + } + + private suspend fun searchCursor( + cursor: Cursor, + filter: SearchFilter, + ) { if (cursor.count > 0 && cursor.moveToFirst()) { do { val nextPath = @@ -59,7 +76,5 @@ class IndexedSearch( } } while (cursor.moveToNext() && coroutineContext.isActive) } - - cursor.close() } } diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivityViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivityViewModel.kt index cbb21527aa..af504a8e44 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivityViewModel.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivityViewModel.kt @@ -22,7 +22,6 @@ package com.amaze.filemanager.ui.activities import android.app.Application import android.content.Intent -import android.provider.MediaStore import androidx.collection.LruCache import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData @@ -138,22 +137,17 @@ class MainActivityViewModel(val applicationContext: Application) : mainActivity: MainActivity, query: String, ): LiveData> { - val projection = - arrayOf( - MediaStore.Files.FileColumns.DATA, - MediaStore.Files.FileColumns.DISPLAY_NAME, - ) - val cursor = - mainActivity - .contentResolver - .query(MediaStore.Files.getContentUri("external"), projection, null, null, null) - ?: return MutableLiveData() - val searchParameters = createSearchParameters(mainActivity) val path = mainActivity.currentMainFragment?.currentPath ?: "" - val indexedSearch = IndexedSearch(query, path, searchParameters, cursor) + val indexedSearch = + IndexedSearch( + query, + path, + searchParameters, + applicationContext.contentResolver, + ) lastSearchJob = viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearchTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearchTest.kt index e0d318108b..27c7772d02 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearchTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearchTest.kt @@ -20,13 +20,17 @@ package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem +import android.content.ContentResolver import android.database.Cursor import android.provider.MediaStore import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.just +import io.mockk.runs import io.mockk.unmockkAll +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert @@ -36,6 +40,10 @@ import org.junit.Test import java.util.EnumSet class IndexedSearchTest { + companion object { + private const val EXTERNAL_VOLUME = "external" + } + @get:Rule val rule = InstantTaskExecutorRule() @@ -45,6 +53,9 @@ class IndexedSearchTest { @RelaxedMockK lateinit var mockCursor: Cursor + @RelaxedMockK + lateinit var contentResolver: ContentResolver + val filePath = "/test/abc.txt" val fileName = "abc.txt" @@ -64,6 +75,16 @@ class IndexedSearchTest { every { mockCursor.getString(dataColumn) } returns filePath every { mockCursor.getString(displayNameColumn) } returns fileName + every { mockCursor.close() } just runs + every { + contentResolver.query( + MediaStore.Files.getContentUri(EXTERNAL_VOLUME), + any(), + null, + null, + null, + ) + } returns mockCursor } /** Clean up all mocks */ @@ -87,12 +108,12 @@ class IndexedSearchTest { "ab", "/", EnumSet.noneOf(SearchParameter::class.java), - mockCursor, + contentResolver, ) indexedSearch.foundFilesLiveData.observeForever { actualResults -> Assert.assertNotNull(actualResults) - Assert.assertEquals(expectedNames, actualResults!!.map { (file, _) -> file.name }) - Assert.assertEquals(expectedPaths, actualResults!!.map { (file, _) -> file.path }) + Assert.assertEquals(expectedNames, actualResults.map { (file, _) -> file.name }) + Assert.assertEquals(expectedPaths, actualResults.map { (file, _) -> file.path }) Assert.assertEquals(expectedRanges, actualResults.map { it.matchRange }) } runTest { @@ -111,7 +132,7 @@ class IndexedSearchTest { "ba", "/", EnumSet.noneOf(SearchParameter::class.java), - mockCursor, + contentResolver, ) indexedSearch.foundFilesLiveData.observeForever { actualResults -> Assert.assertNotNull(actualResults) @@ -136,7 +157,7 @@ class IndexedSearchTest { "te", "/", EnumSet.noneOf(SearchParameter::class.java), - mockCursor, + contentResolver, ) indexedSearch.foundFilesLiveData.observeForever { actualResults -> Assert.assertNotNull(actualResults) @@ -150,5 +171,82 @@ class IndexedSearchTest { } } + /** + * The MediaStore query should happen as part of [IndexedSearch.search], so callers can decide + * the coroutine dispatcher before any provider work starts. + */ + @Test + fun testContentResolverQueryRunsOnlyWhenSearchStarts() { + val indexedSearch = + IndexedSearch( + "ab", + "/", + EnumSet.noneOf(SearchParameter::class.java), + contentResolver, + ) + + verify(exactly = 0) { + contentResolver.query( + MediaStore.Files.getContentUri(EXTERNAL_VOLUME), + any(), + null, + null, + null, + ) + } + + runTest { + indexedSearch.search() + } + + verify(exactly = 1) { + contentResolver.query( + MediaStore.Files.getContentUri(EXTERNAL_VOLUME), + any(), + null, + null, + null, + ) + } + } + + /** + * If MediaStore cannot provide a cursor, indexed search should return without crashing. + */ + @Test + fun testNullCursorDoesNotCrash() { + every { + contentResolver.query( + MediaStore.Files.getContentUri(EXTERNAL_VOLUME), + any(), + null, + null, + null, + ) + } returns null + + val indexedSearch = + IndexedSearch( + "ab", + "/", + EnumSet.noneOf(SearchParameter::class.java), + contentResolver, + ) + + runTest { + indexedSearch.search() + } + + verify(exactly = 1) { + contentResolver.query( + MediaStore.Files.getContentUri(EXTERNAL_VOLUME), + any(), + null, + null, + null, + ) + } + } + private fun listNotEmptyError(size: Int) = "List was not empty as expected but had $size elements" }