Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<File> {
// 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? {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File?> = 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<File?> = 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<File?> = 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
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -68,14 +78,54 @@ class MediaStoreMediaService(
*/
@OptIn(FlowPreview::class)
private val mediaChanges: Flow<Unit> = callbackFlow {
fun scanVolumes(roots: List<File>) = 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(
Expand Down
Loading
Loading