|
| 1 | +/* |
| 2 | + * Copyright (C) 2014-2024 Arpit Khurana <arpitkh96@gmail.com>, Vishal Nehra <vishalneham2@gmail.com>, |
| 3 | + * Emmanuel Messulam<emmanuelbendavid@gmail.com>, Raymond Lai <airwave209gt at gmail.com> and Contributors. |
| 4 | + * |
| 5 | + * This file is part of Amaze File Manager. |
| 6 | + * |
| 7 | + * Amaze File Manager is free software: you can redistribute it and/or modify |
| 8 | + * it under the terms of the GNU General Public License as published by |
| 9 | + * the Free Software Foundation, either version 3 of the License, or |
| 10 | + * (at your option) any later version. |
| 11 | + * |
| 12 | + * This program is distributed in the hope that it will be useful, |
| 13 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 14 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 15 | + * GNU General Public License for more details. |
| 16 | + * |
| 17 | + * You should have received a copy of the GNU General Public License |
| 18 | + * along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 19 | + */ |
| 20 | + |
| 21 | +package com.amaze.filemanager.filesystem |
| 22 | + |
| 23 | +import android.content.ContentResolver |
| 24 | +import android.content.Context |
| 25 | +import android.database.Cursor |
| 26 | +import android.os.Build |
| 27 | +import android.os.Build.VERSION_CODES.LOLLIPOP |
| 28 | +import android.os.Build.VERSION_CODES.P |
| 29 | +import android.provider.MediaStore |
| 30 | +import androidx.test.ext.junit.runners.AndroidJUnit4 |
| 31 | +import com.amaze.filemanager.filesystem.MediaStoreHackTest.Companion.FAKE_ROW_ID |
| 32 | +import com.amaze.filemanager.shadows.ShadowMultiDex |
| 33 | +import io.mockk.every |
| 34 | +import io.mockk.just |
| 35 | +import io.mockk.mockk |
| 36 | +import io.mockk.runs |
| 37 | +import io.mockk.unmockkAll |
| 38 | +import org.junit.After |
| 39 | +import org.junit.Assert.assertEquals |
| 40 | +import org.junit.Assert.assertNotNull |
| 41 | +import org.junit.Assert.assertNull |
| 42 | +import org.junit.Assert.assertTrue |
| 43 | +import org.junit.Test |
| 44 | +import org.junit.runner.RunWith |
| 45 | +import org.robolectric.annotation.Config |
| 46 | + |
| 47 | +/** |
| 48 | + * Robolectric tests for [MediaStoreHack.getUriForMusicMediaFrom]. |
| 49 | + * |
| 50 | + * The new method queries [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI] by file-system path and |
| 51 | + * returns a content:// [android.net.Uri] when a matching row exists, or `null` when it does not. |
| 52 | + * |
| 53 | + * Robolectric is needed so that Android framework statics (e.g. |
| 54 | + * [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI], [android.net.Uri]) are properly initialised. |
| 55 | + * The [ContentResolver] itself is mocked via MockK so tests are not affected by Robolectric 4.9's |
| 56 | + * limited in-process MediaStore ContentProvider support. |
| 57 | + */ |
| 58 | +@RunWith(AndroidJUnit4::class) |
| 59 | +@Config( |
| 60 | + sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], |
| 61 | + shadows = [ShadowMultiDex::class], |
| 62 | +) |
| 63 | +class MediaStoreHackTest { |
| 64 | + companion object { |
| 65 | + private const val TEST_AUDIO_PATH = "/storage/emulated/0/Music/test_ringtone.mp3" |
| 66 | + private const val ABSENT_AUDIO_PATH = "/storage/emulated/0/Music/nonexistent.mp3" |
| 67 | + private const val FAKE_ROW_ID = 42 |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * Builds a mock [Context] whose [ContentResolver] returns [cursor] for any query |
| 72 | + * against [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI]. |
| 73 | + */ |
| 74 | + private fun contextWithCursor(cursor: Cursor?): Context { |
| 75 | + val mockResolver = mockk<ContentResolver>() |
| 76 | + every { |
| 77 | + mockResolver.query( |
| 78 | + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, |
| 79 | + any(), |
| 80 | + any(), |
| 81 | + any(), |
| 82 | + null, |
| 83 | + ) |
| 84 | + } returns cursor |
| 85 | + |
| 86 | + return mockk<Context>().also { ctx -> |
| 87 | + every { ctx.contentResolver } returns mockResolver |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + /** |
| 92 | + * Builds a mock [Cursor] that simulates a single-row result with [_ID][MediaStore.Audio.Media._ID] |
| 93 | + * equal to [FAKE_ROW_ID]. |
| 94 | + */ |
| 95 | + private fun singleRowCursor(): Cursor { |
| 96 | + val idColumnIndex = 0 |
| 97 | + return mockk<Cursor>(relaxed = true).also { cursor -> |
| 98 | + every { cursor.moveToFirst() } returns true |
| 99 | + every { cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) } returns idColumnIndex |
| 100 | + every { cursor.getInt(idColumnIndex) } returns FAKE_ROW_ID |
| 101 | + every { cursor.close() } just runs |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + /** |
| 106 | + * Builds a mock [Cursor] that simulates an empty result set (no matching rows). |
| 107 | + */ |
| 108 | + private fun emptyCursor(): Cursor = |
| 109 | + mockk<Cursor>(relaxed = true).also { cursor -> |
| 110 | + every { cursor.moveToFirst() } returns false |
| 111 | + every { cursor.close() } just runs |
| 112 | + } |
| 113 | + |
| 114 | + /** |
| 115 | + * After test clean up. |
| 116 | + */ |
| 117 | + @After |
| 118 | + fun tearDown() { |
| 119 | + unmockkAll() |
| 120 | + } |
| 121 | + |
| 122 | + // ------------------------------------------------------------------------- |
| 123 | + // Tests |
| 124 | + // ------------------------------------------------------------------------- |
| 125 | + |
| 126 | + /** |
| 127 | + * When the ContentResolver returns a cursor with a matching row, |
| 128 | + * [MediaStoreHack.getUriForMusicMediaFrom] must return a non-null `content://` URI under |
| 129 | + * [MediaStore.Audio.Media.EXTERNAL_CONTENT_URI] whose last path segment is the row id. |
| 130 | + */ |
| 131 | + @Test |
| 132 | + fun testGetUriForMusicMediaFromReturnsUriWhenCursorHasMatchingRow() { |
| 133 | + val context = contextWithCursor(singleRowCursor()) |
| 134 | + |
| 135 | + val result = MediaStoreHack.getUriForMusicMediaFrom(TEST_AUDIO_PATH, context) |
| 136 | + |
| 137 | + assertNotNull("Expected a non-null URI when the cursor has a matching row", result) |
| 138 | + assertEquals("content", result!!.scheme) |
| 139 | + assertTrue( |
| 140 | + "Returned URI should be under MediaStore.Audio.Media.EXTERNAL_CONTENT_URI", |
| 141 | + result.toString().startsWith( |
| 142 | + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.toString(), |
| 143 | + ), |
| 144 | + ) |
| 145 | + assertEquals( |
| 146 | + "Last path segment must equal the row id returned by the cursor", |
| 147 | + FAKE_ROW_ID.toString(), |
| 148 | + result.lastPathSegment, |
| 149 | + ) |
| 150 | + } |
| 151 | + |
| 152 | + /** |
| 153 | + * When the ContentResolver returns a cursor with NO matching rows, |
| 154 | + * [MediaStoreHack.getUriForMusicMediaFrom] must return `null`. |
| 155 | + */ |
| 156 | + @Test |
| 157 | + fun testGetUriForMusicMediaFromReturnsNullWhenCursorIsEmpty() { |
| 158 | + val context = contextWithCursor(emptyCursor()) |
| 159 | + |
| 160 | + val result = MediaStoreHack.getUriForMusicMediaFrom(ABSENT_AUDIO_PATH, context) |
| 161 | + |
| 162 | + assertNull( |
| 163 | + "Expected null URI when the cursor is empty (no matching row)", |
| 164 | + result, |
| 165 | + ) |
| 166 | + } |
| 167 | + |
| 168 | + /** |
| 169 | + * When the ContentResolver returns a `null` cursor (provider error or unavailable), |
| 170 | + * [MediaStoreHack.getUriForMusicMediaFrom] must return `null` without throwing. |
| 171 | + */ |
| 172 | + @Test |
| 173 | + fun testGetUriForMusicMediaFromReturnsNullWhenCursorIsNull() { |
| 174 | + val context = contextWithCursor(null) |
| 175 | + |
| 176 | + val result = MediaStoreHack.getUriForMusicMediaFrom(TEST_AUDIO_PATH, context) |
| 177 | + |
| 178 | + assertNull( |
| 179 | + "Expected null URI when the ContentResolver returns a null cursor", |
| 180 | + result, |
| 181 | + ) |
| 182 | + } |
| 183 | + |
| 184 | + /** |
| 185 | + * The URI returned by [MediaStoreHack.getUriForMusicMediaFrom] must use only |
| 186 | + * the row [_ID][MediaStore.Audio.Media._ID] from the cursor — confirming that |
| 187 | + * different row ids produce distinct URIs (selection correctness guard). |
| 188 | + */ |
| 189 | + @Test |
| 190 | + fun testGetUriForMusicMediaFromBuildsUriFromCursorId() { |
| 191 | + val alternativeId = 99 |
| 192 | + val altCursor = |
| 193 | + mockk<Cursor>(relaxed = true).also { cursor -> |
| 194 | + every { cursor.moveToFirst() } returns true |
| 195 | + every { cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) } returns 0 |
| 196 | + every { cursor.getInt(0) } returns alternativeId |
| 197 | + every { cursor.close() } just runs |
| 198 | + } |
| 199 | + val context = contextWithCursor(altCursor) |
| 200 | + |
| 201 | + val result = MediaStoreHack.getUriForMusicMediaFrom(TEST_AUDIO_PATH, context) |
| 202 | + |
| 203 | + assertNotNull(result) |
| 204 | + assertEquals( |
| 205 | + "URI last path segment must equal the alternative row id", |
| 206 | + alternativeId.toString(), |
| 207 | + result!!.lastPathSegment, |
| 208 | + ) |
| 209 | + // Verify it is distinct from a URI built with FAKE_ROW_ID |
| 210 | + val firstResult = |
| 211 | + MediaStoreHack.getUriForMusicMediaFrom( |
| 212 | + TEST_AUDIO_PATH, |
| 213 | + contextWithCursor(singleRowCursor()), |
| 214 | + ) |
| 215 | + assertTrue( |
| 216 | + "URIs built from different row ids must differ", |
| 217 | + result.toString() != firstResult.toString(), |
| 218 | + ) |
| 219 | + } |
| 220 | +} |
0 commit comments