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.