diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DeleteTask.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DeleteTask.java index 5ce3703e62..7011c8bb34 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DeleteTask.java +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DeleteTask.java @@ -115,7 +115,7 @@ protected final AsyncTaskResult doInBackground( // delete file from media database if (!file.isSmb() && !file.isSftp()) { - MediaConnectionUtils.scanFile( + MediaConnectionUtils.scanFiles( applicationContext, files.toArray(new HybridFile[files.size()])); if (FileUtils.NOMEDIA_FILE.equals(file.getName())) diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFilesTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFilesTask.kt index a1d700a606..cd13bbd5da 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFilesTask.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFilesTask.kt @@ -107,8 +107,8 @@ class MoveFilesTask( for (hybridFileParcelables in files) { sourcesFiles.addAll(hybridFileParcelables) } - MediaConnectionUtils.scanFile(applicationContext, sourcesFiles.toTypedArray()) - MediaConnectionUtils.scanFile(applicationContext, targetFiles.toTypedArray()) + MediaConnectionUtils.scanFiles(applicationContext, sourcesFiles.toTypedArray()) + MediaConnectionUtils.scanFiles(applicationContext, targetFiles.toTypedArray()) } // updating encrypted db entry if any encrypted file was moved diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt index 7ee9cb13cb..5aa822072f 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt @@ -30,7 +30,6 @@ import com.afollestad.materialdialogs.DialogAction import com.afollestad.materialdialogs.MaterialDialog import com.amaze.filemanager.R import com.amaze.filemanager.asynchronous.asynctasks.fromTask -import com.amaze.filemanager.asynchronous.asynctasks.movecopy.PreparePasteTask.CopyNode import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.asynchronous.services.CopyService import com.amaze.filemanager.databinding.CopyDialogBinding @@ -142,7 +141,7 @@ class PreparePasteTask(strongRefMain: MainActivity) { filesToCopy[0].getParent(context.get()) == targetPath ) { Toast.makeText(context.get(), R.string.same_dir_move_error, Toast.LENGTH_SHORT).show() - MediaConnectionUtils.scanFile(context.get() as Context, filesToCopy.toTypedArray()) + MediaConnectionUtils.scanFiles(context.get() as Context, filesToCopy.toTypedArray()) return } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/CopyService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/CopyService.java index e49e0f26f0..3f3e08aca7 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/CopyService.java +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/CopyService.java @@ -469,7 +469,7 @@ void copyRoot(HybridFileParcelable sourceFile, HybridFile targetFile, boolean mo e); failedFOps.add(sourceFile); } - MediaConnectionUtils.scanFile(c, new HybridFile[] {targetFile}); + MediaConnectionUtils.scanFiles(c, new HybridFile[] {targetFile}); } private void copyFiles( diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java b/app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java index 37b01b01df..02c45cea90 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java @@ -1581,7 +1581,7 @@ public void restoreFromBin(Context context) { if (!source.renameTo(dest)) { return false; } - MediaConnectionUtils.scanFile(context, new HybridFile[] {this}); + MediaConnectionUtils.scanFiles(context, new HybridFile[] {this}); return true; }); } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/MediaStoreHack.java b/app/src/main/java/com/amaze/filemanager/filesystem/MediaStoreHack.java index 5336fd6bc0..aa390f2bde 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/MediaStoreHack.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/MediaStoreHack.java @@ -40,6 +40,7 @@ import android.provider.MediaStore; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; /** @@ -164,6 +165,43 @@ public static OutputStream getOutputStream(Context context, String str) { } } + public static @Nullable Uri getUriForMusicMediaFrom( + @NonNull String path, @NonNull Context context) { + Uri retval = getUriForMusicMediaInternal(path, context); + return retval; + } + + private static @Nullable Uri getUriForMusicMediaInternal( + @NonNull String path, @NonNull Context context) { + String[] projection = {MediaStore.Audio.Media._ID}; + + String selection = MediaStore.Audio.Media.DATA + "=?"; + String[] selectionArgs = new String[] {path}; + + try (Cursor cursor = + context + .getContentResolver() + .query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + null)) { + if (cursor != null && cursor.moveToFirst()) { + int id = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)); + return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + .buildUpon() + .appendPath(String.valueOf(id)) + .build(); + } else { + return null; + } + } catch (Exception e) { + Log.e(TAG, "Error querying MediaStore", e); + return null; + } + } + /** Returns an OutputStream to write to the file. The file will be truncated immediately. */ private static int getTemporaryAlbumId(final Context context) { final File temporaryTrack; diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/Operations.java b/app/src/main/java/com/amaze/filemanager/filesystem/Operations.java index 306eaaf3c7..f57424a749 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/Operations.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/Operations.java @@ -728,7 +728,7 @@ protected void onPostExecute(Void aVoid) { super.onPostExecute(aVoid); if (newFile != null && oldFile != null) { HybridFile[] hybridFiles = {newFile, oldFile}; - MediaConnectionUtils.scanFile(context, hybridFiles); + MediaConnectionUtils.scanFiles(context, hybridFiles); } } }.executeOnExecutor(executor); diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java b/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java index 75dc8ab34f..680aebb289 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java @@ -269,7 +269,7 @@ private void startCopy( // If target file is copied onto the device and copy was successful, trigger media store // rescan if (mTargetFile != null) { - MediaConnectionUtils.scanFile(mContext, new HybridFile[] {mTargetFile}); + MediaConnectionUtils.scanFiles(mContext, new HybridFile[] {mTargetFile}); } } } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtils.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtils.kt index 1c982a04e3..e960ed3fd7 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtils.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtils.kt @@ -36,13 +36,14 @@ object MediaConnectionUtils { * @param hybridFiles files to be scanned */ @JvmStatic - fun scanFile( + fun scanFiles( context: Context, hybridFiles: Array, ) { - val paths = arrayOfNulls(hybridFiles.size) - - for (i in hybridFiles.indices) paths[i] = hybridFiles[i].path + val paths: Array = + hybridFiles.map { + it.path + }.toTypedArray() MediaScannerConnection.scanFile( context, @@ -72,4 +73,31 @@ object MediaConnectionUtils { LOG.info("MediaConnectionUtils#scanFile finished scanning path$path") } } + + /** + * Invokes MediaScannerConnection#scanFile for the given file. + * + * @param context the context + * @param path the file path to be scanned + * @param mimeType the mime type of the file. Optional. + * + */ + @JvmStatic + fun scanFileByFileSystemPathAndMimeType( + context: Context, + path: String, + mimeType: String? = null, + callback: MediaScannerConnection.OnScanCompletedListener? = null, + ) { + MediaScannerConnection.scanFile( + context, + arrayOf(path), + mimeType?.let { + arrayOf(it) + }, + ) { scannedPath: String, uri: Uri? -> + LOG.info("Finished scanning path $scannedPath") + callback?.onScanCompleted(scannedPath, uri) + } + } } 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 069e2b95cf..cbb21527aa 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 @@ -40,7 +40,7 @@ import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.SearchResu import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.searchParametersFromBoolean import com.amaze.filemanager.fileoperations.filesystem.OpenMode import com.amaze.filemanager.filesystem.HybridFile -import com.amaze.filemanager.filesystem.files.MediaConnectionUtils.scanFile +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils.scanFiles import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_REGEX import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_REGEX_MATCHES import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES @@ -235,7 +235,7 @@ class MainActivityViewModel(val applicationContext: Application) : OpenMode.TRASH_BIN, originalFilePath, ) - scanFile(applicationContext, arrayOf(hybridFile)) + scanFiles(applicationContext, arrayOf(hybridFile)) val intent = Intent(MainActivity.KEY_INTENT_LOAD_LIST) hybridFile.getParent(applicationContext)?.let { intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, it) @@ -292,7 +292,7 @@ class MainActivityViewModel(val applicationContext: Application) : OpenMode.TRASH_BIN, source, ) - scanFile(applicationContext, arrayOf(hybridFile)) + scanFiles(applicationContext, arrayOf(hybridFile)) val intent = Intent(MainActivity.KEY_INTENT_LOAD_LIST) hybridFile.getParent(applicationContext)?.let { intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, it) diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java index 8dddfa8579..745494272a 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java @@ -58,6 +58,7 @@ import com.amaze.filemanager.filesystem.FileProperties; import com.amaze.filemanager.filesystem.HybridFile; import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.MediaStoreHack; import com.amaze.filemanager.filesystem.SafRootHolder; import com.amaze.filemanager.filesystem.files.CryptUtil; import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils; @@ -136,6 +137,7 @@ import jcifs.smb.SmbFile; import kotlin.collections.ArraysKt; import kotlin.collections.CollectionsKt; +import kotlin.text.StringsKt; public class MainFragment extends Fragment implements BottomBarButtonPath, @@ -576,10 +578,29 @@ public void returnIntentResults(HybridFileParcelable[] baseFiles) { HybridFileParcelable resultBaseFile = result.getKey(); if (requireMainActivity().mRingtonePickerIntent) { - intent.setDataAndType( - resultUri, - MimeTypes.getMimeType(resultBaseFile.getPath(), resultBaseFile.isDirectory())); - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, resultUri); + // Query MediaStore to get the proper content URI for the selected audio file + Uri mediaFileUri = + MediaStoreHack.getUriForMusicMediaFrom(resultBaseFile.getPath(), requireContext()); + if (mediaFileUri != null) { + String filename = resultBaseFile.getName(); + Uri properUri = + mediaFileUri + .buildUpon() + .appendQueryParameter("canonical", "1") + .appendQueryParameter( + "title", StringsKt.substringBeforeLast(filename, ".", filename)) + .build(); + // Set the proper content URI as result + String mimeType = MimeTypes.getMimeType(resultBaseFile.getPath(), false); + intent.setDataAndType(properUri, mimeType); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, properUri); + } else { + Toast.makeText(requireContext(), R.string.error_mediastore_query_uri, Toast.LENGTH_LONG) + .show(); + requireActivity().setResult(FragmentActivity.RESULT_CANCELED); + requireActivity().finish(); + return; + } } else { LOG.debug("pickup file"); intent.setDataAndType(resultUri, MimeTypes.getExtension(resultBaseFile.getPath())); @@ -1374,7 +1395,7 @@ public void hide(String path) { LOG.warn("failure when hiding file", e); } } - MediaConnectionUtils.scanFile( + MediaConnectionUtils.scanFiles( requireMainActivity(), new HybridFile[] {new HybridFile(OpenMode.FILE, path)}); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11fca688cd..7ca3286946 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -875,5 +875,6 @@ You only need to do this once, until the next time you select a new location for Cleanup interval Trigger auto-cleanup interval (hours) File Deletion + Error querying MediaStore for content URI of selected media file. Refresh the MediaStore and try again. diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/MediaStoreHackTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/MediaStoreHackTest.kt new file mode 100644 index 0000000000..ae2023be9e --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/filesystem/MediaStoreHackTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.os.Build +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.P +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.filesystem.MediaStoreHackTest.Companion.FAKE_ROW_ID +import com.amaze.filemanager.shadows.ShadowMultiDex +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Robolectric tests for [MediaStoreHack.getUriForMusicMediaFrom]. + * + * The new method queries [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI] by file-system path and + * returns a content:// [android.net.Uri] when a matching row exists, or `null` when it does not. + * + * Robolectric is needed so that Android framework statics (e.g. + * [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI], [android.net.Uri]) are properly initialised. + * The [ContentResolver] itself is mocked via MockK so tests are not affected by Robolectric 4.9's + * limited in-process MediaStore ContentProvider support. + */ +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + shadows = [ShadowMultiDex::class], +) +class MediaStoreHackTest { + companion object { + private const val TEST_AUDIO_PATH = "/storage/emulated/0/Music/test_ringtone.mp3" + private const val ABSENT_AUDIO_PATH = "/storage/emulated/0/Music/nonexistent.mp3" + private const val FAKE_ROW_ID = 42 + } + + /** + * Builds a mock [Context] whose [ContentResolver] returns [cursor] for any query + * against [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI]. + */ + private fun contextWithCursor(cursor: Cursor?): Context { + val mockResolver = mockk() + every { + mockResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + any(), + any(), + any(), + null, + ) + } returns cursor + + return mockk().also { ctx -> + every { ctx.contentResolver } returns mockResolver + } + } + + /** + * Builds a mock [Cursor] that simulates a single-row result with [_ID][MediaStore.Audio.Media._ID] + * equal to [FAKE_ROW_ID]. + */ + private fun singleRowCursor(): Cursor { + val idColumnIndex = 0 + return mockk(relaxed = true).also { cursor -> + every { cursor.moveToFirst() } returns true + every { cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) } returns idColumnIndex + every { cursor.getInt(idColumnIndex) } returns FAKE_ROW_ID + every { cursor.close() } just runs + } + } + + /** + * Builds a mock [Cursor] that simulates an empty result set (no matching rows). + */ + private fun emptyCursor(): Cursor = + mockk(relaxed = true).also { cursor -> + every { cursor.moveToFirst() } returns false + every { cursor.close() } just runs + } + + /** + * After test clean up. + */ + @After + fun tearDown() { + unmockkAll() + } + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + /** + * When the ContentResolver returns a cursor with a matching row, + * [MediaStoreHack.getUriForMusicMediaFrom] must return a non-null `content://` URI under + * [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI] whose last path segment is the row id. + */ + @Test + fun testGetUriForMusicMediaFromReturnsUriWhenCursorHasMatchingRow() { + val context = contextWithCursor(singleRowCursor()) + + val result = MediaStoreHack.getUriForMusicMediaFrom(TEST_AUDIO_PATH, context) + + assertNotNull("Expected a non-null URI when the cursor has a matching row", result) + assertEquals("content", result!!.scheme) + assertTrue( + "Returned URI should be under MediaStore.Audio.Media.EXTERNAL_CONTENT_URI", + result.toString().startsWith( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.toString(), + ), + ) + assertEquals( + "Last path segment must equal the row id returned by the cursor", + FAKE_ROW_ID.toString(), + result.lastPathSegment, + ) + } + + /** + * When the ContentResolver returns a cursor with NO matching rows, + * [MediaStoreHack.getUriForMusicMediaFrom] must return `null`. + */ + @Test + fun testGetUriForMusicMediaFromReturnsNullWhenCursorIsEmpty() { + val context = contextWithCursor(emptyCursor()) + + val result = MediaStoreHack.getUriForMusicMediaFrom(ABSENT_AUDIO_PATH, context) + + assertNull( + "Expected null URI when the cursor is empty (no matching row)", + result, + ) + } + + /** + * When the ContentResolver returns a `null` cursor (provider error or unavailable), + * [MediaStoreHack.getUriForMusicMediaFrom] must return `null` without throwing. + */ + @Test + fun testGetUriForMusicMediaFromReturnsNullWhenCursorIsNull() { + val context = contextWithCursor(null) + + val result = MediaStoreHack.getUriForMusicMediaFrom(TEST_AUDIO_PATH, context) + + assertNull( + "Expected null URI when the ContentResolver returns a null cursor", + result, + ) + } + + /** + * The URI returned by [MediaStoreHack.getUriForMusicMediaFrom] must use only + * the row [_ID][MediaStore.Audio.Media._ID] from the cursor — confirming that + * different row ids produce distinct URIs (selection correctness guard). + */ + @Test + fun testGetUriForMusicMediaFromBuildsUriFromCursorId() { + val alternativeId = 99 + val altCursor = + mockk(relaxed = true).also { cursor -> + every { cursor.moveToFirst() } returns true + every { cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) } returns 0 + every { cursor.getInt(0) } returns alternativeId + every { cursor.close() } just runs + } + val context = contextWithCursor(altCursor) + + val result = MediaStoreHack.getUriForMusicMediaFrom(TEST_AUDIO_PATH, context) + + assertNotNull(result) + assertEquals( + "URI last path segment must equal the alternative row id", + alternativeId.toString(), + result!!.lastPathSegment, + ) + // Verify it is distinct from a URI built with FAKE_ROW_ID + val firstResult = + MediaStoreHack.getUriForMusicMediaFrom( + TEST_AUDIO_PATH, + contextWithCursor(singleRowCursor()), + ) + assertTrue( + "URIs built from different row ids must differ", + result.toString() != firstResult.toString(), + ) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtilsTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtilsTest.kt new file mode 100644 index 0000000000..ce47c2ead8 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtilsTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files + +import android.content.Context +import android.media.MediaScannerConnection +import android.os.Build +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.P +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.shadows.ShadowMultiDex +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowMediaScannerConnection +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Robolectric unit tests for [MediaConnectionUtils]. + * + * Changes covered: + * 1. `scanFile` was renamed to `scanFiles` and now uses `map { it.path }.toTypedArray()`. + * 2. A new `scanFileByFileSystemPathAndMimeType` overload was added that accepts an optional + * mime-type and an optional [MediaScannerConnection.OnScanCompletedListener]. + */ +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + shadows = [ShadowMultiDex::class], +) +class MediaConnectionUtilsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + /** + * Setup before tests. + */ + @Before + fun setUp() { + ShadowMediaScannerConnection.reset() + } + + /** + * After test clean up. + */ + @After + fun tearDown() { + ShadowMediaScannerConnection.reset() + } + + /** + * [MediaConnectionUtils.scanFiles] with a single [HybridFile] must forward exactly that + * file's path to [MediaScannerConnection.scanFile]. + */ + @Test + fun testScanFilesWithSingleFileForwardsPath() { + val path = "/storage/emulated/0/Download/photo.jpg" + val hybridFiles = arrayOf(HybridFile(OpenMode.FILE, path)) + + MediaConnectionUtils.scanFiles(context, hybridFiles) + + val savedPaths = ShadowMediaScannerConnection.getSavedPaths() + assertNotNull("ShadowMediaScannerConnection#getSavedPaths should not be null", savedPaths) + assertEquals("Exactly one path should have been submitted for scanning", 1, savedPaths.size) + assertTrue( + "The submitted path should match the HybridFile path", + savedPaths.contains(path), + ) + } + + /** + * [MediaConnectionUtils.scanFiles] with multiple [HybridFile] entries must forward ALL + * their paths — demonstrating the `map { it.path }.toTypedArray()` refactoring still + * preserves every path. + */ + @Test + fun testScanFilesWithMultipleFilesForwardsAllPaths() { + val paths = + listOf( + "/storage/emulated/0/Music/song1.mp3", + "/storage/emulated/0/Music/song2.flac", + "/storage/emulated/0/Music/song3.ogg", + ) + val hybridFiles = paths.map { HybridFile(OpenMode.FILE, it) }.toTypedArray() + + MediaConnectionUtils.scanFiles(context, hybridFiles) + + val savedPaths = ShadowMediaScannerConnection.getSavedPaths() + assertEquals( + "All ${paths.size} paths should have been submitted for scanning", + paths.size, + savedPaths.size, + ) + paths.forEach { path -> + assertTrue("Path $path should be in savedPaths", savedPaths.contains(path)) + } + } + + /** + * [MediaConnectionUtils.scanFiles] with an empty array must not submit any path. + */ + @Test + fun testScanFilesWithEmptyArraySubmitsNoPaths() { + MediaConnectionUtils.scanFiles(context, emptyArray()) + + assertTrue( + "No paths should be saved when scanning an empty array", + ShadowMediaScannerConnection.getSavedPaths().isEmpty(), + ) + } + + // ------------------------------------------------------------------------- + // scanFileByFileSystemPathAndMimeType (new method) + // ------------------------------------------------------------------------- + + /** + * Calling [MediaConnectionUtils.scanFileByFileSystemPathAndMimeType] with an explicit + * mime-type must submit the path AND the mime-type to [MediaScannerConnection.scanFile]. + */ + @Test + fun testScanFileByFileSystemPathAndMimeTypeWithMimeTypeSubmitsPathAndMimeType() { + val path = "/storage/emulated/0/Music/ringtone.mp3" + val mimeType = "audio/mpeg" + + MediaConnectionUtils.scanFileByFileSystemPathAndMimeType( + context, + path, + mimeType, + ) + + val savedPaths = ShadowMediaScannerConnection.getSavedPaths() + val savedMimeTypes = ShadowMediaScannerConnection.getSavedMimeTypes() + + assertTrue("Path should be submitted to scanner", savedPaths.contains(path)) + assertTrue( + "Mime type should be submitted to scanner", + savedMimeTypes.contains(mimeType), + ) + } + + /** + * Calling [MediaConnectionUtils.scanFileByFileSystemPathAndMimeType] with a `null` + * mime-type must still submit the path; no mime-type entry should be recorded. + */ + @Test + fun testScanFileByFileSystemPathAndMimeTypeWithNullMimeTypeSubmitsOnlyPath() { + val path = "/storage/emulated/0/DCIM/photo.png" + + MediaConnectionUtils.scanFileByFileSystemPathAndMimeType( + context = context, + path = path, + mimeType = null, + ) + + val savedPaths = ShadowMediaScannerConnection.getSavedPaths() + val savedMimeTypes = ShadowMediaScannerConnection.getSavedMimeTypes() + + assertTrue("Path should be submitted to scanner", savedPaths.contains(path)) + assertTrue( + "No mime-type should be recorded when null is passed", + savedMimeTypes.isEmpty(), + ) + } + + /** + * When a [MediaScannerConnection.OnScanCompletedListener] callback is provided, it must be + * passed through to [MediaScannerConnection.scanFile]. Robolectric's + * [ShadowMediaScannerConnection] does not auto-invoke the callback, but the method under + * test should not swallow it — verified here by checking that providing a callback does not + * prevent the path from being registered for scanning. + */ + @Test + fun testScanFileByFileSystemPathAndMimeTypeWithCallbackRegistersPath() { + val path = "/storage/emulated/0/Music/notification.ogg" + val callbackInvoked = AtomicBoolean(false) + val callback = + MediaScannerConnection.OnScanCompletedListener { _, _ -> + callbackInvoked.set(true) + } + + MediaConnectionUtils.scanFileByFileSystemPathAndMimeType( + context = context, + path = path, + mimeType = "audio/ogg", + callback = callback, + ) + + val savedPaths = ShadowMediaScannerConnection.getSavedPaths() + assertTrue( + "Path should be submitted to scanner even when callback is provided", + savedPaths.contains(path), + ) + // Robolectric's shadow does not invoke the completion callback automatically. + // A separate integration / instrumented test would cover the actual callback execution. + } + + /** + * Default parameter: omitting the optional parameters should compile and submit the path. + * This is the Kotlin-idiomatic call site used by callers that do not care about mime-type + * or completion notification. + */ + @Test + fun testScanFileByFileSystemPathAndMimeTypeDefaultParamsSubmitsPath() { + val path = "/storage/emulated/0/Music/alarm.mp3" + + // Call using only mandatory parameters — mime-type and callback default to null. + MediaConnectionUtils.scanFileByFileSystemPathAndMimeType(context, path) + + assertTrue( + "Path should be submitted even when optional params are omitted", + ShadowMediaScannerConnection.getSavedPaths().contains(path), + ) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/MainFragmentReturnIntentResultsTest.kt b/app/src/test/java/com/amaze/filemanager/ui/fragments/MainFragmentReturnIntentResultsTest.kt new file mode 100644 index 0000000000..dc5a8e1c54 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/MainFragmentReturnIntentResultsTest.kt @@ -0,0 +1,320 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.amaze.filemanager.ui.fragments +import android.app.Activity +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.provider.MediaStore +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.R +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.shadows.ShadowMultiDex +import com.amaze.filemanager.shadows.jcifs.smb.ShadowSmbFile +import com.amaze.filemanager.test.ShadowPasswordUtil +import com.amaze.filemanager.test.ShadowTabHandler +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.MainFragmentReturnIntentResultsTest.Companion.TEST_AUDIO_PATH +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.Schedulers +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.fakes.RoboCursor +import org.robolectric.shadows.ShadowLooper +import org.robolectric.shadows.ShadowSQLiteConnection +import org.robolectric.shadows.ShadowStorageManager +import org.robolectric.shadows.ShadowToast + +/** + * Robolectric tests for the ringtone-picker path in [MainFragment.returnIntentResults]. + * + * Changes covered (against `upstream/release/4.0`): + * - When `MainActivity.mRingtonePickerIntent == true` and the selected file IS indexed in + * MediaStore, the activity result must be RESULT_OK with an intent whose data URI comes from + * MediaStore and carries `canonical=1` and `title=` query parameters. + * - When `mRingtonePickerIntent == true` but the file is NOT found in MediaStore, the activity + * must show an error [android.widget.Toast] and finish with RESULT_CANCELED. + * + * Tests run only on [LOLLIPOP] (API 21) so that `Utils.getUriForBaseFile` returns a plain + * `file://` URI via `Uri.fromFile()` without requiring a configured + * [androidx.core.content.FileProvider]. + * + * Because Robolectric 4.9's in-process MediaStore ContentProvider does not support the + * INSERT → `_data=?` QUERY cycle, the MediaStore query response is simulated by registering a + * [RoboCursor] on the activity's [android.content.ContentResolver] shadow via + * [org.robolectric.shadows.ShadowContentResolver.setCursor]. + */ +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Suppress("StringLiteralDuplication") +@Config( + // API 21: Uri.fromFile() path avoids FileProvider setup complexity. + sdk = [LOLLIPOP], + shadows = [ + ShadowMultiDex::class, + ShadowStorageManager::class, + ShadowPasswordUtil::class, + ShadowSmbFile::class, + ShadowTabHandler::class, + ], +) +class MainFragmentReturnIntentResultsTest { + companion object { + private const val TEST_AUDIO_PATH = "/storage/emulated/0/Music/ringtone_test.mp3" + private const val TEST_AUDIO_FILENAME = "ringtone_test.mp3" + + // Expected title = filename stem (everything before the last '.'). + private const val TEST_AUDIO_STEM = "ringtone_test" + + // Fake MediaStore row id planted into the RoboCursor. + private const val FAKE_MEDIA_ID = 7 + } + + private lateinit var scenario: ActivityScenario + + /** + * Setup before tests. + */ + @Before + fun setUp() { + RxJavaPlugins.reset() + RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.reset() + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + ShadowSQLiteConnection.reset() + } + + /** + * After test clean up. + */ + @After + fun tearDown() { + if (::scenario.isInitialized) { + scenario.close() + } + ShadowSQLiteConnection.reset() + RxAndroidPlugins.reset() + RxJavaPlugins.reset() + } + + /** Builds an [Intent] that starts [MainActivity] as a ringtone picker. */ + private fun ringtonePickerIntent(context: android.content.Context): Intent = + Intent(context, MainActivity::class.java).apply { + action = RingtoneManager.ACTION_RINGTONE_PICKER + } + + /** + * Registers a single-row [RoboCursor] against [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI] + * on the given [MainActivity]'s ContentResolver shadow so that + * [com.amaze.filemanager.filesystem.MediaStoreHack.getUriForMusicMediaFrom] returns a + * non-null URI when queried for [TEST_AUDIO_PATH]. + * + * This works around Robolectric 4.9's lack of MediaStore INSERT→QUERY support. + */ + private fun registerFakeMediaStoreCursor(activity: MainActivity) { + val cursor = + RoboCursor().apply { + setColumnNames(listOf(MediaStore.Audio.Media._ID)) + setResults(arrayOf>(arrayOf(FAKE_MEDIA_ID))) + } + shadowOf(activity.contentResolver) + .setCursor(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, cursor) + } + + /** Extracts the first [MainFragment] from the currently active [TabFragment]. */ + private fun MainActivity.firstMainFragment(): MainFragment? = getTabFragment()?.getFragmentAtIndex(0) as? MainFragment + + /** Creates a minimal [HybridFileParcelable] for [TEST_AUDIO_PATH]. */ + private fun audioFileParcelable(): HybridFileParcelable = HybridFileParcelable(TEST_AUDIO_PATH).also { it.name = TEST_AUDIO_FILENAME } + + /** + * Happy path: the selected audio file IS indexed in MediaStore. + * + * Expected behaviour: + * - `Activity.setResult(RESULT_OK, intent)` is called. + * - `intent.data` is a `content://` URI under [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI]. + * - The URI carries `canonical=1` and `title=` query parameters. + * - [RingtoneManager.EXTRA_RINGTONE_PICKED_URI] extra equals `intent.data`. + */ + @Test + fun testReturnIntentResultsForRingtonePickerWhenFileFoundInMediaStore() { + val ctx = ApplicationProvider.getApplicationContext() + scenario = ActivityScenario.launch(ringtonePickerIntent(ctx)) + ShadowLooper.idleMainLooper() + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { activity -> + assertTrue( + "mRingtonePickerIntent must be true after launching with ACTION_RINGTONE_PICKER", + activity.mRingtonePickerIntent, + ) + // Simulate MediaStore having the file indexed via a fake cursor. + registerFakeMediaStoreCursor(activity) + val mainFragment = activity.firstMainFragment() + assertNotNull("MainFragment must be attached to the activity", mainFragment) + mainFragment!!.returnIntentResults(arrayOf(audioFileParcelable())) + ShadowLooper.idleMainLooper() + val shadow = shadowOf(activity) + assertEquals( + "Activity result code must be RESULT_OK", + Activity.RESULT_OK, + shadow.resultCode, + ) + val resultIntent = shadow.resultIntent + assertNotNull("Result intent must not be null", resultIntent) + val resultData: Uri? = resultIntent.data + assertNotNull("Result intent data URI must not be null", resultData) + // URI must be a MediaStore content:// URI. + assertEquals("Result URI scheme must be 'content'", "content", resultData!!.scheme) + assertTrue( + "Result URI must be under MediaStore.Audio.Media.EXTERNAL_CONTENT_URI", + resultData.toString() + .startsWith(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.toString()), + ) + // The fake cursor id must appear as the base path segment (before query params). + val baseUri = resultData.buildUpon().clearQuery().build() + assertEquals( + "Base URI last path segment must match the fake media id", + FAKE_MEDIA_ID.toString(), + baseUri.lastPathSegment, + ) + // Query parameters appended in returnIntentResults. + assertEquals( + "canonical query param must be '1'", + "1", + resultData.getQueryParameter("canonical"), + ) + assertEquals( + "title query param must equal the filename stem", + TEST_AUDIO_STEM, + resultData.getQueryParameter("title"), + ) + // EXTRA_RINGTONE_PICKED_URI must equal the data URI. + @Suppress("DEPRECATION") + val pickedUri: Uri? = + resultIntent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + assertEquals( + "EXTRA_RINGTONE_PICKED_URI must equal intent data", + resultData, + pickedUri, + ) + } + } + + /** + * Error path: the selected audio file is NOT indexed in MediaStore. + * + * Expected behaviour: + * - A [android.widget.Toast] with text [R.string.error_mediastore_query_uri] is shown. + * - `Activity.setResult(RESULT_CANCELED)` is called. + */ + @Test + fun testReturnIntentResultsForRingtonePickerWhenFileNotFoundInMediaStore() { + val ctx = ApplicationProvider.getApplicationContext() + scenario = ActivityScenario.launch(ringtonePickerIntent(ctx)) + ShadowLooper.idleMainLooper() + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { activity -> + assertTrue( + "mRingtonePickerIntent must be true after launching with ACTION_RINGTONE_PICKER", + activity.mRingtonePickerIntent, + ) + // Intentionally do NOT register any cursor → + // ShadowContentResolver returns null for the MediaStore query. + val mainFragment = activity.firstMainFragment() + assertNotNull("MainFragment must be attached to the activity", mainFragment) + mainFragment!!.returnIntentResults(arrayOf(audioFileParcelable())) + ShadowLooper.idleMainLooper() + // Error toast must be displayed. + val latestToast = ShadowToast.getLatestToast() + assertNotNull( + "An error Toast should have been shown when MediaStore lookup fails", + latestToast, + ) + assertEquals( + "Toast must show the MediaStore error string", + ctx.getString(R.string.error_mediastore_query_uri), + ShadowToast.getTextOfLatestToast(), + ) + // Activity must finish with RESULT_CANCELED. + val shadow = shadowOf(activity) + assertEquals( + "Activity result code must be RESULT_CANCELED", + Activity.RESULT_CANCELED, + shadow.resultCode, + ) + } + } + + /** + * Sanity check: when `mRingtonePickerIntent == false` (normal file-pickup mode), + * [MainFragment.returnIntentResults] must NOT query MediaStore and must set a plain + * `file://` data URI on the result intent with RESULT_OK. + * + * This is a regression guard for the unchanged `else`-branch in the original code. + */ + @Test + fun testReturnIntentResultsForNonRingtonePickerUsesFileUri() { + val ctx = ApplicationProvider.getApplicationContext() + // Launch without ACTION_RINGTONE_PICKER — mRingtonePickerIntent stays false. + val plainPickerIntent = + Intent(ctx, MainActivity::class.java).apply { + action = Intent.ACTION_GET_CONTENT + } + scenario = ActivityScenario.launch(plainPickerIntent) + ShadowLooper.idleMainLooper() + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { activity -> + // Set mReturnIntent manually (normally set by the GET_CONTENT handler). + activity.mReturnIntent = true + val mainFragment = activity.firstMainFragment() + assertNotNull("MainFragment must be attached to the activity", mainFragment) + mainFragment!!.returnIntentResults(arrayOf(audioFileParcelable())) + ShadowLooper.idleMainLooper() + val shadow = shadowOf(activity) + assertEquals( + "Activity result code must be RESULT_OK for normal pick", + Activity.RESULT_OK, + shadow.resultCode, + ) + val resultData: Uri? = shadow.resultIntent?.data + assertNotNull("Result URI must not be null for normal pick", resultData) + // On LOLLIPOP, Utils.getUriForBaseFile returns a file:// URI. + assertEquals( + "Normal pick result must use file:// scheme on API 21", + "file", + resultData!!.scheme, + ) + } + } +}