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
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
Expand Down Expand Up @@ -44,4 +45,4 @@
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>
</manifest>
19 changes: 13 additions & 6 deletions app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dagger.hilt.android.AndroidEntryPoint
import dev.anilbeesetti.nextplayer.core.common.storagePermission
import dev.anilbeesetti.nextplayer.core.common.hasFullStoragePermission
import dev.anilbeesetti.nextplayer.core.common.storagePermissions
import dev.anilbeesetti.nextplayer.core.media.services.MediaService
import dev.anilbeesetti.nextplayer.core.media.sync.MediaSynchronizer
import dev.anilbeesetti.nextplayer.core.model.ThemeConfig
Expand Down Expand Up @@ -102,15 +103,21 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
val storagePermissionState = rememberPermissionState(permission = storagePermission)
val storagePermissionsState = rememberMultiplePermissionsState(permissions = storagePermissions)
val permissionGrants = storagePermissionsState.permissions.associate {
it.permission to it.status.isGranted
}
val hasFullStorageAccess = hasFullStoragePermission(permissionGrants)

LifecycleEventEffect(event = Lifecycle.Event.ON_START) {
storagePermissionState.launchPermissionRequest()
storagePermissionsState.launchMultiplePermissionRequest()
}

LaunchedEffect(key1 = storagePermissionState.status.isGranted) {
if (storagePermissionState.status.isGranted) {
LaunchedEffect(hasFullStorageAccess) {
if (hasFullStorageAccess) {
synchronizer.startSync()
} else {
synchronizer.stopSync()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,39 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.abs

val storagePermission = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.READ_MEDIA_VIDEO
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Manifest.permission.READ_EXTERNAL_STORAGE
else -> Manifest.permission.WRITE_EXTERNAL_STORAGE
fun resolveStoragePermissions(
sdkInt: Int = Build.VERSION.SDK_INT,
): List<String> = when {
sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf(
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
)
sdkInt >= Build.VERSION_CODES.TIRAMISU -> listOf(Manifest.permission.READ_MEDIA_VIDEO)
sdkInt >= Build.VERSION_CODES.R -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
else -> listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}

val storagePermissions = resolveStoragePermissions()

val storagePermission = storagePermissions.first()

fun hasFullStoragePermission(
permissionGrants: Map<String, Boolean>,
sdkInt: Int = Build.VERSION.SDK_INT,
): Boolean = when {
sdkInt >= Build.VERSION_CODES.TIRAMISU -> permissionGrants[Manifest.permission.READ_MEDIA_VIDEO] == true
sdkInt >= Build.VERSION_CODES.R -> permissionGrants[Manifest.permission.READ_EXTERNAL_STORAGE] == true
else -> permissionGrants[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true
}

fun hasLimitedStoragePermission(
permissionGrants: Map<String, Boolean>,
sdkInt: Int = Build.VERSION.SDK_INT,
): Boolean {
if (sdkInt < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return false
val hasSelectedAccess = permissionGrants[Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED] == true
val hasFullAccess = hasFullStoragePermission(permissionGrants, sdkInt)
return hasSelectedAccess && !hasFullAccess
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package dev.anilbeesetti.nextplayer.core.common

import android.Manifest
import android.os.Build
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class StoragePermissionTest {

@Test
fun `resolveStoragePermissions returns video and selected permissions on android 14 plus`() {
val permissions = resolveStoragePermissions(sdkInt = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)

assertEquals(
listOf(
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
),
permissions,
)
}

@Test
fun `hasFullStoragePermission returns true when read media video is granted`() {
val hasFullAccess = hasFullStoragePermission(
permissionGrants = mapOf(
Manifest.permission.READ_MEDIA_VIDEO to true,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED to false,
),
sdkInt = Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
)

assertTrue(hasFullAccess)
}

@Test
fun `hasLimitedStoragePermission returns true when only selected media access is granted`() {
val hasLimitedAccess = hasLimitedStoragePermission(
permissionGrants = mapOf(
Manifest.permission.READ_MEDIA_VIDEO to false,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED to true,
),
sdkInt = Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
)

assertTrue(hasLimitedAccess)
}

@Test
fun `hasLimitedStoragePermission returns false when full access is granted`() {
val hasLimitedAccess = hasLimitedStoragePermission(
permissionGrants = mapOf(
Manifest.permission.READ_MEDIA_VIDEO to true,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED to true,
),
sdkInt = Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
)

assertFalse(hasLimitedAccess)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,14 @@ class LocalMediaSynchronizer @Inject constructor(
val dateModifiedColumn = cursor.getColumnIndex(MediaStore.Video.Media.DATE_MODIFIED)

while (cursor.moveToNext()) {
if (dataColumn == -1 || cursor.isNull(dataColumn)) continue

val id = cursor.getLong(idColumn)
val data = cursor.getString(dataColumn)
mediaVideos.add(
MediaVideo(
id = id,
data = cursor.getString(dataColumn),
data = data,
duration = cursor.getLong(durationColumn),
uri = ContentUris.withAppendedId(VIDEO_COLLECTION_URI, id),
width = cursor.getInt(widthColumn),
Expand All @@ -239,7 +242,7 @@ class LocalMediaSynchronizer @Inject constructor(
)
}
}
return mediaVideos.filter { File(it.data).exists() }
return mediaVideos.filter { it.data.isNotBlank() }
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ import dev.anilbeesetti.nextplayer.core.ui.R
@Composable
fun PermissionMissingView(
isGranted: Boolean,
isLimitedAccess: Boolean,
showRationale: Boolean,
permission: String,
launchPermissionRequest: () -> Unit,
content: @Composable () -> Unit,
) {
if (isGranted) {
content()
} else if (isLimitedAccess) {
PermissionRationaleDialog(
text = stringResource(id = R.string.permission_limited_info),
onConfirmButtonClick = launchPermissionRequest,
)
} else if (showRationale) {
PermissionRationaleDialog(
text = stringResource(
Expand Down
1 change: 1 addition & 0 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<string name="no_videos_found">No videos found</string>
<string name="open_settings">Open Settings</string>
<string name="permission_info">The Next Player app needs \"%1$s\" permission to access and play media. Without it, media access and playback won\'t work. To use the app fully, grant the permission.</string>
<string name="permission_limited_info">Limited media access is enabled. To show newly added videos, grant full media access.</string>
<string name="permission_not_granted">Permission Not Granted</string>
<string name="permission_request">Permission Request</string>
<string name="permission_settings">The Next Player app needs \"%1$s\" permission to access and play media. Without it, media access and playback won\'t work. To use the app fully, grant the permission from settings since it was permanently denied.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,12 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.shouldShowRationale
import dev.anilbeesetti.nextplayer.core.common.hasFullStoragePermission
import dev.anilbeesetti.nextplayer.core.common.hasLimitedStoragePermission
import dev.anilbeesetti.nextplayer.core.common.storagePermission
import dev.anilbeesetti.nextplayer.core.common.storagePermissions
import dev.anilbeesetti.nextplayer.core.media.services.MediaService
import dev.anilbeesetti.nextplayer.core.model.ApplicationPreferences
import dev.anilbeesetti.nextplayer.core.model.Folder
Expand Down Expand Up @@ -141,7 +144,11 @@ internal fun MediaPickerScreen(
onEvent: (MediaPickerUiEvent) -> Unit = {},
) {
val selectionManager = rememberSelectionManager()
val permissionState = rememberPermissionState(permission = storagePermission)
val permissionsState = rememberMultiplePermissionsState(permissions = storagePermissions)
val permissionGrants = permissionsState.permissions.associate { it.permission to it.status.isGranted }
val hasFullStorageAccess = hasFullStoragePermission(permissionGrants)
val hasLimitedStorageAccess = hasLimitedStoragePermission(permissionGrants)
val shouldShowPermissionRationale = permissionsState.permissions.any { it.status.shouldShowRationale }
val lazyGridState = rememberLazyGridState()
val selectVideoFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
Expand Down Expand Up @@ -375,10 +382,11 @@ internal fun MediaPickerScreen(
) {
val updatedScaffoldPadding = scaffoldPadding.copy(top = 0.dp, start = 0.dp)
PermissionMissingView(
isGranted = permissionState.status.isGranted,
showRationale = permissionState.status.shouldShowRationale,
permission = permissionState.permission,
launchPermissionRequest = { permissionState.launchPermissionRequest() },
isGranted = hasFullStorageAccess,
isLimitedAccess = hasLimitedStorageAccess,
showRationale = shouldShowPermissionRationale,
permission = storagePermission,
launchPermissionRequest = { permissionsState.launchMultiplePermissionRequest() },
) {
val rootFolder = uiState.mediaDataState.value
if (rootFolder == null || rootFolder.folderList.isEmpty() && rootFolder.mediaList.isEmpty()) {
Expand Down
Loading