diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1e316fdc..7ba7aadf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,6 +86,22 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt new file mode 100644 index 00000000..797bda36 --- /dev/null +++ b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt @@ -0,0 +1,461 @@ +package chat.revolt.activities + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.documentfile.provider.DocumentFile +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import chat.revolt.R +import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.ChannelUtils +import chat.revolt.api.routes.channel.sendMessage +import chat.revolt.api.routes.microservices.autumn.FileArgs +import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE +import chat.revolt.api.routes.microservices.autumn.uploadToAutumn +import chat.revolt.api.schemas.ChannelType +import chat.revolt.api.settings.GlobalState +import chat.revolt.api.settings.SyncedSettings +import chat.revolt.components.chat.NativeMessageField +import chat.revolt.components.emoji.EmojiPicker +import chat.revolt.components.generic.presenceFromStatus +import chat.revolt.components.screens.chat.AttachmentManager +import chat.revolt.components.screens.chat.drawer.server.DrawerChannel +import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType +import chat.revolt.persistence.KVStorage +import chat.revolt.screens.chat.views.channel.BottomPane +import chat.revolt.ui.theme.RevoltTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import io.ktor.http.ContentType +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@AndroidEntryPoint +class ShareTargetActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val text: String? = intent.getStringExtra(Intent.EXTRA_TEXT) + val media: List = when (intent?.action) { + // We receive one of something. Could be text, could be media. + Intent.ACTION_SEND -> { + when { + // No media if we receive text/plain + intent.type == "text/plain" -> { + listOf() + } + + // Otherwise, we receive a single Uri + else -> { + listOf( + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + intent.getParcelableExtra( + Intent.EXTRA_STREAM, + Parcelable::class.java + ) as? Uri + } + + else -> { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + } + ) + } + } + } + + // We receive multiple URIs, definitely media + Intent.ACTION_SEND_MULTIPLE -> { + try { + val bundle: ArrayList? = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + @Suppress("UNCHECKED_CAST") + intent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM, + Parcelable::class.java + ) as? ArrayList + } + + else -> { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + } + + bundle ?: listOf() + } catch (e: Exception) { + Log.e("ShareTargetActivity", "Failed to get multiple URIs", e) + listOf() + } + } + + // We don't know what we're receiving + else -> { + Toast.makeText( + this, + getString(R.string.share_target_invalid_intent), + Toast.LENGTH_SHORT + ).show() + + finish() + return + } + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + ShareTargetScreen( + text = text, + media = media.filterNotNull(), + onFinished = { finish() } + ) + } + } +} + +@HiltViewModel +class ShareTargetScreenViewModel @Inject constructor( + private val kvStorage: KVStorage, +) : ViewModel() { + var apiIsReady by mutableStateOf(false) + + var messageContent by mutableStateOf("") + var attachments = mutableStateListOf() + var attachmentsUploading by mutableStateOf(false) + var attachmentProgress by mutableFloatStateOf(0f) + var activeBottomPane by mutableStateOf(BottomPane.None) + + suspend fun isLoggedIn(): Boolean { + return kvStorage.get("sessionToken") != null + } + + suspend fun initialiseAPI() { + if (!RevoltAPI.isLoggedIn()) { + val token = kvStorage.get("sessionToken") ?: return + RevoltAPI.loginAs(token) + RevoltAPI.initialize() + } + apiIsReady = true + } + + fun send(channelId: String, onFinished: () -> Unit) { + viewModelScope.launch { + val attachmentIds = arrayListOf() + val takenAttachments = attachments.take(MAX_ATTACHMENTS_PER_MESSAGE) + val totalTaken = takenAttachments.size + + takenAttachments.forEachIndexed { index, it -> + try { + val id = uploadToAutumn( + it.file, + it.filename, + "attachments", + ContentType.parse(it.contentType), + onProgress = { current, total -> + attachmentProgress = + ((current.toFloat() / total.toFloat()) / totalTaken.toFloat()) + (index.toFloat() / totalTaken.toFloat()) + } + ) + attachmentIds.add(id) + } catch (e: Exception) { + return@launch + } + } + + sendMessage( + channelId = channelId, + content = messageContent, + attachments = attachmentIds + ) + + onFinished() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareTargetScreen( + text: String?, + media: List?, + onFinished: () -> Unit = {}, + viewModel: ShareTargetScreenViewModel = hiltViewModel() +) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + if (!viewModel.isLoggedIn()) { + Toast.makeText( + context, + context.getString(R.string.share_target_login_first), + Toast.LENGTH_SHORT + ).show() + + onFinished() + return@LaunchedEffect + } + + viewModel.initialiseAPI() + } + + LaunchedEffect(Unit) { + media?.forEach { uri -> + 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) + } + + viewModel.attachments.add( + FileArgs( + file = mFile, + contentType = file.type ?: "application/octet-stream", + filename = file.name ?: "attachment", + pickerIdentifier = null + ) + ) + } + } + + text?.let { + viewModel.messageContent = it + } + } + + var channelSearchContent by remember { mutableStateOf("") } + var selectedChannel by rememberSaveable { mutableStateOf(null) } + + RevoltTheme( + requestedTheme = GlobalState.theme, + colourOverrides = SyncedSettings.android.colourOverrides + ) { + Scaffold( + topBar = { + TopAppBar(title = { + Text(text = stringResource(R.string.share)) + }) + } + ) { pv -> + Surface( + modifier = Modifier + .padding(pv) + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + ) { + if (!viewModel.apiIsReady) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier + .size(48.dp) + ) + } + return@Surface + } + + Column { + OutlinedTextField( + value = channelSearchContent, + onValueChange = { + channelSearchContent = it + }, + label = { + Text(text = stringResource(R.string.share_target_search_channels)) + }, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) + + Box( + modifier = Modifier.weight(1f) + ) { + val filteredChannels = RevoltAPI.channelCache.values.asSequence().filter { + it.name?.contains( + channelSearchContent, + ignoreCase = true + ) == true + || ChannelUtils.resolveDMName(it) + ?.contains( + channelSearchContent, + ignoreCase = true + ) == true + } + + LazyColumn { + items(filteredChannels.count()) { + val channel = filteredChannels.elementAt(it) + + DrawerChannel( + iconType = DrawerChannelIconType.Channel( + channel.channelType ?: ChannelType.TextChannel + ), + name = (if (channel.server != null) "${channel.name} (${RevoltAPI.serverCache[channel.server]?.name})" else channel.name) + ?: ChannelUtils.resolveDMName(channel) + ?: stringResource(R.string.unknown), + selected = selectedChannel == channel.id, + hasUnread = false, + onClick = { + selectedChannel = channel.id + }, + dmPartnerIcon = ChannelUtils.resolveDMPartner( + channel + )?.let { u -> RevoltAPI.userCache[u] }?.avatar, + dmPartnerName = ChannelUtils.resolveDMName( + channel + ), + dmPartnerStatus = ChannelUtils.resolveDMPartner( + channel + ) + ?.let { u -> RevoltAPI.userCache[u] }?.status?.presence?.let { p -> + presenceFromStatus( + p, + RevoltAPI.userCache[ChannelUtils.resolveDMPartner( + channel + )]?.online ?: false + ) + }, + dmPartnerId = ChannelUtils.resolveDMPartner( + channel + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + Column( + modifier = Modifier + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + top = 8.dp + ) + .clip(MaterialTheme.shapes.medium) + ) { + AnimatedVisibility(viewModel.attachments.isNotEmpty()) { + AttachmentManager( + attachments = viewModel.attachments, + uploading = viewModel.attachmentsUploading, + uploadProgress = viewModel.attachmentProgress, + onRemove = {}, + canRemove = false + ) + } + + NativeMessageField( + value = viewModel.messageContent, + onValueChange = { viewModel.messageContent = it }, + canAttach = false, + forceSendButton = viewModel.attachments.isNotEmpty(), + disabled = viewModel.attachmentsUploading, + onAddAttachment = {}, + onCommitAttachment = {}, + onPickEmoji = { + if (viewModel.activeBottomPane is BottomPane.EmojiPicker) { + viewModel.activeBottomPane = BottomPane.None + } else { + viewModel.activeBottomPane = BottomPane.EmojiPicker + } + }, + onSendMessage = { + if (selectedChannel == null) { + Toast.makeText( + context, + context.getString(R.string.share_target_select_channel), + Toast.LENGTH_SHORT + ).show() + return@NativeMessageField + } else { + viewModel.send(selectedChannel!!) { + onFinished() + } + } + }, + channelType = RevoltAPI.channelCache[selectedChannel]?.channelType + ?: ChannelType.TextChannel, + channelName = RevoltAPI.channelCache[selectedChannel]?.name ?: "", + ) + + AnimatedVisibility(viewModel.activeBottomPane is BottomPane.EmojiPicker) { + BackHandler(enabled = viewModel.activeBottomPane == BottomPane.EmojiPicker) { + viewModel.activeBottomPane = BottomPane.None + } + + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.5f) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) + .padding(4.dp) + ) { + EmojiPicker { + viewModel.messageContent += " $it" + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt b/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt index 3e5e1c60..c75ddd6f 100644 --- a/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt +++ b/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt @@ -21,7 +21,7 @@ object ChannelUtils { } fun resolveDMPartner(channel: Channel): String? { - return channel.recipients?.first { u -> u != RevoltAPI.selfId } + return channel.recipients?.firstOrNull { u -> u != RevoltAPI.selfId } } fun categoriseServerFlat(server: Server): List { diff --git a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt index cde64e43..89188657 100644 --- a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt +++ b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt @@ -156,6 +156,7 @@ fun NativeMessageField( channelName: String, modifier: Modifier = Modifier, forceSendButton: Boolean = false, + canAttach: Boolean = true, disabled: Boolean = false, serverId: String? = null, channelId: String? = null, @@ -341,29 +342,31 @@ fun NativeMessageField( ) { Spacer(modifier = Modifier.width(8.dp)) - Icon( - when { - editMode -> Icons.Default.Close - else -> Icons.Default.Add - }, - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - contentDescription = stringResource(id = R.string.add_attachment_alt), - modifier = Modifier - .clip(CircleShape) - .size(32.dp) - .clickable { - when { - editMode -> cancelEdit() - else -> { - // hide keyboard because it's annoying - clearFocus() - onAddAttachment() + if (canAttach) { + Icon( + when { + editMode -> Icons.Default.Close + else -> Icons.Default.Add + }, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + contentDescription = stringResource(id = R.string.add_attachment_alt), + modifier = Modifier + .clip(CircleShape) + .size(32.dp) + .clickable { + when { + editMode -> cancelEdit() + else -> { + // hide keyboard because it's annoying + clearFocus() + onAddAttachment() + } } } - } - .padding(4.dp) - .testTag("add_attachment") - ) + .padding(4.dp) + .testTag("add_attachment") + ) + } AndroidView( factory = { context -> @@ -599,6 +602,7 @@ fun NativeMessageFieldPreview() { channelName = "Test", modifier = Modifier, forceSendButton = false, + canAttach = true, disabled = false, editMode = false, cancelEdit = {}, diff --git a/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt b/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt index c79b220c..7a6ea72a 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt @@ -36,7 +36,8 @@ fun AttachmentManager( attachments: List, uploading: Boolean, uploadProgress: Float = 0f, - onRemove: (FileArgs) -> Unit + onRemove: (FileArgs) -> Unit, + canRemove: Boolean = true ) { val animatedProgress by animateFloatAsState( targetValue = uploadProgress, @@ -69,11 +70,13 @@ fun AttachmentManager( .padding(8.dp) ) { Text(attachment.filename, maxLines = 1) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.remove_attachment_alt) - ) + if (canRemove) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.remove_attachment_alt) + ) + } } Spacer(modifier = Modifier.width(8.dp)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b310b9bf..74693d27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -529,4 +529,11 @@ What\'s been cooking ✨ Changelog for %1$s that version + + Share to Revolt + Please log in before sharing to Revolt. + This is not a valid share intent. + This attachment is too large for Revolt (max. $1$s). + Search channels + Please select a channel to share to.