feat: support android 14 partial file access API

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-21 15:10:57 +02:00
parent 1eec2499ee
commit c3994ed9c5
4 changed files with 175 additions and 14 deletions

View File

@ -5,8 +5,11 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Android 14+ -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />

View File

@ -1,12 +1,16 @@
package chat.revolt.components.media package chat.revolt.components.media
import android.Manifest
import android.content.ContentUris import android.content.ContentUris
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
@ -44,7 +48,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -57,11 +63,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import chat.revolt.R import chat.revolt.R
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage import com.bumptech.glide.integration.compose.GlideImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
data class Media( data class Media(
@ -87,7 +93,7 @@ private fun Long.formatAsLengthDuration(): String {
return components.joinToString(separator = "") return components.joinToString(separator = "")
} }
@OptIn(ExperimentalPermissionsApi::class, ExperimentalGlideComposeApi::class) @OptIn(ExperimentalGlideComposeApi::class)
@RequiresApi(Build.VERSION_CODES.TIRAMISU) @RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable @Composable
fun InbuiltMediaPicker( fun InbuiltMediaPicker(
@ -98,23 +104,75 @@ fun InbuiltMediaPicker(
pendingMedia: List<String>, pendingMedia: List<String>,
disabled: Boolean = false, disabled: Boolean = false,
) { ) {
val mediaPermissionState = rememberMultiplePermissionsState(
listOf(
android.Manifest.permission.READ_MEDIA_IMAGES,
android.Manifest.permission.READ_MEDIA_VIDEO
)
)
val context = LocalContext.current val context = LocalContext.current
var hasPhotoPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_MEDIA_IMAGES
) == PermissionChecker.PERMISSION_GRANTED
)
}
var hasVideoPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_MEDIA_VIDEO
) == PermissionChecker.PERMISSION_GRANTED
)
}
val hasMediaPermissions by remember {
derivedStateOf {
hasPhotoPermission && hasVideoPermission
}
}
var mediaPermissionIsPartial by remember { mutableStateOf<Boolean?>(null) }
val canShowGallery by remember {
derivedStateOf {
hasMediaPermissions || (mediaPermissionIsPartial == true)
}
}
val permissionRequester = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { mediaPermissionState ->
hasPhotoPermission = mediaPermissionState[Manifest.permission.READ_MEDIA_IMAGES] == true
hasVideoPermission = mediaPermissionState[Manifest.permission.READ_MEDIA_VIDEO] == true
mediaPermissionIsPartial = if (!hasMediaPermissions
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
&& mediaPermissionState[Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED] == true
) {
true
} else if (hasMediaPermissions) {
false
} else null
}
)
val images = remember { mutableStateListOf<Media>() } val images = remember { mutableStateListOf<Media>() }
BackHandler { BackHandler {
onClose() onClose()
} }
LaunchedEffect(mediaPermissionState.allPermissionsGranted) { LaunchedEffect(hasMediaPermissions) {
if (mediaPermissionState.allPermissionsGranted) { mediaPermissionIsPartial = if (!hasMediaPermissions
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
&& ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
) == PermissionChecker.PERMISSION_GRANTED
) {
true
} else if (hasMediaPermissions) {
false
} else null
}
LaunchedEffect(hasMediaPermissions, mediaPermissionIsPartial) {
if (hasMediaPermissions || mediaPermissionIsPartial == true) {
val projection = arrayOf( val projection = arrayOf(
MediaStore.Images.ImageColumns._ID, MediaStore.Images.ImageColumns._ID,
MediaStore.Images.ImageColumns.RESOLUTION, MediaStore.Images.ImageColumns.RESOLUTION,
@ -197,7 +255,7 @@ fun InbuiltMediaPicker(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Crossfade( Crossfade(
targetState = mediaPermissionState.allPermissionsGranted, targetState = canShowGallery,
animationSpec = tween( animationSpec = tween(
durationMillis = 300, durationMillis = 300,
easing = FastOutSlowInEasing easing = FastOutSlowInEasing
@ -241,7 +299,22 @@ fun InbuiltMediaPicker(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { Button(onClick = {
mediaPermissionState.launchMultiplePermissionRequest() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
permissionRequester.launch(
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
)
)
} else {
permissionRequester.launch(
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
)
)
}
}) { }) {
Text(text = stringResource(id = R.string.file_picker_permission_request_cta)) Text(text = stringResource(id = R.string.file_picker_permission_request_cta))
} }
@ -253,6 +326,74 @@ fun InbuiltMediaPicker(
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
AnimatedVisibility(
mediaPermissionIsPartial == true
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.ux_file_unpartialise_request),
modifier = Modifier
.size(52.dp),
contentDescription = null // decorative
)
Column(
modifier = Modifier
.weight(1f)
) {
Text(
text = stringResource(id = R.string.file_picker_permission_unpartialise_request_header),
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.file_picker_permission_unpartialise_request_body),
style = MaterialTheme.typography.bodyMedium.copy(
color = LocalContentColor.current.copy(
alpha = 0.5f
)
)
)
}
}
Button(onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
permissionRequester.launch(
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
)
)
} else {
permissionRequester.launch(
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
)
)
}
}) {
Text(text = stringResource(id = R.string.file_picker_permission_unpartialise_request_cta))
}
}
}
Row( Row(
modifier = Modifier modifier = Modifier
.horizontalScroll(rememberScrollState()), .horizontalScroll(rememberScrollState()),

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32.604dp"
android:height="25.573dp"
android:viewportWidth="32.604"
android:viewportHeight="25.573">
<path
android:fillColor="@color/foreground"
android:pathData="m7.88,7.088 l-6.274,1.681c-1.161,0.311 -1.842,1.491 -1.531,2.652l3.362,12.547a2.165,2.165 0,0 0,2.652 1.531l16.73,-4.483a2.165,2.165 0,0 0,1.531 -2.652l-2.802,-10.456c-0.311,-1.161 -1.501,-1.839 -2.652,-1.531l-8.365,2.241z"
android:strokeWidth="1.08249"/>
<path
android:pathData="m30.724,0 l1.88,0.186 -1.027,10.339 -1.88,-0.186zM31.297,13.346 L31.11,15.226 29.23,15.04 29.417,13.16z"
android:strokeWidth="0.944576"
android:fillColor="#db4e5b"/>
</vector>

View File

@ -340,6 +340,9 @@
<string name="file_picker_permission_request_header">We need your permission to access photos and videos</string> <string name="file_picker_permission_request_header">We need your permission to access photos and videos</string>
<string name="file_picker_permission_request_body">You will be able to attach photos and videos to your messages afterwards.</string> <string name="file_picker_permission_request_body">You will be able to attach photos and videos to your messages afterwards.</string>
<string name="file_picker_permission_request_cta">Allow access</string> <string name="file_picker_permission_request_cta">Allow access</string>
<string name="file_picker_permission_unpartialise_request_header">You have granted partial access to photos and videos</string>
<string name="file_picker_permission_unpartialise_request_body">To attach all your photos and videos, we need your permission</string>
<string name="file_picker_permission_unpartialise_request_cta">Allow full access</string>
<string name="file_picker_chip_documents">Attach a file</string> <string name="file_picker_chip_documents">Attach a file</string>
<string name="file_picker_chip_camera">Take a photo</string> <string name="file_picker_chip_camera">Take a photo</string>