diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt new file mode 100644 index 000000000..279308bfc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import com.vitorpamplona.amethyst.Amethyst +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.IOException + +object ShareHelper { + private const val TAG = "ShareHelper" + private const val DEFAULT_EXTENSION = "jpg" + private const val SHARED_FILE_PREFIX = "shared_media" + + // Media type magic numbers + private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) + private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte()) + private val WEBP_HEADER_START = "RIFF".toByteArray() + private val WEBP_HEADER_END = "WEBP".toByteArray() + private val GIF_MAGIC = "GIF8".toByteArray() + + suspend fun getSharableUriFromUrl( + context: Context, + imageUrl: String, + ): Pair = + withContext(Dispatchers.IO) { + // Safely get snapshot and file + Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> + val file = snapshot.data.toFile() + + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableFile(context, file, fileExtension) + + // Return sharable uri + return@use Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", fileCopy), + fileExtension, + ) + } ?: throw IOException("Unable to open snapshot for: $imageUrl") + } + + private fun getImageExtension(file: File): String = + try { + FileInputStream(file).use { inputStream -> + val header = ByteArray(12) + val bytesRead = inputStream.read(header) + + if (bytesRead < 4) { + // If we couldn't read at least 4 bytes, default to jpg + return DEFAULT_EXTENSION + } + + when { + // JPEG: Check first 2 bytes + matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" + + // PNG: Check first 4 bytes + matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" + + // GIF: Check first 4 bytes for "GIF8" + matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif" + + // WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11) + matchesMagicNumbers(header, 0, WEBP_HEADER_START) && + bytesRead >= 12 && + matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" + + else -> DEFAULT_EXTENSION + } + } + } catch (e: IOException) { + Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e) + DEFAULT_EXTENSION + } + + private fun matchesMagicNumbers( + data: ByteArray, + offset: Int, + magicBytes: ByteArray, + ): Boolean { + if (offset + magicBytes.size > data.size) { + return false + } + + for (i in magicBytes.indices) { + if (data[offset + i] != magicBytes[i]) { + return false + } + } + return true + } + + private fun prepareSharableFile( + context: Context, + originalFile: File, + extension: String, + ): File { + val timestamp = System.currentTimeMillis() + val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension") + + try { + originalFile.copyTo(sharableFile, overwrite = true) + } catch (e: IOException) { + Log.e(TAG, "Failed to copy file for sharing", e) + throw e + } + + return sharableFile + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index b2c5169c0..ea77ff3b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.content.Context +import android.content.Intent import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -47,6 +49,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -104,6 +107,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds @@ -680,6 +684,7 @@ fun ShareImageAction( hash = content.hash, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } else if (content is MediaPreloadedContent) { ShareImageAction( @@ -692,6 +697,7 @@ fun ShareImageAction( hash = null, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } } @@ -708,7 +714,10 @@ fun ShareImageAction( hash: String?, mimeType: String?, onDismiss: () -> Unit, + content: BaseMediaContent? = null, ) { + val scope = rememberCoroutineScope() + DropdownMenu( expanded = popupExpanded.value, onDismissRequest = onDismiss, @@ -751,10 +760,49 @@ fun ShareImageAction( }, ) } + + content?.let { + if (content is MediaUrlImage) { + val context = LocalContext.current + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text(stringRes(R.string.share_image)) }, + onClick = { + scope.launch { shareImageFile(context, videoUri, mimeType) } + onDismiss() + }, + ) + } + } + } + } } } -private suspend fun verifyHash(content: MediaUrlContent): Boolean? { +private suspend fun shareImageFile( + context: Context, + videoUri: String, + mimeType: String?, +) { + // Get sharable URI and file extension + val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) + + // Determine mime type, use provided or derive from extension + val determinedMimeType = mimeType ?: "image/$fileExtension" + + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = determinedMimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(shareIntent, null)) +} + +private fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot -> diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 9a8369877..02c89a154 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -936,4 +936,5 @@ Pro otevření a stažení souboru nejsou nainstalovány žádné torrent aplikace. Vyberte seznam pro filtrování kanálu Odhlásit se na zámek zařízení + Sdílet obrázek… diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 0a62275d1..2c0c4ed3b 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -941,4 +941,5 @@ anz der Bedingungen ist erforderlich Keine Torrent-Apps installiert, um die Datei zu öffnen und herunterzuladen. Liste zum Filtern des Feeds auswählen Beim Sperren des Geräts abmelden + Bild teilen… diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 1e7c8e877..0ca3700a8 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -958,4 +958,5 @@ Mensagem Privada Relé de chat O relé a qual todos os usuários deste chat se conectam + Compartilhar imagem… diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bca4d2e09..7c8430213 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1170,4 +1170,5 @@ Chat Relay The relay that all users of this chat connect to + Share image… diff --git a/amethyst/src/main/res/xml/file_paths.xml b/amethyst/src/main/res/xml/file_paths.xml index a075ef96b..0b339a9cf 100644 --- a/amethyst/src/main/res/xml/file_paths.xml +++ b/amethyst/src/main/res/xml/file_paths.xml @@ -3,4 +3,6 @@ + +