diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index efa17d1820..25e7c0a467 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.koin.annotations) testImplementation(libs.junit4) + testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.androidx.test.espresso.core) } diff --git a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt index 48abe391b7..a2b6f6999f 100644 --- a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt +++ b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt @@ -13,6 +13,7 @@ import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.os.Environment +import android.os.storage.StorageManager import android.provider.DocumentsContract import android.provider.MediaStore import android.provider.OpenableColumns @@ -445,12 +446,27 @@ suspend fun ContentResolver.deleteMedia( } } -fun Context.getStorageVolumes() = try { - getExternalFilesDirs(null)?.mapNotNull { - File(it.path.substringBefore("/Android")).takeIf { file -> file.exists() } - } ?: listOf(Environment.getExternalStorageDirectory()) -} catch (e: Exception) { - listOf(Environment.getExternalStorageDirectory()) +fun Context.getStorageVolumes(): List { + // USB OTG volumes do not necessarily support app-specific external directories. + // On Android 11+, use the public volume roots instead of deriving them from those directories. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + val volumes = getSystemService(StorageManager::class.java)?.storageVolumes + ?.filter { it.state == Environment.MEDIA_MOUNTED || it.state == Environment.MEDIA_MOUNTED_READ_ONLY } + ?.mapNotNull { it.directory } + ?.distinct() + if (!volumes.isNullOrEmpty()) return volumes + } catch (e: Exception) { + // Keep the legacy fallback for devices whose StorageManager is unavailable. + } + } + return try { + getExternalFilesDirs(null)?.mapNotNull { directory -> + directory?.let { File(it.path.substringBefore("/Android")) }?.takeIf { it.exists() } + }?.distinct()?.takeIf { it.isNotEmpty() } ?: listOf(Environment.getExternalStorageDirectory()) + } catch (e: Exception) { + listOf(Environment.getExternalStorageDirectory()) + } } fun Context.appIcon(): Bitmap? { diff --git a/core/common/src/test/java/dev/anilbeesetti/nextplayer/core/common/extensions/StorageVolumesTest.kt b/core/common/src/test/java/dev/anilbeesetti/nextplayer/core/common/extensions/StorageVolumesTest.kt new file mode 100644 index 0000000000..906d51cc96 --- /dev/null +++ b/core/common/src/test/java/dev/anilbeesetti/nextplayer/core/common/extensions/StorageVolumesTest.kt @@ -0,0 +1,72 @@ +package dev.anilbeesetti.nextplayer.core.common.extensions + +import android.content.Context +import android.content.ContextWrapper +import android.os.Environment +import android.os.Process +import android.os.storage.StorageManager +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.StorageVolumeBuilder + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30, 35], manifest = Config.NONE) +class StorageVolumesTest { + private val application = RuntimeEnvironment.getApplication() + private val storageManager = application.getSystemService(StorageManager::class.java) + private val context = object : ContextWrapper(application) { + override fun getExternalFilesDirs(type: String?): Array = arrayOf(application.getExternalFilesDir(type)) + } + + @Test + fun `mounted USB without an app-specific directory is discovered`() { + val usb = addVolume("USB", Environment.MEDIA_MOUNTED) + assertTrue(context.getStorageVolumes().contains(usb)) + } + + @Test + fun `read-only mounted volumes are included and unmounted volumes are excluded`() { + val readOnly = addVolume("read-only", Environment.MEDIA_MOUNTED_READ_ONLY) + val unmounted = addVolume("unmounted", Environment.MEDIA_UNMOUNTED) + val volumes = context.getStorageVolumes() + assertTrue(volumes.contains(readOnly)) + assertTrue(!volumes.contains(unmounted)) + } + + @Test + fun `null legacy directory entries do not discard other volumes`() { + val legacy = File(application.cacheDir, "legacy/Android/data/test/files").apply { mkdirs() } + val legacyContext = object : ContextWrapper(application) { + override fun getExternalFilesDirs(type: String?): Array = arrayOf(null, legacy) + } + assertTrue(legacyContext.getStorageVolumes().contains(File(application.cacheDir, "legacy"))) + } + + @Test + @Config(sdk = [24, 29]) + fun `legacy storage roots remain available`() { + val root = File(application.cacheDir, "legacy").apply { mkdirs() } + val legacyContext = object : ContextWrapper(application) { + override fun getExternalFilesDirs(type: String?): Array = arrayOf(File(root, "Android/data/test/files")) + } + assertEquals(listOf(root), legacyContext.getStorageVolumes()) + } + + private fun addVolume(name: String, state: String): File { + val root = File(application.cacheDir, name).apply { mkdirs() } + shadowOf(storageManager).addStorageVolume( + StorageVolumeBuilder(name, root, name, Process.myUserHandle(), state) + .setIsPrimary(false) + .setIsRemovable(true) + .build(), + ) + return root + } +} diff --git a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/services/MediaStoreMediaService.kt b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/services/MediaStoreMediaService.kt index 252c9ad662..16ee05aaa1 100644 --- a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/services/MediaStoreMediaService.kt +++ b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/services/MediaStoreMediaService.kt @@ -1,19 +1,28 @@ package dev.anilbeesetti.nextplayer.core.media.services +import android.content.BroadcastReceiver import android.content.ContentUris import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.database.ContentObserver import android.database.Cursor import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Environment import android.provider.MediaStore +import android.util.Log import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat import dev.anilbeesetti.nextplayer.core.common.di.DiQualifiers import dev.anilbeesetti.nextplayer.core.common.extensions.VIDEO_COLLECTION_URI +import dev.anilbeesetti.nextplayer.core.common.extensions.getStorageVolumes import dev.anilbeesetti.nextplayer.core.common.extensions.prettyName +import dev.anilbeesetti.nextplayer.core.common.extensions.scanStorage import java.io.File import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -26,6 +35,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.Named import org.koin.core.annotation.Single @@ -68,14 +78,54 @@ class MediaStoreMediaService( */ @OptIn(FlowPreview::class) private val mediaChanges: Flow = callbackFlow { + fun scanVolumes(roots: List) = launch(Dispatchers.IO) { + for (root in roots) { + try { + context.scanStorage(root.path) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The drive may have been removed or become inaccessible during the scan. + Log.w("MediaStoreMediaService", "Unable to scan storage volume", e) + } + } + trySend(Unit) + } + val observer = object : ContentObserver(null) { override fun onChange(selfChange: Boolean) { trySend(Unit) } } + val storageReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(Unit) + if (intent.action == Intent.ACTION_MEDIA_MOUNTED) { + intent.data?.path?.let { scanVolumes(listOf(File(it))) } + } + } + } + val storageFilter = IntentFilter().apply { + addAction(Intent.ACTION_MEDIA_MOUNTED) + addAction(Intent.ACTION_MEDIA_UNMOUNTED) + addAction(Intent.ACTION_MEDIA_EJECT) + addAction(Intent.ACTION_MEDIA_REMOVED) + addAction(Intent.ACTION_MEDIA_BAD_REMOVAL) + addAction(Intent.ACTION_MEDIA_SCANNER_FINISHED) + addDataScheme("file") + } + // These are protected system broadcasts. MediaProvider can run under its own UID, + // so the receiver must also accept broadcasts from outside the application's UID. + ContextCompat.registerReceiver(context, storageReceiver, storageFilter, ContextCompat.RECEIVER_EXPORTED) context.contentResolver.registerContentObserver(VIDEO_COLLECTION_URI, true, observer) trySend(Unit) - awaitClose { context.contentResolver.unregisterContentObserver(observer) } + // Index already connected removable storage when opening the library. The primary + // volume is indexed by Android and does not need another scan on each subscription. + scanVolumes(context.getStorageVolumes().filter { it != Environment.getExternalStorageDirectory() }) + awaitClose { + context.contentResolver.unregisterContentObserver(observer) + context.unregisterReceiver(storageReceiver) + } } .debounce(OBSERVER_DEBOUNCE_MS.milliseconds) .shareIn( diff --git a/core/media/src/test/java/dev/anilbeesetti/nextplayer/core/media/services/MediaStoreStorageTest.kt b/core/media/src/test/java/dev/anilbeesetti/nextplayer/core/media/services/MediaStoreStorageTest.kt new file mode 100644 index 0000000000..b27f2db90f --- /dev/null +++ b/core/media/src/test/java/dev/anilbeesetti/nextplayer/core/media/services/MediaStoreStorageTest.kt @@ -0,0 +1,193 @@ +package dev.anilbeesetti.nextplayer.core.media.services + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.database.Cursor +import android.database.MatrixCursor +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Environment +import android.os.Looper +import android.os.Process +import android.os.storage.StorageManager +import java.io.File +import java.util.Collections +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowContentResolver +import org.robolectric.shadows.StorageVolumeBuilder + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30, 35], manifest = Config.NONE, shadows = [MediaStoreStorageTest.Scanner::class]) +class MediaStoreStorageTest { + private val application = RuntimeEnvironment.getApplication() + private val context = object : ContextWrapper(application) { + override fun getExternalFilesDirs(type: String?): Array = arrayOf(application.getExternalFilesDir(type)) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val provider = VideoProvider() + private val service = MediaStoreMediaService(context, scope) + private val usb = File(context.cacheDir, "USB").apply { mkdirs() } + private val video = File(usb, "video.mp4").apply { writeText("video") } + + @Before + fun setUp() { + Scanner.paths.clear() + Scanner.failure = null + ShadowContentResolver.registerProviderInternal("media", provider) + } + + @After + fun tearDown() { + scope.cancel() + usb.deleteRecursively() + } + + @Test + fun `opening the library scans a USB volume omitted from app-specific directories`() = runBlocking { + mountUsb() + provider.video = video + provider.requiredScanPath = usb.path + val emissions = Channel>(Channel.UNLIMITED) + val collector = launch { service.observeVideos().collect { emissions.send(it) } } + try { + withTimeout(5_000) { + var videos = emissions.receive() + while (videos.isEmpty()) videos = emissions.receive() + assertEquals(video.path, videos.single().path) + assertTrue(Scanner.paths.contains(usb.path)) + assertTrue(!Scanner.paths.contains(Environment.getExternalStorageDirectory().path)) + } + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `mount and removal update videos and folders without video-table notifications`() = runBlocking { + for (folders in listOf(false, true)) { + Scanner.paths.clear() + provider.video = null + provider.requiredScanPath = usb.path + val emissions = Channel>(Channel.UNLIMITED) + val flow = if (folders) service.observeFolders() else service.observeVideos() + val collector = launch { flow.collect { emissions.send(it) } } + try { + withTimeout(5_000) { + assertEquals(emptyList(), emissions.receive()) + provider.video = video + sendStorageBroadcast(Intent.ACTION_MEDIA_MOUNTED) + assertEquals(1, emissions.receive().size) + assertTrue(Scanner.paths.contains(usb.path)) + provider.video = null + sendStorageBroadcast(Intent.ACTION_MEDIA_UNMOUNTED) + assertEquals(emptyList(), emissions.receive()) + provider.video = video + sendStorageBroadcast(Intent.ACTION_MEDIA_SCANNER_FINISHED) + assertEquals(1, emissions.receive().size) + } + } finally { + collector.cancelAndJoin() + } + } + } + + @Test + fun `a failed storage scan does not stop library updates`() = runBlocking { + mountUsb() + Scanner.failure = SecurityException("Drive became inaccessible") + provider.video = video + val emissions = Channel>(Channel.UNLIMITED) + val collector = launch { service.observeVideos().collect { emissions.send(it) } } + try { + withTimeout(5_000) { + assertEquals(video.path, emissions.receive().single().path) + provider.video = null + sendStorageBroadcast(Intent.ACTION_MEDIA_UNMOUNTED) + assertEquals(emptyList(), emissions.receive()) + Scanner.failure = null + provider.video = video + provider.requiredScanPath = usb.path + sendStorageBroadcast(Intent.ACTION_MEDIA_MOUNTED) + assertEquals(video.path, emissions.receive().single().path) + } + } finally { + collector.cancelAndJoin() + } + } + + private fun mountUsb() { + shadowOf(context.getSystemService(StorageManager::class.java)).addStorageVolume( + StorageVolumeBuilder("USB", usb, "USB", Process.myUserHandle(), Environment.MEDIA_MOUNTED) + .setIsPrimary(false) + .setIsRemovable(true) + .build(), + ) + } + + private fun sendStorageBroadcast(action: String) { + context.sendBroadcast(Intent(action, Uri.fromFile(usb))) + shadowOf(Looper.getMainLooper()).idle() + } + + @Implements(MediaScannerConnection::class) + class Scanner { + companion object { + val paths: MutableList = Collections.synchronizedList(mutableListOf()) + var failure: RuntimeException? = null + + @JvmStatic + @Implementation + fun scanFile(context: Context, paths: Array, mimeTypes: Array?, callback: MediaScannerConnection.OnScanCompletedListener?) { + failure?.let { throw it } + paths.forEach { + this.paths.add(it) + callback?.onScanCompleted(it, null) + } + } + } + } + + private class VideoProvider : ContentProvider() { + @Volatile + var video: File? = null + var requiredScanPath: String? = null + + override fun onCreate(): Boolean = true + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor { + return MatrixCursor(projection).apply { + video?.takeIf { requiredScanPath == null || Scanner.paths.contains(requiredScanPath) }?.let { + addRow(arrayOf(1L, it.path, it.name, 100L, 1080, 1920, it.length(), 1L, 2L)) + } + } + } + + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 + } +}