From 159938348272d35f18bc42299cdfac4be378ee9f Mon Sep 17 00:00:00 2001 From: Infi Date: Tue, 18 Apr 2023 02:29:25 +0200 Subject: [PATCH] feat: image attachment viewer with sharing & download --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 14 + app/src/main/java/chat/revolt/MainActivity.kt | 2 - .../activities/media/ImageViewActivity.kt | 300 ++++++++++++++++++ .../java/chat/revolt/api/schemas/Generic.kt | 8 +- .../chat/revolt/components/chat/Message.kt | 26 +- .../revolt/components/generic/PageHeader.kt | 26 +- .../revolt/provider/AttachmentProvider.kt | 7 + .../main/res/drawable/ic_download_24dp.xml | 9 + app/src/main/res/drawable/ic_share_24dp.xml | 9 + app/src/main/res/values/strings.xml | 9 + app/src/main/res/xml/file_paths.xml | 5 + 12 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt create mode 100644 app/src/main/java/chat/revolt/provider/AttachmentProvider.kt create mode 100644 app/src/main/res/drawable/ic_download_24dp.xml create mode 100644 app/src/main/res/drawable/ic_share_24dp.xml create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/build.gradle b/app/build.gradle index 80ab67f7..c13f19a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 71369618..1e96b0ff 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,16 @@ android:name=".RevoltApplication" android:theme="@style/Theme.Revolt" tools:targetApi="31"> + + + + @@ -66,6 +76,10 @@ + + \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/MainActivity.kt b/app/src/main/java/chat/revolt/MainActivity.kt index 93d04258..43369d82 100644 --- a/app/src/main/java/chat/revolt/MainActivity.kt +++ b/app/src/main/java/chat/revolt/MainActivity.kt @@ -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 } diff --git a/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt b/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt new file mode 100644 index 00000000..38b8d3c1 --- /dev/null +++ b/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt @@ -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), + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/schemas/Generic.kt b/app/src/main/java/chat/revolt/api/schemas/Generic.kt index 2c594ccd..45101245 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Generic.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Generic.kt @@ -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( diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index cb6c0ad1..b571769b 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -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)) } diff --git a/app/src/main/java/chat/revolt/components/generic/PageHeader.kt b/app/src/main/java/chat/revolt/components/generic/PageHeader.kt index 37bca79a..6c28a401 100644 --- a/app/src/main/java/chat/revolt/components/generic/PageHeader.kt +++ b/app/src/main/java/chat/revolt/components/generic/PageHeader.kt @@ -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 + ) + } + }) } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/provider/AttachmentProvider.kt b/app/src/main/java/chat/revolt/provider/AttachmentProvider.kt new file mode 100644 index 00000000..0d50f596 --- /dev/null +++ b/app/src/main/java/chat/revolt/provider/AttachmentProvider.kt @@ -0,0 +1,7 @@ +package chat.revolt.provider + +import androidx.core.content.FileProvider +import chat.revolt.R + +class AttachmentProvider : FileProvider(R.xml.file_paths) { +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_download_24dp.xml b/app/src/main/res/drawable/ic_download_24dp.xml new file mode 100644 index 00000000..874fab8d --- /dev/null +++ b/app/src/main/res/drawable/ic_download_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_share_24dp.xml b/app/src/main/res/drawable/ic_share_24dp.xml new file mode 100644 index 00000000..287a5d59 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 815a08f9..96e474c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Next → OK Cancel + Share Let\'s go Fetching some info, hang in there… @@ -227,6 +228,14 @@ You are banned from this server. An unknown error occurred. + %1$s + Save + Saved + Failed to save image + Open + Share URL + Share image + Appearance Theme System diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..bae99c16 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + \ No newline at end of file