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" }