feat: make revolt a share target (rough around edges)

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-12-26 00:31:32 +01:00
parent 35dc87ed7e
commit 1bf9610d7d
6 changed files with 519 additions and 28 deletions

View File

@ -86,6 +86,22 @@
</intent-filter>
</activity>
<activity
android:name=".activities.ShareTargetActivity"
android:theme="@style/Theme.Revolt"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<activity
android:name=".activities.media.ImageViewActivity"
android:theme="@style/Theme.Revolt" />

View File

@ -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<Uri?> = 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<Uri>? = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
@Suppress("UNCHECKED_CAST")
intent.getParcelableArrayListExtra(
Intent.EXTRA_STREAM,
Parcelable::class.java
) as? ArrayList<Uri>
}
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<FileArgs>()
var attachmentsUploading by mutableStateOf(false)
var attachmentProgress by mutableFloatStateOf(0f)
var activeBottomPane by mutableStateOf<BottomPane>(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<String>()
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<Uri>?,
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<String?>(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"
}
}
}
}
}
}
}
}
}

View File

@ -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<CategorisedChannelList> {

View File

@ -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 = {},

View File

@ -36,7 +36,8 @@ fun AttachmentManager(
attachments: List<FileArgs>,
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))
}

View File

@ -529,4 +529,11 @@
<string name="settings_changelogs_new_header">What\'s been cooking ✨</string>
<string name="settings_changelogs_historical_version_header">Changelog for %1$s</string>
<string name="settings_changelogs_historical_version_header_placeholder">that version</string>
<string name="share_target_heading">Share to Revolt</string>
<string name="share_target_login_first">Please log in before sharing to Revolt.</string>
<string name="share_target_invalid_intent">This is not a valid share intent.</string>
<string name="share_target_attachment_too_large">This attachment is too large for Revolt (max. $1$s).</string>
<string name="share_target_search_channels">Search channels</string>
<string name="share_target_select_channel">Please select a channel to share to.</string>
</resources>