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