feat: image attachment viewer with sharing & download

This commit is contained in:
Infi 2023-04-18 02:29:25 +02:00
parent 1ee13f6215
commit 1599383482
12 changed files with 407 additions and 10 deletions

View File

@ -7,6 +7,7 @@ plugins {
id "io.sentry.android.gradle" version "3.4.2"
id 'kotlin-kapt'
id 'kotlin-parcelize'
}
def property(String fileName, String propertyName, String fallbackEnv = null) {
@ -166,6 +167,7 @@ dependencies {
// Libraries used for legacy View-based UI
implementation "androidx.constraintlayout:constraintlayout:2.2.0-alpha09"
implementation 'com.github.MikeOrtiz:TouchImageView:3.3'
// JDK Desugaring - polyfill for new Java APIs
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'

View File

@ -16,6 +16,16 @@
android:name=".RevoltApplication"
android:theme="@style/Theme.Revolt"
tools:targetApi="31">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<meta-data
android:name="io.sentry.auto-init"
android:value="false" />
@ -66,6 +76,10 @@
</intent-filter>
</activity>
<activity
android:name=".activities.media.ImageViewActivity"
android:theme="@style/Theme.Revolt" />
</application>
</manifest>

View File

@ -41,8 +41,6 @@ class MainActivity : ComponentActivity() {
SentryAndroid.init(this) { options ->
options.dsn = BuildConfig.SENTRY_DSN
options.isDebug = BuildConfig.DEBUG
options.environment = BuildConfig.BUILD_TYPE
options.release = BuildConfig.VERSION_NAME
}

View File

@ -0,0 +1,300 @@
package chat.revolt.activities.media
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltHttp
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.settings.GlobalState
import chat.revolt.components.generic.PageHeader
import chat.revolt.ui.theme.RevoltTheme
import com.bumptech.glide.Glide
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import kotlinx.coroutines.launch
import java.io.File
class ImageViewActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val autumnResource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("autumnResource", AutumnResource::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("autumnResource")
}
if (autumnResource?.id == null) {
Log.e("ImageViewActivity", "No AutumnResource provided")
finish()
return
}
setContent {
ImageViewScreen(resource = autumnResource, onClose = { finish() })
}
}
}
class ImageViewScreenViewModel : ViewModel() {
suspend fun getAttachmentContentUri(
context: Context,
resourceUrl: String,
id: String,
filename: String
): Uri {
val attachmentsDir = File(context.cacheDir, "attachments")
if (!attachmentsDir.exists()) {
attachmentsDir.mkdir()
}
val response = RevoltHttp.get(resourceUrl)
val file = File(attachmentsDir, "$id-$filename")
file.writeBytes(response.readBytes())
return FileProvider.getUriForFile(
context,
"chat.revolt.fileprovider",
file
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImageViewScreen(
resource: AutumnResource,
onClose: () -> Unit = {},
viewModel: ImageViewScreenViewModel = viewModel()
) {
val resourceUrl = "$REVOLT_FILES/attachments/${resource.id}/${resource.filename}"
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val activityLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {}
val shareSubmenuIsOpen = remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
fun shareUrl() {
shareSubmenuIsOpen.value = false
val intent =
Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(
Intent.EXTRA_TEXT,
resourceUrl
)
val shareIntent = Intent.createChooser(intent, null)
activityLauncher.launch(shareIntent)
}
fun shareImage() {
shareSubmenuIsOpen.value = false
coroutineScope.launch {
val contentUri = viewModel.getAttachmentContentUri(
context,
resourceUrl,
resource.id!!,
resource.filename ?: "image"
)
Log.d("ImageViewActivity", "Content URI: $contentUri")
val intent =
Intent(Intent.ACTION_SEND)
intent.type = resource.contentType ?: "image/*"
intent.putExtra(
Intent.EXTRA_STREAM,
contentUri
)
val shareIntent = Intent.createChooser(intent, null)
activityLauncher.launch(shareIntent)
}
}
fun saveToGallery() {
coroutineScope.launch {
context.applicationContext.let {
it.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, resource.filename)
put(MediaStore.Images.Media.MIME_TYPE, resource.contentType)
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Revolt")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
)
}?.let { uri ->
context.contentResolver.openOutputStream(uri).use { stream ->
val image = RevoltHttp.get(resourceUrl).readBytes()
stream?.write(image)
context.applicationContext.let {
it.contentResolver.update(
uri,
ContentValues().apply {
put(MediaStore.Images.Media.IS_PENDING, 0)
},
null,
null
)
}
val snackbar = snackbarHostState.showSnackbar(
message = context.getString(R.string.image_viewer_saved),
actionLabel = context.getString(R.string.image_viewer_open),
duration = SnackbarDuration.Short
)
if (snackbar == SnackbarResult.ActionPerformed) {
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(uri, resource.contentType)
intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
activityLauncher.launch(intent)
}
}
}
}
}
RevoltTheme(requestedTheme = GlobalState.theme) {
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { pv ->
Surface(
modifier = Modifier
.padding(pv)
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) {
Column {
PageHeader(text = stringResource(
id = R.string.image_viewer_title, resource.filename ?: resource.id!!
),
showBackButton = true,
onBackButtonClicked = onClose,
maxLines = 1,
additionalButtons = {
Row {
IconButton(onClick = {
shareSubmenuIsOpen.value = true
}) {
Icon(
painter = painterResource(id = R.drawable.ic_share_24dp),
contentDescription = stringResource(id = R.string.share)
)
}
DropdownMenu(
expanded = shareSubmenuIsOpen.value,
onDismissRequest = {
shareSubmenuIsOpen.value = false
}) {
DropdownMenuItem(
onClick = {
shareUrl()
},
text = {
Text(stringResource(id = R.string.image_viewer_share_url))
}
)
DropdownMenuItem(
onClick = {
shareImage()
},
text = {
Text(stringResource(id = R.string.image_viewer_share_image))
}
)
}
IconButton(onClick = {
saveToGallery()
}) {
Icon(
painter = painterResource(id = R.drawable.ic_download_24dp),
contentDescription = stringResource(id = R.string.image_viewer_save)
)
}
}
})
Box(
modifier = Modifier
.clip(RectangleShape)
.fillMaxSize()
) {
AndroidView(
factory = { context ->
com.ortiz.touchview.TouchImageView(context).apply {
maxZoom = 10f
doubleTapScale = 3f
}
},
update = {
Glide.with(it).load(resourceUrl).into(it)
},
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
)
}
}
}
}
}
}

View File

@ -1,9 +1,12 @@
package chat.revolt.api.schemas
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class AutumnResource(
@SerialName("_id")
val id: String? = null,
@ -30,14 +33,15 @@ data class AutumnResource(
@SerialName("object_id")
val objectID: String? = null
)
) : Parcelable
@Serializable
@Parcelize
data class Metadata(
val type: String? = null,
val width: Long? = null,
val height: Long? = null
)
) : Parcelable
@Serializable
data class AutumnId(

View File

@ -1,5 +1,6 @@
package chat.revolt.components.chat
import android.content.Intent
import android.icu.text.DateFormat
import android.icu.text.RelativeDateTimeFormatter
import android.net.Uri
@ -7,6 +8,8 @@ import android.os.Build
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@ -26,6 +29,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import chat.revolt.R
import chat.revolt.activities.media.ImageViewActivity
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.asJanuaryProxyUrl
@ -96,6 +100,12 @@ fun Message(
val context = LocalContext.current
val contentColor = LocalContentColor.current
val attachmentView = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = {
// do nothing
})
Column {
if (message.tail == false) {
Spacer(modifier = Modifier.height(10.dp))
@ -106,7 +116,11 @@ fun Message(
InReplyTo(
messageId = reply,
withMention = replyMessage?.author?.let { message.mentions?.contains(replyMessage.author) }
withMention = replyMessage?.author?.let {
message.mentions?.contains(
replyMessage.author
)
}
?: false,
) {
// TODO Add jump to message
@ -206,7 +220,15 @@ fun Message(
message.attachments.forEach { attachment ->
Spacer(modifier = Modifier.height(2.dp))
MessageAttachment(attachment) {
viewAttachmentInBrowser(context, attachment)
if (attachment.metadata?.type == "Image") {
attachmentView.launch(
Intent(context, ImageViewActivity::class.java).apply {
putExtra("autumnResource", attachment)
}
)
} else {
viewAttachmentInBrowser(context, attachment)
}
}
Spacer(modifier = Modifier.height(2.dp))
}

View File

@ -1,10 +1,10 @@
package chat.revolt.components.generic
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -26,6 +26,8 @@ fun PageHeader(
modifier: Modifier = Modifier,
showBackButton: Boolean = false,
onBackButtonClicked: () -> Unit = {},
additionalButtons: @Composable () -> Unit = {},
maxLines: Int = Int.MAX_VALUE,
) {
Row(
verticalAlignment = Alignment.CenterVertically
@ -41,6 +43,7 @@ fun PageHeader(
}
Text(
text = text,
maxLines = maxLines,
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
@ -48,19 +51,34 @@ fun PageHeader(
),
modifier = modifier
.padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(),
.weight(1f),
)
additionalButtons()
}
}
@Preview
@Preview(showBackground = true)
@Composable
fun PageHeaderPreview() {
PageHeader(text = "Page Header")
}
@Preview
@Preview(showBackground = true)
@Composable
fun PageHeaderPreviewWithBackButton() {
PageHeader(text = "Page Header", showBackButton = true)
}
@Preview(showBackground = true)
@Composable
fun PageHeaderPreviewWithAdditionalButtons() {
PageHeader(text = "Page Header", showBackButton = true, additionalButtons = {
IconButton(onClick = {}) {
Icon(
modifier = Modifier,
imageVector = Icons.Default.ArrowForward,
contentDescription = null
)
}
})
}

View File

@ -0,0 +1,7 @@
package chat.revolt.provider
import androidx.core.content.FileProvider
import chat.revolt.R
class AttachmentProvider : FileProvider(R.xml.file_paths) {
}

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="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" />
</vector>

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="M21,12L14,5V9C7,10 4,15 3,20C5.5,16.5 9,14.9 14,14.9V19L21,12Z" />
</vector>

View File

@ -5,6 +5,7 @@
<string name="next">Next →</string>
<string name="ok">OK</string>
<string name="cancel">Cancel</string>
<string name="share">Share</string>
<string name="lets_go">Let\'s go</string>
<string name="loading">Fetching some info, hang in there…</string>
@ -227,6 +228,14 @@
<string name="invite_error_banned">You are banned from this server.</string>
<string name="invite_error_unknown">An unknown error occurred.</string>
<string name="image_viewer_title">%1$s</string>
<string name="image_viewer_save">Save</string>
<string name="image_viewer_saved">Saved</string>
<string name="image_viewer_save_failed">Failed to save image</string>
<string name="image_viewer_open">Open</string>
<string name="image_viewer_share_url">Share URL</string>
<string name="image_viewer_share_image">Share image</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_appearance_theme">Theme</string>
<string name="settings_appearance_theme_none">System</string>

View File

@ -0,0 +1,5 @@
<paths>
<cache-path
path="attachments/"
name="attachments" />
</paths>