From df57124ab47a76b1ebf8a313c85ed1c6af670bde Mon Sep 17 00:00:00 2001 From: Infi Date: Tue, 11 Jul 2023 00:34:01 +0200 Subject: [PATCH] feat: inbuilt media picker Signed-off-by: Infi --- app/src/main/AndroidManifest.xml | 3 + .../api/routes/microservices/autumn/Autumn.kt | 15 +- .../chat/revolt/api/settings/FeatureFlags.kt | 22 ++ .../revolt/components/chat/MessageField.kt | 11 +- .../components/media/InbuiltMediaPicker.kt | 269 ++++++++++++++++++ .../chat/views/channel/ChannelScreen.kt | 115 ++++++-- .../views/channel/ChannelScreenViewModel.kt | 4 + .../main/res/drawable/ic_paperclip_24dp.xml | 9 + app/src/main/res/drawable/ux_file_request.xml | 29 ++ app/src/main/res/values/strings.xml | 7 + 10 files changed, 457 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/settings/FeatureFlags.kt create mode 100644 app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt create mode 100644 app/src/main/res/drawable/ic_paperclip_24dp.xml create mode 100644 app/src/main/res/drawable/ux_file_request.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ac8e3abc..d77d0193 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + 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 -> diff --git a/app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt b/app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt new file mode 100644 index 00000000..af13464b --- /dev/null +++ b/app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt @@ -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, + 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() } + + 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? = 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() + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index 8a8c424a..94a59b7f 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -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, + ) + } + } } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt index 292f2392..f7149a56 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -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() var pendingAttachments = mutableStateListOf() + @FeatureFlag("TiramisuFilePicker") + var inbuiltFilePickerOpen by mutableStateOf(false) + var pendingUploadProgress by mutableFloatStateOf(0f) var editingMessage by mutableStateOf(null) diff --git a/app/src/main/res/drawable/ic_paperclip_24dp.xml b/app/src/main/res/drawable/ic_paperclip_24dp.xml new file mode 100644 index 00000000..3a361b79 --- /dev/null +++ b/app/src/main/res/drawable/ic_paperclip_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ux_file_request.xml b/app/src/main/res/drawable/ux_file_request.xml new file mode 100644 index 00000000..845797a5 --- /dev/null +++ b/app/src/main/res/drawable/ux_file_request.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d15f47f..59e07fbf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,6 +285,13 @@ Share video Share image + This file is invalid and cannot be attached. + We need your permission to access photos and videos + You will be able to attach photos and videos to your messages afterwards. + Allow access + Attach a file + Take a photo + The settings are in the sidebar You can open the sidebar by swiping from the left edge of the screen. Then long tap your profile picture to open the settings.