feat: replace built-in media picker with google picker
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
156ab8b16a
commit
02ab989c67
|
|
@ -1,4 +1,3 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DesignSurface">
|
<component name="DesignSurface">
|
||||||
<option name="filePathToZoomLevelMap">
|
<option name="filePathToZoomLevelMap">
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,22 @@
|
||||||
<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+ -->
|
<!-- Up to Android 10, we need the following to take photos from the camera. -->
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
<!-- Android 14+ -->
|
android:maxSdkVersion="29"
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
* FIXME LiveKit is temporarily not included, hence the following is commented out.
|
* FIXME LiveKit is temporarily not included, hence the following is commented out.
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
-->
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
-->
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|
@ -118,6 +118,20 @@
|
||||||
android:configChanges="orientation|screenSize"
|
android:configChanges="orientation|screenSize"
|
||||||
android:theme="@style/Theme.Revolt" />
|
android:theme="@style/Theme.Revolt" />
|
||||||
|
|
||||||
|
<!-- Backport photo picker via Google Play Services -->
|
||||||
|
<service
|
||||||
|
android:name="com.google.android.gms.metadata.ModuleDependencies"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false"
|
||||||
|
tools:ignore="MissingClass">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="photopicker_activity:0:required"
|
||||||
|
android:value="" />
|
||||||
|
</service>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -1,538 +0,0 @@
|
||||||
package chat.revolt.components.media
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.AssistChip
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.surfaceColorAtElevation
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.PermissionChecker
|
|
||||||
import chat.revolt.R
|
|
||||||
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
|
|
||||||
import com.bumptech.glide.integration.compose.GlideImage
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
data class Media(
|
|
||||||
val uri: Uri,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
val duration: Long?,
|
|
||||||
val aspectRatio: Float = width.toFloat() / height.toFloat()
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun Long.formatAsLengthDuration(): String {
|
|
||||||
val asDuration = this.milliseconds
|
|
||||||
|
|
||||||
val components = asDuration.toComponents { days, hours, minutes, seconds, _ ->
|
|
||||||
listOfNotNull(
|
|
||||||
if (days > 0) "$days:" else null,
|
|
||||||
if (hours > 0) "$hours".padStart(2, '0') + ":" else null,
|
|
||||||
"$minutes".padStart(2, '0') + ":",
|
|
||||||
"$seconds".padStart(2, '0')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return components.joinToString(separator = "")
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalGlideComposeApi::class)
|
|
||||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
|
||||||
@Composable
|
|
||||||
fun InbuiltMediaPicker(
|
|
||||||
onOpenDocumentsUi: () -> Unit,
|
|
||||||
onOpenCamera: () -> Unit,
|
|
||||||
onClose: () -> Unit,
|
|
||||||
onMediaSelected: (Media) -> Unit,
|
|
||||||
pendingMedia: List<String>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
disabled: Boolean = false
|
|
||||||
) {
|
|
||||||
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>() }
|
|
||||||
|
|
||||||
BackHandler {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hasMediaPermissions) {
|
|
||||||
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(
|
|
||||||
MediaStore.Images.ImageColumns._ID,
|
|
||||||
MediaStore.Images.ImageColumns.RESOLUTION,
|
|
||||||
MediaStore.Images.ImageColumns.ORIENTATION,
|
|
||||||
MediaStore.Images.ImageColumns.MIME_TYPE,
|
|
||||||
MediaStore.Video.VideoColumns.DURATION
|
|
||||||
)
|
|
||||||
|
|
||||||
val selection: String? = null
|
|
||||||
val selectionArgs: Array<String>? = null
|
|
||||||
val sortOrder = MediaStore.Images.ImageColumns.DATE_ADDED + " DESC"
|
|
||||||
|
|
||||||
val queryUri = MediaStore.Files.getContentUri("external")
|
|
||||||
|
|
||||||
val cursor: Cursor? = context.contentResolver.query(
|
|
||||||
queryUri,
|
|
||||||
projection,
|
|
||||||
selection,
|
|
||||||
selectionArgs,
|
|
||||||
sortOrder
|
|
||||||
)
|
|
||||||
|
|
||||||
if (cursor != null) {
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
try {
|
|
||||||
val id =
|
|
||||||
cursor.getLong(
|
|
||||||
cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)
|
|
||||||
)
|
|
||||||
val resolution =
|
|
||||||
cursor.getString(
|
|
||||||
cursor.getColumnIndexOrThrow(
|
|
||||||
MediaStore.Images.ImageColumns.RESOLUTION
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val orientation =
|
|
||||||
cursor.getInt(
|
|
||||||
cursor.getColumnIndexOrThrow(
|
|
||||||
MediaStore.Images.ImageColumns.ORIENTATION
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val swapDimensions = orientation == 90 || orientation == 270
|
|
||||||
|
|
||||||
val isVideo =
|
|
||||||
cursor.getString(
|
|
||||||
cursor.getColumnIndexOrThrow(
|
|
||||||
MediaStore.Images.ImageColumns.MIME_TYPE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.startsWith("video")
|
|
||||||
|
|
||||||
val durationColumn =
|
|
||||||
cursor.getColumnIndex(MediaStore.Video.VideoColumns.DURATION)
|
|
||||||
val videoDuration = if (isVideo && durationColumn != -1) {
|
|
||||||
cursor.getLong(durationColumn)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val contentUri =
|
|
||||||
ContentUris.withAppendedId(
|
|
||||||
MediaStore.Files.getContentUri("external"),
|
|
||||||
id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (resolution == null) continue
|
|
||||||
|
|
||||||
val (width, height) = resolution.split("×").map { it.toInt() }
|
|
||||||
|
|
||||||
images.add(
|
|
||||||
Media(
|
|
||||||
uri = contentUri,
|
|
||||||
width = if (swapDimensions) height else width,
|
|
||||||
height = if (swapDimensions) width else height,
|
|
||||||
duration = videoDuration
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Crossfade(
|
|
||||||
targetState = canShowGallery,
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 300,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
),
|
|
||||||
label = "Media picker permission dialog"
|
|
||||||
) { state ->
|
|
||||||
if (!state) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.ux_file_request),
|
|
||||||
modifier = Modifier
|
|
||||||
.width(128.dp)
|
|
||||||
.height(128.dp),
|
|
||||||
contentDescription = null // decorative
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.file_picker_permission_request_header),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.file_picker_permission_request_body),
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = LocalContentColor.current.copy(
|
|
||||||
alpha = 0.5f
|
|
||||||
)
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
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_request_cta)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.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(
|
|
||||||
modifier = Modifier
|
|
||||||
.horizontalScroll(rememberScrollState()),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
AssistChip(
|
|
||||||
onClick = {
|
|
||||||
onOpenDocumentsUi()
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.file_picker_chip_documents)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_paperclip_24dp),
|
|
||||||
contentDescription = null, // see label
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.padding(2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
AssistChip(
|
|
||||||
onClick = {
|
|
||||||
onOpenCamera()
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(text = stringResource(id = R.string.file_picker_chip_camera))
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_camera_24dp),
|
|
||||||
contentDescription = null, // see label
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.padding(2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
LazyVerticalStaggeredGrid(
|
|
||||||
columns = StaggeredGridCells.Adaptive(100.dp),
|
|
||||||
verticalItemSpacing = 8.dp,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
content = {
|
|
||||||
items(images.size) { image ->
|
|
||||||
val imageIsSelected by derivedStateOf { images[image].uri.lastPathSegment in pendingMedia }
|
|
||||||
|
|
||||||
val borderSize by animateDpAsState(
|
|
||||||
targetValue = if (imageIsSelected) 2.dp else 0.dp,
|
|
||||||
animationSpec = tween(),
|
|
||||||
label = "Media picker image border size #$image"
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.border(
|
|
||||||
width = borderSize,
|
|
||||||
color = if (borderSize > 0.dp) MaterialTheme.colorScheme.primary else Color.Transparent,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (disabled) {
|
|
||||||
Modifier.alpha(0.5f)
|
|
||||||
} else {
|
|
||||||
Modifier.clickable {
|
|
||||||
onMediaSelected(images[image])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.width(100.dp)
|
|
||||||
.aspectRatio(images[image].aspectRatio)
|
|
||||||
) {
|
|
||||||
GlideImage(
|
|
||||||
model = images[image].uri.toString(),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.fillMaxSize()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (images[image].duration != null) {
|
|
||||||
Text(
|
|
||||||
text = "▶ ${images[image].duration!!.formatAsLengthDuration()}",
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(4.dp)
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
MaterialTheme.shapes.small
|
|
||||||
)
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package chat.revolt.components.media
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.revolt.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaPickerGateway(
|
||||||
|
onOpenPhotoPicker: () -> Unit,
|
||||||
|
onOpenDocumentPicker: () -> Unit,
|
||||||
|
onOpenCamera: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// This is a column with one item. For future expansion
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.weight(.60f)
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSecondaryContainer) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(onClick = onOpenPhotoPicker)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_image_multiple_24dp),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.file_picker_chip_photo_picker),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.weight(.40f)
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(.33f)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(onClick = onOpenDocumentPicker)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
brush = SolidColor(LocalContentColor.current),
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_paperclip_24dp),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.file_picker_chip_documents),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onTertiaryContainer) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(.66f)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(onClick = onOpenCamera)
|
||||||
|
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_camera_24dp),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.file_picker_chip_camera),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package chat.revolt.screens.chat.views.channel
|
||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
|
|
@ -10,6 +9,7 @@ import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
|
@ -115,7 +115,7 @@ import chat.revolt.components.generic.PresenceBadge
|
||||||
import chat.revolt.components.generic.UserAvatar
|
import chat.revolt.components.generic.UserAvatar
|
||||||
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
|
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
|
||||||
import chat.revolt.components.generic.presenceFromStatus
|
import chat.revolt.components.generic.presenceFromStatus
|
||||||
import chat.revolt.components.media.InbuiltMediaPicker
|
import chat.revolt.components.media.MediaPickerGateway
|
||||||
import chat.revolt.components.screens.chat.AttachmentManager
|
import chat.revolt.components.screens.chat.AttachmentManager
|
||||||
import chat.revolt.components.screens.chat.ChannelIcon
|
import chat.revolt.components.screens.chat.ChannelIcon
|
||||||
import chat.revolt.components.screens.chat.ReplyManager
|
import chat.revolt.components.screens.chat.ReplyManager
|
||||||
|
|
@ -129,7 +129,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
sealed class ChannelScreenItem {
|
sealed class ChannelScreenItem {
|
||||||
|
|
@ -255,6 +254,16 @@ fun ChannelScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pickMediaLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.PickMultipleVisualMedia()
|
||||||
|
) {
|
||||||
|
it.let { list ->
|
||||||
|
list.forEach { uri ->
|
||||||
|
processFileUri(uri, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val capturedPhotoUri = rememberSaveable { mutableStateOf<Uri?>(null) }
|
val capturedPhotoUri = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
val pickCameraLauncher = rememberLauncherForActivityResult(
|
val pickCameraLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.TakePicture()
|
contract = ActivityResultContracts.TakePicture()
|
||||||
|
|
@ -927,85 +936,76 @@ fun ChannelScreen(
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
viewModel.activePane = ChannelScreenActivePane.None
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
MediaPickerGateway(
|
||||||
InbuiltMediaPicker(
|
onOpenPhotoPicker = {
|
||||||
onOpenDocumentsUi = {
|
pickMediaLauncher.launch(
|
||||||
pickFileLauncher.launch(arrayOf("*/*"))
|
PickVisualMediaRequest(
|
||||||
viewModel.activePane =
|
mediaType = ActivityResultContracts.PickVisualMedia.ImageAndVideo
|
||||||
ChannelScreenActivePane.None
|
)
|
||||||
},
|
)
|
||||||
onOpenCamera = {
|
viewModel.activePane =
|
||||||
// Create a new content URI to store the captured image.
|
ChannelScreenActivePane.None
|
||||||
val contentResolver =
|
},
|
||||||
context.contentResolver
|
onOpenDocumentPicker = {
|
||||||
val contentValues = ContentValues().apply {
|
pickFileLauncher.launch(arrayOf("*/*"))
|
||||||
put(
|
viewModel.activePane =
|
||||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
ChannelScreenActivePane.None
|
||||||
"RVL_${System.currentTimeMillis()}.jpg"
|
},
|
||||||
)
|
onOpenCamera = {
|
||||||
put(
|
// Create a new content URI to store the captured image.
|
||||||
MediaStore.MediaColumns.MIME_TYPE,
|
val contentResolver =
|
||||||
"image/jpeg"
|
context.contentResolver
|
||||||
)
|
val contentValues = ContentValues().apply {
|
||||||
put(
|
put(
|
||||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||||
Environment.DIRECTORY_PICTURES
|
"RVL_${System.currentTimeMillis()}.jpg"
|
||||||
)
|
)
|
||||||
}
|
put(
|
||||||
|
MediaStore.MediaColumns.MIME_TYPE,
|
||||||
|
"image/jpeg"
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||||
|
Environment.DIRECTORY_PICTURES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
capturedPhotoUri.value =
|
capturedPhotoUri.value =
|
||||||
contentResolver.insert(
|
contentResolver.insert(
|
||||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
contentValues
|
contentValues
|
||||||
)
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(
|
||||||
|
R.string.file_picker_chip_camera_failed
|
||||||
|
),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
|
||||||
try {
|
return@MediaPickerGateway
|
||||||
capturedPhotoUri.value?.let { uri ->
|
}
|
||||||
pickCameraLauncher.launch(uri)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(
|
|
||||||
R.string.file_picker_chip_camera_none_installed
|
|
||||||
),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.activePane =
|
try {
|
||||||
ChannelScreenActivePane.None
|
capturedPhotoUri.value?.let { uri ->
|
||||||
},
|
pickCameraLauncher.launch(uri)
|
||||||
onClose = {
|
|
||||||
viewModel.activePane =
|
|
||||||
ChannelScreenActivePane.None
|
|
||||||
},
|
|
||||||
onMediaSelected = { media ->
|
|
||||||
try {
|
|
||||||
processFileUri(
|
|
||||||
media.uri,
|
|
||||||
media.uri.lastPathSegment
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e is FileNotFoundException) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(
|
|
||||||
R.string.file_picker_cannot_attach_file_invalid
|
|
||||||
),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
} catch (e: Exception) {
|
||||||
pendingMedia = viewModel.draftAttachments
|
Toast.makeText(
|
||||||
.filterNot { it.pickerIdentifier == null }
|
context,
|
||||||
.map { it.pickerIdentifier!! },
|
context.getString(
|
||||||
modifier = Modifier
|
R.string.file_picker_chip_camera_none_installed
|
||||||
.imePadding()
|
),
|
||||||
.navigationBarsPadding()
|
Toast.LENGTH_SHORT
|
||||||
)
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModel.activePane =
|
||||||
|
ChannelScreenActivePane.None
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|
|
||||||
|
|
@ -458,9 +458,11 @@
|
||||||
<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_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_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_permission_unpartialise_request_cta">Allow full access</string>
|
||||||
<string name="file_picker_chip_documents">Attach a file</string>
|
<string name="file_picker_chip_photo_picker">Select Photos</string>
|
||||||
<string name="file_picker_chip_camera">Take a photo</string>
|
<string name="file_picker_chip_documents">Files</string>
|
||||||
|
<string name="file_picker_chip_camera">Camera</string>
|
||||||
<string name="file_picker_chip_camera_none_installed">No camera app installed</string>
|
<string name="file_picker_chip_camera_none_installed">No camera app installed</string>
|
||||||
|
<string name="file_picker_chip_camera_failed">Failed to open camera</string>
|
||||||
|
|
||||||
<string name="inline_media_picker_current_description">Currently selected media</string>
|
<string name="inline_media_picker_current_description">Currently selected media</string>
|
||||||
<string name="inline_media_picker_no_media_placeholder">Pick media…</string>
|
<string name="inline_media_picker_no_media_placeholder">Pick media…</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue