feat: inbuilt media picker

Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
Infi 2023-07-11 00:34:01 +02:00
parent de37d14139
commit df57124ab4
10 changed files with 457 additions and 27 deletions

View File

@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<application
android:allowBackup="true"

View File

@ -5,11 +5,15 @@ import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.AutumnError
import chat.revolt.api.schemas.AutumnId
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.client.plugins.onUpload
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import java.io.File
const val MAX_ATTACHMENTS_PER_MESSAGE = 5
@ -18,6 +22,7 @@ data class FileArgs(
val file: File,
val filename: String,
val contentType: String,
val pickerIdentifier: String? = null
)
suspend fun uploadToAutumn(

View File

@ -0,0 +1,22 @@
package chat.revolt.api.settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
annotation class FeatureFlag(val name: String)
annotation class Treatment(val description: String)
@FeatureFlag("TiramisuFilePicker")
enum class FilePickerFeatureFlagVariates {
@Treatment("Use the READ_MEDIA_IMAGES or READ_MEDIA_VIDEO permissions introduced in Android Tiramisu")
TiramisuMediaPermissions,
@Treatment("Use the DocumentsUI picker introduced in Android KitKat")
DocumentsUI
}
object FeatureFlags {
@FeatureFlag("TiramisuFilePicker")
var filePickerType by mutableStateOf(FilePickerFeatureFlagVariates.TiramisuMediaPermissions)
}

View File

@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
@ -61,6 +62,7 @@ fun MessageField(
disabled: Boolean = false,
editMode: Boolean = false,
cancelEdit: () -> Unit = {},
onFocusChange: (Boolean) -> Unit = {},
) {
val focusRequester = remember { FocusRequester() }
val placeholderResource = when (channelType) {
@ -74,7 +76,7 @@ fun MessageField(
val sendButtonVisible = (messageContent.isNotBlank() || forceSendButton) && !disabled
Row(
modifier = modifier
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
) {
@ -85,9 +87,12 @@ fun MessageField(
enabled = !disabled,
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier
modifier = modifier
.weight(1f)
.focusRequester(focusRequester),
.focusRequester(focusRequester)
.onFocusChanged { state ->
onFocusChange(state.isFocused)
},
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions.Default,
decorationBox = @Composable { innerTextField ->

View File

@ -0,0 +1,269 @@
package chat.revolt.components.media
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.annotation.RequiresApi
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FastOutSlowInEasing
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.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.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.mutableStateListOf
import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.revolt.R
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
data class Media(
val uri: Uri,
val width: Int,
val height: Int,
val aspectRatio: Float = width.toFloat() / height.toFloat()
)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalGlideComposeApi::class)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun InbuiltMediaPicker(
onOpenDocumentsUi: () -> Unit,
onClose: () -> Unit,
onMediaSelected: (Media) -> Unit,
pendingMedia: List<String>,
disabled: Boolean = false,
) {
val mediaPermissionState = rememberMultiplePermissionsState(
listOf(
android.Manifest.permission.READ_MEDIA_IMAGES,
android.Manifest.permission.READ_MEDIA_VIDEO
)
)
val context = LocalContext.current
val images = remember { mutableStateListOf<Media>() }
BackHandler {
onClose()
}
LaunchedEffect(mediaPermissionState.allPermissionsGranted) {
if (mediaPermissionState.allPermissionsGranted) {
val projection = arrayOf(
MediaStore.Images.ImageColumns._ID,
MediaStore.Images.ImageColumns.RESOLUTION
)
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 contentUri =
ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
if (resolution == null) continue
images.add(
Media(
uri = contentUri,
width = resolution.split("×")[0].toInt(),
height = resolution.split("×")[1].toInt()
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
cursor.close()
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Crossfade(
targetState = mediaPermissionState.allPermissionsGranted,
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 = {
mediaPermissionState.launchMultiplePermissionRequest()
}) {
Text(text = stringResource(id = R.string.file_picker_permission_request_cta))
}
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(horizontal = 16.dp),
) {
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState()),
) {
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
)
}
)
}
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 ->
GlideImage(
model = images[image].uri.toString(),
contentDescription = null,
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.then(
if (images[image].uri.lastPathSegment in pendingMedia) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = MaterialTheme.shapes.medium
)
} else Modifier
)
.then(
if (disabled) {
Modifier.alpha(0.5f)
} else {
Modifier.clickable {
onMediaSelected(images[image])
}
}
)
.width(100.dp)
.aspectRatio(images[image].aspectRatio),
)
}
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}

View File

@ -1,5 +1,7 @@
package chat.revolt.screens.chat.views.channel
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -45,6 +47,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -58,9 +61,13 @@ import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.settings.FeatureFlag
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.api.settings.FilePickerFeatureFlagVariates
import chat.revolt.components.chat.Message
import chat.revolt.components.chat.MessageField
import chat.revolt.components.chat.SystemMessage
import chat.revolt.components.media.InbuiltMediaPicker
import chat.revolt.components.screens.chat.AttachmentManager
import chat.revolt.components.screens.chat.ChannelHeader
import chat.revolt.components.screens.chat.ReplyManager
@ -81,6 +88,7 @@ import com.discord.simpleast.core.simple.SimpleRenderer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileNotFoundException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -104,27 +112,43 @@ fun ChannelScreen(
var messageContextSheetShown by remember { mutableStateOf(false) }
var messageContextSheetTarget by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
fun processFileUri(uri: Uri, pickerIdentifier: String? = null) {
DocumentFile.fromSingleUri(context, uri)?.let { file ->
val mFile = File(context.cacheDir, file.name ?: "attachment")
mFile.outputStream().use { output ->
@Suppress("Recycle")
context.contentResolver.openInputStream(uri)?.copyTo(output)
}
// If the file is already pending and was picked from the inbuilt picker, remove it.
// This is so you can "toggle" the file in the picker.
// If the file was picked via DocumentsUI we don't want toggling functionality as
// if you specifically opened it from DocumentsUI you probably want to send it anyway.
if (viewModel.pendingAttachments.any { it.pickerIdentifier == pickerIdentifier }) {
viewModel.pendingAttachments.removeIf { it.pickerIdentifier == pickerIdentifier }
return
}
viewModel.pendingAttachments.add(
FileArgs(
file = mFile,
contentType = file.type ?: "application/octet-stream",
filename = file.name ?: "attachment",
pickerIdentifier = pickerIdentifier
)
)
}
}
val pickFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uriList ->
uriList.let { uris ->
uris.forEach {
DocumentFile.fromSingleUri(context, it)?.let { file ->
val mFile = File(context.cacheDir, file.name ?: "attachment")
mFile.outputStream().use { output ->
@Suppress("Recycle")
context.contentResolver.openInputStream(it)?.copyTo(output)
}
viewModel.pendingAttachments.add(
FileArgs(
file = mFile,
contentType = file.type ?: "application/octet-stream",
filename = file.name ?: "attachment"
)
)
}
uriList.let { list ->
list.forEach { uri ->
processFileUri(uri)
}
}
}
@ -409,7 +433,21 @@ fun ChannelScreen(
},
onSendMessage = viewModel::sendPendingMessage,
onAddAttachment = {
pickFileLauncher.launch(arrayOf("*/*"))
val isTiramisu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@FeatureFlag("TiramisuFilePicker")
when {
FeatureFlags.filePickerType == FilePickerFeatureFlagVariates.TiramisuMediaPermissions
&& isTiramisu -> {
focusManager.clearFocus()
viewModel.inbuiltFilePickerOpen = !viewModel.inbuiltFilePickerOpen
}
FeatureFlags.filePickerType == FilePickerFeatureFlagVariates.DocumentsUI
|| !isTiramisu -> {
pickFileLauncher.launch(arrayOf("*/*"))
}
}
},
channelType = channel.channelType,
channelName = channel.name ?: ChannelUtils.resolveDMName(channel) ?: stringResource(
@ -419,7 +457,46 @@ fun ChannelScreen(
disabled = viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage,
editMode = viewModel.editingMessage != null,
cancelEdit = viewModel::cancelEditingMessage,
onFocusChange = { nowFocused ->
if (nowFocused && viewModel.inbuiltFilePickerOpen) {
viewModel.inbuiltFilePickerOpen = false
}
},
)
AnimatedVisibility(visible = viewModel.inbuiltFilePickerOpen) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
InbuiltMediaPicker(
onOpenDocumentsUi = {
pickFileLauncher.launch(arrayOf("*/*"))
viewModel.inbuiltFilePickerOpen = false
},
onClose = {
viewModel.inbuiltFilePickerOpen = false
},
onMediaSelected = { media ->
try {
processFileUri(
media.uri,
pickerIdentifier = 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()
}
}
},
pendingMedia = viewModel.pendingAttachments
.filterNot { it.pickerIdentifier == null }
.map { it.pickerIdentifier!! },
disabled = viewModel.isSendingMessage,
)
}
}
}
}
}

View File

@ -31,6 +31,7 @@ import chat.revolt.api.routes.server.fetchMember
import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message
import chat.revolt.api.settings.FeatureFlag
import chat.revolt.callbacks.UiCallback
import chat.revolt.callbacks.UiCallbacks
import io.ktor.http.ContentType
@ -58,6 +59,9 @@ class ChannelScreenViewModel : ViewModel() {
var pendingReplies = mutableStateListOf<SendMessageReply>()
var pendingAttachments = mutableStateListOf<FileArgs>()
@FeatureFlag("TiramisuFilePicker")
var inbuiltFilePickerOpen by mutableStateOf(false)
var pendingUploadProgress by mutableFloatStateOf(0f)
var editingMessage by mutableStateOf<String?>(null)

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M16.5,6V17.5A4,4 0 0,1 12.5,21.5A4,4 0 0,1 8.5,17.5V5A2.5,2.5 0 0,1 11,2.5A2.5,2.5 0 0,1 13.5,5V15.5A1,1 0 0,1 12.5,16.5A1,1 0 0,1 11.5,15.5V6H10V15.5A2.5,2.5 0 0,0 12.5,18A2.5,2.5 0 0,0 15,15.5V5A4,4 0 0,0 11,1A4,4 0 0,0 7,5V17.5A5.5,5.5 0 0,0 12.5,23A5.5,5.5 0 0,0 18,17.5V6H16.5Z" />
</vector>

View File

@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="95.914dp"
android:height="80.251dp"
android:viewportWidth="95.914"
android:viewportHeight="80.251">
<path
android:pathData="M4.557,40.125a38.721,40.125 0,1 0,77.443 0a38.721,40.125 0,1 0,-77.443 0z"
android:strokeWidth="7.76499"
android:fillColor="@color/material_dynamic_primary100"
android:fillAlpha="0.196078"/>
<path
android:pathData="M13.333,40.125a29.945,31.031 0,1 0,59.891 0a29.945,31.031 0,1 0,-59.891 0z"
android:strokeWidth="6.00512"
android:fillColor="@color/material_dynamic_primary100"
android:fillAlpha="0.196078"/>
<path
android:pathData="M24.1,40.125a19.179,19.874 0,1 0,38.358 0a19.179,19.874 0,1 0,-38.358 0z"
android:strokeWidth="3.84605"
android:fillColor="@color/material_dynamic_primary100"
android:fillAlpha="0.196078"/>
<path
android:pathData="m70.658,10.102 l-13.704,-3.441c-2.535,-0.637 -5.078,0.886 -5.715,3.421L44.356,37.489a4.71,4.71 0,0 0,3.421 5.715l36.543,9.177a4.71,4.71 0,0 0,5.715 -3.421l5.736,-22.84c0.637,-2.535 -0.908,-5.084 -3.421,-5.715l-18.272,-4.589z"
android:strokeWidth="2.35489"
android:fillColor="#db4e5b"/>
<path
android:pathData="m11.386,55.62 l4.774,3.257 3.159,-7.823 8.836,6.717 -19.927,5.672m23.179,-5.059 l-5.672,-19.927c-0.45,-1.58 -2.091,-2.482 -3.657,-2.036l-19.927,5.672a2.96,2.96 0,0 0,-2.036 3.657l5.672,19.927a2.96,2.96 0,0 0,3.657 2.036l19.927,-5.672a2.96,2.96 0,0 0,2.036 -3.657z"
android:strokeWidth="1.47992"
android:fillColor="@color/foreground"/>
</vector>

View File

@ -285,6 +285,13 @@
<string name="media_viewer_share_video">Share video</string>
<string name="media_viewer_share_image">Share image</string>
<string name="file_picker_cannot_attach_file_invalid">This file is invalid and cannot be attached.</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_cta">Allow access</string>
<string name="file_picker_chip_documents">Attach a file</string>
<string name="file_picker_chip_camera">Take a photo</string>
<string name="spark_sidebar_settings_tutorial">The settings are in the sidebar</string>
<string name="spark_sidebar_settings_tutorial_description_1">You can open the sidebar by swiping from the left edge of the screen.</string>
<string name="spark_sidebar_settings_tutorial_description_2">Then long tap your profile picture to open the settings.</string>