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

View File

@ -16,6 +16,16 @@
android:name=".RevoltApplication" android:name=".RevoltApplication"
android:theme="@style/Theme.Revolt" android:theme="@style/Theme.Revolt"
tools:targetApi="31"> 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 <meta-data
android:name="io.sentry.auto-init" android:name="io.sentry.auto-init"
android:value="false" /> android:value="false" />
@ -66,6 +76,10 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".activities.media.ImageViewActivity"
android:theme="@style/Theme.Revolt" />
</application> </application>
</manifest> </manifest>

View File

@ -41,8 +41,6 @@ class MainActivity : ComponentActivity() {
SentryAndroid.init(this) { options -> SentryAndroid.init(this) { options ->
options.dsn = BuildConfig.SENTRY_DSN options.dsn = BuildConfig.SENTRY_DSN
options.isDebug = BuildConfig.DEBUG
options.environment = BuildConfig.BUILD_TYPE
options.release = BuildConfig.VERSION_NAME 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 package chat.revolt.api.schemas
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@Parcelize
data class AutumnResource( data class AutumnResource(
@SerialName("_id") @SerialName("_id")
val id: String? = null, val id: String? = null,
@ -30,14 +33,15 @@ data class AutumnResource(
@SerialName("object_id") @SerialName("object_id")
val objectID: String? = null val objectID: String? = null
) ) : Parcelable
@Serializable @Serializable
@Parcelize
data class Metadata( data class Metadata(
val type: String? = null, val type: String? = null,
val width: Long? = null, val width: Long? = null,
val height: Long? = null val height: Long? = null
) ) : Parcelable
@Serializable @Serializable
data class AutumnId( data class AutumnId(

View File

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

View File

@ -1,10 +1,10 @@
package chat.revolt.components.generic package chat.revolt.components.generic
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -26,6 +26,8 @@ fun PageHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showBackButton: Boolean = false, showBackButton: Boolean = false,
onBackButtonClicked: () -> Unit = {}, onBackButtonClicked: () -> Unit = {},
additionalButtons: @Composable () -> Unit = {},
maxLines: Int = Int.MAX_VALUE,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -41,6 +43,7 @@ fun PageHeader(
} }
Text( Text(
text = text, text = text,
maxLines = maxLines,
style = MaterialTheme.typography.displaySmall.copy( style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
@ -48,19 +51,34 @@ fun PageHeader(
), ),
modifier = modifier modifier = modifier
.padding(horizontal = 15.dp, vertical = 15.dp) .padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(), .weight(1f),
) )
additionalButtons()
} }
} }
@Preview @Preview(showBackground = true)
@Composable @Composable
fun PageHeaderPreview() { fun PageHeaderPreview() {
PageHeader(text = "Page Header") PageHeader(text = "Page Header")
} }
@Preview @Preview(showBackground = true)
@Composable @Composable
fun PageHeaderPreviewWithBackButton() { fun PageHeaderPreviewWithBackButton() {
PageHeader(text = "Page Header", showBackButton = true) 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="next">Next →</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="share">Share</string>
<string name="lets_go">Let\'s go</string> <string name="lets_go">Let\'s go</string>
<string name="loading">Fetching some info, hang in there…</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_banned">You are banned from this server.</string>
<string name="invite_error_unknown">An unknown error occurred.</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">Appearance</string>
<string name="settings_appearance_theme">Theme</string> <string name="settings_appearance_theme">Theme</string>
<string name="settings_appearance_theme_none">System</string> <string name="settings_appearance_theme_none">System</string>

View File

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