From ebf1571b92f677c1b23d883e34e96bbad73e07be Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Thu, 8 May 2025 18:05:06 +0200 Subject: [PATCH 01/13] share images from coil cache to other apps --- .../amethyst/ui/components/ShareHelper.kt | 102 ++++++++++++++++++ .../ui/components/ZoomableContentView.kt | 18 ++++ amethyst/src/main/res/xml/file_paths.xml | 2 + 3 files changed, 122 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt 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..94e1e1752 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 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.content.Intent +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.size.Size +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException + +object ShareHelper { + suspend fun shareImageFromUrl( + context: Context, + imageUrl: String, + ) { + val file = getCachedFileForUrl(context, imageUrl) + + if (file != null) { + shareMediaFile(context, getSharableUri(context, file), "image/*") + } else { + throw IOException("Image file does not exist at path: $imageUrl") + } + } + + private suspend fun getCachedFileForUrl( + context: Context, + url: String, + ): File? { + val loader = context.imageLoader + val request = + ImageRequest + .Builder(context) + .data(url) + .size(Size.ORIGINAL) + .allowHardware(true) + .build() + + val result = loader.execute(request) + if (result is SuccessResult) { + val diskCacheKey = result.diskCacheKey ?: return null + val snapshot = loader.diskCache?.openSnapshot(diskCacheKey) + return snapshot?.data?.toFile() + } else { + Log.d("ShareHelper", "Failed to get cached file for URL: $url") + return null + } + } + + private fun getSharableUri( + context: Context, + file: File, + ): Uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file, + ) + + private fun shareMediaFile( + context: Context, + uri: Uri, + mimeType: String = "image/*", + ) { + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + CoroutineScope(Dispatchers.Main).launch { + context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + } + } +} 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 c3af28ddd..4f0560954 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 @@ -47,6 +47,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 +105,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 @@ -751,6 +753,22 @@ fun ShareImageAction( }, ) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text("Share media...") }, + onClick = { + scope.launch(Dispatchers.IO) { + ShareHelper.shareImageFromUrl(context, videoUri) + } + onDismiss() + }, + ) + } + } } } 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 @@ + + From 13ee0a23de05aa667a427486b55152a63d5dc570 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 16:44:31 +0200 Subject: [PATCH 02/13] Use disk cache reference from app Detect image type based on magic number --- .../amethyst/ui/components/ShareHelper.kt | 83 ++++++++++++------- 1 file changed, 53 insertions(+), 30 deletions(-) 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 index 94e1e1752..bc17ba229 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -23,55 +23,78 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent import android.net.Uri -import android.util.Log import androidx.core.content.FileProvider -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import coil3.request.allowHardware -import coil3.size.Size +import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.io.File +import java.io.FileInputStream import java.io.IOException object ShareHelper { - suspend fun shareImageFromUrl( + fun shareImageFromUrl( context: Context, imageUrl: String, ) { - val file = getCachedFileForUrl(context, imageUrl) + // get snapshot + val snapShot = Amethyst.instance.diskCache.openSnapshot(imageUrl) + + // get file from snapshot + val file = snapShot?.data?.toFile() if (file != null) { - shareMediaFile(context, getSharableUri(context, file), "image/*") + // get file extension + val fileExtension = getImageExtension(file) + + // copy to local file with correct extension + val fileCopy = prepareSharableImageFile(context, file, fileExtension) + + // share with intent + shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") } else { throw IOException("Image file does not exist at path: $imageUrl") } + + // close snapshot + snapShot.close() } - private suspend fun getCachedFileForUrl( - context: Context, - url: String, - ): File? { - val loader = context.imageLoader - val request = - ImageRequest - .Builder(context) - .data(url) - .size(Size.ORIGINAL) - .allowHardware(true) - .build() - - val result = loader.execute(request) - if (result is SuccessResult) { - val diskCacheKey = result.diskCacheKey ?: return null - val snapshot = loader.diskCache?.openSnapshot(diskCacheKey) - return snapshot?.data?.toFile() - } else { - Log.d("ShareHelper", "Failed to get cached file for URL: $url") - return null + private fun getImageExtension(file: File): String { + val header = ByteArray(12) + FileInputStream(file).use { + it.read(header) } + + return when { + // JPEG magic number: FF D8 FF + header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte() -> "jpg" + + // PNG magic number: 89 50 4E 47 + header[0] == 0x89.toByte() && + header[1] == 0x50.toByte() && + header[2] == 0x4E.toByte() && + header[3] == 0x47.toByte() -> "png" + + // WEBP magic number: "RIFF....WEBP" + header[0] == 'R'.code.toByte() && + header[1] == 'I'.code.toByte() && + header[2] == 'F'.code.toByte() && + header[3] == 'F'.code.toByte() && + header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" + + else -> "jpg" // default fallback + } + } + + private fun prepareSharableImageFile( + context: Context, + originalFile: File, + extension: String, + ): File { + val sharableFile = File(context.cacheDir, "shared_image.$extension") + originalFile.copyTo(sharableFile, overwrite = true) + return sharableFile } private fun getSharableUri( From fee85a611185e35387c45d22ae5d7fb67f500e74 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 17:06:05 +0200 Subject: [PATCH 03/13] remove scope launch --- .../amethyst/ui/components/ZoomableContentView.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 4f0560954..3cd6a9a33 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 @@ -47,7 +47,6 @@ 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 @@ -105,7 +104,6 @@ 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 @@ -755,15 +753,13 @@ fun ShareImageAction( } val context = LocalContext.current - val scope = rememberCoroutineScope() videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( text = { Text("Share media...") }, onClick = { - scope.launch(Dispatchers.IO) { - ShareHelper.shareImageFromUrl(context, videoUri) - } + ShareHelper.shareImageFromUrl(context, videoUri) + onDismiss() }, ) From 0192a4eb2044cbabd6ce2a33d44fd4b0571da482 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 17:13:29 +0200 Subject: [PATCH 04/13] Add test to only share images. Other media not supported atm --- .../ui/components/ZoomableContentView.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) 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 3cd6a9a33..a933e38da 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 @@ -752,17 +752,21 @@ fun ShareImageAction( ) } - val context = LocalContext.current - videoUri?.let { - if (videoUri.isNotEmpty()) { - DropdownMenuItem( - text = { Text("Share media...") }, - onClick = { - ShareHelper.shareImageFromUrl(context, videoUri) + mimeType?.let { + if (mimeType.startsWith("image")) { + val context = LocalContext.current + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text("Share image...") }, + onClick = { + ShareHelper.shareImageFromUrl(context, videoUri) - onDismiss() - }, - ) + onDismiss() + }, + ) + } + } } } } From 8c1888b4d54e1b37ca9851a57b6b46eabaf29884 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 17:35:06 +0200 Subject: [PATCH 05/13] added error handling .use {} for automatic resource management with snapshots --- .../amethyst/ui/components/ShareHelper.kt | 94 ++++++++++--------- 1 file changed, 50 insertions(+), 44 deletions(-) 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 index bc17ba229..6f9675f8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Log import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope @@ -37,56 +38,61 @@ object ShareHelper { context: Context, imageUrl: String, ) { - // get snapshot - val snapShot = Amethyst.instance.diskCache.openSnapshot(imageUrl) + try { + // Safely get snapshot and file + val snapshot = + Amethyst.instance.diskCache.openSnapshot(imageUrl) + ?: throw IOException("Unable to open snapshot for: $imageUrl") - // get file from snapshot - val file = snapShot?.data?.toFile() + snapshot.use { snapshot -> + val file = + snapshot.data.toFile() - if (file != null) { - // get file extension - val fileExtension = getImageExtension(file) + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableImageFile(context, file, fileExtension) - // copy to local file with correct extension - val fileCopy = prepareSharableImageFile(context, file, fileExtension) - - // share with intent - shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") - } else { - throw IOException("Image file does not exist at path: $imageUrl") - } - - // close snapshot - snapShot.close() - } - - private fun getImageExtension(file: File): String { - val header = ByteArray(12) - FileInputStream(file).use { - it.read(header) - } - - return when { - // JPEG magic number: FF D8 FF - header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte() -> "jpg" - - // PNG magic number: 89 50 4E 47 - header[0] == 0x89.toByte() && - header[1] == 0x50.toByte() && - header[2] == 0x4E.toByte() && - header[3] == 0x47.toByte() -> "png" - - // WEBP magic number: "RIFF....WEBP" - header[0] == 'R'.code.toByte() && - header[1] == 'I'.code.toByte() && - header[2] == 'F'.code.toByte() && - header[3] == 'F'.code.toByte() && - header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" - - else -> "jpg" // default fallback + // Share the file + shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") + } + } catch (e: IOException) { + Log.e("ShareHelper", "Error sharing image", e) + throw e } } + private fun getImageExtension(file: File): String = + try { + FileInputStream(file).use { inputStream -> + val header = ByteArray(12) + inputStream.read(header) + + when { + // JPEG magic number: FF D8 FF + header.sliceArray(0..1).contentEquals(byteArrayOf(0xFF.toByte(), 0xD8.toByte())) -> "jpg" + + // PNG magic number: 89 50 4E 47 + header.sliceArray(0..3).contentEquals( + byteArrayOf( + 0x89.toByte(), + 0x50.toByte(), + 0x4E.toByte(), + 0x47.toByte(), + ), + ) -> "png" + + // WEBP magic number: "RIFF....WEBP" + header.sliceArray(0..3).contentEquals("RIFF".toByteArray()) && + header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" + + else -> "jpg" // default fallback + } + } + } catch (e: IOException) { + Log.w("ShareHelper", "Could not determine image type, defaulting to jpg", e) + "jpg" + } + private fun prepareSharableImageFile( context: Context, originalFile: File, From ce96ab873f6980d07230c63f9f06189c1ca6e093 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 18:42:56 +0200 Subject: [PATCH 06/13] added todos --- .../com/vitorpamplona/amethyst/ui/components/ShareHelper.kt | 3 +++ 1 file changed, 3 insertions(+) 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 index 6f9675f8c..e32bcd05c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -33,6 +33,9 @@ import java.io.File import java.io.FileInputStream import java.io.IOException +// TODO use passed in context type rather than hard coding +// TODO Add unit tests for sharehelper +// TODO Move intent sharing back to ZoomableContentView for coroutine management? object ShareHelper { fun shareImageFromUrl( context: Context, From 35001c0b002ffae5d4caaefe134dd72bd3b5d948 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 20:56:16 +0200 Subject: [PATCH 07/13] moved sharing intent back to view --- .../amethyst/ui/components/ShareHelper.kt | 33 ++++--------------- .../ui/components/ZoomableContentView.kt | 27 +++++++++++++-- 2 files changed, 32 insertions(+), 28 deletions(-) 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 index e32bcd05c..b248959a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -21,26 +21,23 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context -import android.content.Intent import android.net.Uri import android.util.Log import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import java.io.File import java.io.FileInputStream import java.io.IOException -// TODO use passed in context type rather than hard coding +// TODO use passed in mime type rather than hard coding // TODO Add unit tests for sharehelper -// TODO Move intent sharing back to ZoomableContentView for coroutine management? +// TODO Rely on passed in mime type for image type? object ShareHelper { - fun shareImageFromUrl( + fun getSharableUriFromUrl( context: Context, imageUrl: String, - ) { + mimeType: String, + ): Uri { try { // Safely get snapshot and file val snapshot = @@ -55,8 +52,8 @@ object ShareHelper { val fileExtension = getImageExtension(file) val fileCopy = prepareSharableImageFile(context, file, fileExtension) - // Share the file - shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") + // Return sharable uri + return getSharableUri(context, fileCopy) } } catch (e: IOException) { Log.e("ShareHelper", "Error sharing image", e) @@ -115,20 +112,4 @@ object ShareHelper { "${context.packageName}.provider", file, ) - - private fun shareMediaFile( - context: Context, - uri: Uri, - mimeType: String = "image/*", - ) { - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - CoroutineScope(Dispatchers.Main).launch { - context.startActivity(Intent.createChooser(shareIntent, "Share Image")) - } - } } 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 a933e38da..c9ec199f9 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,9 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.content.Context +import android.content.Intent +import android.net.Uri import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -102,8 +105,10 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds @@ -758,10 +763,11 @@ fun ShareImageAction( videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( + // TODO localise text = { Text("Share image...") }, onClick = { - ShareHelper.shareImageFromUrl(context, videoUri) - + val uri = ShareHelper.getSharableUriFromUrl(context, videoUri, mimeType) + shareMediaFile(context, uri, mimeType) onDismiss() }, ) @@ -772,6 +778,23 @@ fun ShareImageAction( } } +private fun shareMediaFile( + context: Context, + uri: Uri, + mimeType: String = "image/*", +) { + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + // TODO is this the right scope to avoid leaks? + CoroutineScope(Dispatchers.Main).launch { + context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + } +} + private suspend fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null From 91b6bf6e88911214305b28cad98e04606995024b Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 21:03:17 +0200 Subject: [PATCH 08/13] localisation --- .../amethyst/ui/components/ZoomableContentView.kt | 3 +-- amethyst/src/main/res/values-cs/strings.xml | 1 + amethyst/src/main/res/values-de/strings.xml | 1 + amethyst/src/main/res/values-pt-rBR/strings.xml | 1 + amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 5 insertions(+), 2 deletions(-) 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 c9ec199f9..a944d6c93 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 @@ -763,8 +763,7 @@ fun ShareImageAction( videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( - // TODO localise - text = { Text("Share image...") }, + text = { Text(stringRes(R.string.share_image)) }, onClick = { val uri = ShareHelper.getSharableUriFromUrl(context, videoUri, mimeType) shareMediaFile(context, uri, mimeType) 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 5646d9b57..8f8a5fad2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1159,4 +1159,5 @@ Chat Relay The relay that all users of this chat connect to + Share image… From 7efec030561c9cc42771a04a86e7501d22da19dd Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 22:26:24 +0200 Subject: [PATCH 09/13] Return file extension from ShareHelper just in case mimetype is null Use content to determine whether it's an image --- .../amethyst/ui/components/ShareHelper.kt | 7 +++---- .../ui/components/ZoomableContentView.kt | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) 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 index b248959a5..c3e24ff3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -36,8 +36,7 @@ object ShareHelper { fun getSharableUriFromUrl( context: Context, imageUrl: String, - mimeType: String, - ): Uri { + ): Pair { try { // Safely get snapshot and file val snapshot = @@ -53,7 +52,7 @@ object ShareHelper { val fileCopy = prepareSharableImageFile(context, file, fileExtension) // Return sharable uri - return getSharableUri(context, fileCopy) + return Pair(getSharableUri(context, fileCopy), fileExtension) } } catch (e: IOException) { Log.e("ShareHelper", "Error sharing image", e) @@ -61,7 +60,7 @@ object ShareHelper { } } - private fun getImageExtension(file: File): String = + fun getImageExtension(file: File): String = try { FileInputStream(file).use { inputStream -> val header = ByteArray(12) 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 a944d6c93..6f73b1b68 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 @@ -685,6 +685,7 @@ fun ShareImageAction( hash = content.hash, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } else if (content is MediaPreloadedContent) { ShareImageAction( @@ -713,6 +714,7 @@ fun ShareImageAction( hash: String?, mimeType: String?, onDismiss: () -> Unit, + content: BaseMediaContent? = null, ) { DropdownMenu( expanded = popupExpanded.value, @@ -757,16 +759,20 @@ fun ShareImageAction( ) } - mimeType?.let { - if (mimeType.startsWith("image")) { + content?.let { + if (content is MediaUrlImage) { val context = LocalContext.current videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( text = { Text(stringRes(R.string.share_image)) }, onClick = { - val uri = ShareHelper.getSharableUriFromUrl(context, videoUri, mimeType) - shareMediaFile(context, uri, mimeType) + val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) + if (mimeType == null) { + shareMediaFile(context, uri, "image/$fileExtension") + } else { + shareMediaFile(context, uri, mimeType) + } onDismiss() }, ) @@ -780,7 +786,7 @@ fun ShareImageAction( private fun shareMediaFile( context: Context, uri: Uri, - mimeType: String = "image/*", + mimeType: String, ) { val shareIntent = Intent(Intent.ACTION_SEND).apply { @@ -790,7 +796,7 @@ private fun shareMediaFile( } // TODO is this the right scope to avoid leaks? CoroutineScope(Dispatchers.Main).launch { - context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + context.startActivity(Intent.createChooser(shareIntent, null)) } } From 2e4c9f77e6295950fa53e582f4be6722b405b1be Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 22:33:15 +0200 Subject: [PATCH 10/13] refactor --- .../amethyst/ui/components/ShareHelper.kt | 122 ++++++++++-------- .../ui/components/ZoomableContentView.kt | 23 ++-- 2 files changed, 80 insertions(+), 65 deletions(-) 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 index c3e24ff3c..f4d29bbfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -29,86 +29,100 @@ import java.io.File import java.io.FileInputStream import java.io.IOException -// TODO use passed in mime type rather than hard coding -// TODO Add unit tests for sharehelper -// TODO Rely on passed in mime type for image type? 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() + fun getSharableUriFromUrl( context: Context, imageUrl: String, ): Pair { - try { - // Safely get snapshot and file - val snapshot = - Amethyst.instance.diskCache.openSnapshot(imageUrl) - ?: throw IOException("Unable to open snapshot for: $imageUrl") + // Safely get snapshot and file + Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> + val file = snapshot.data.toFile() - snapshot.use { snapshot -> - val file = - snapshot.data.toFile() + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableFile(context, file, fileExtension) - // Determine file extension and prepare sharable file - val fileExtension = getImageExtension(file) - val fileCopy = prepareSharableImageFile(context, file, fileExtension) - - // Return sharable uri - return Pair(getSharableUri(context, fileCopy), fileExtension) - } - } catch (e: IOException) { - Log.e("ShareHelper", "Error sharing image", e) - throw e - } + // Return sharable uri + return Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", fileCopy), + fileExtension, + ) + } ?: throw IOException("Unable to open snapshot for: $imageUrl") } - fun getImageExtension(file: File): String = + private fun getImageExtension(file: File): String = try { FileInputStream(file).use { inputStream -> val header = ByteArray(12) - inputStream.read(header) + 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 magic number: FF D8 FF - header.sliceArray(0..1).contentEquals(byteArrayOf(0xFF.toByte(), 0xD8.toByte())) -> "jpg" + // JPEG: Check first 2 bytes + matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" - // PNG magic number: 89 50 4E 47 - header.sliceArray(0..3).contentEquals( - byteArrayOf( - 0x89.toByte(), - 0x50.toByte(), - 0x4E.toByte(), - 0x47.toByte(), - ), - ) -> "png" + // PNG: Check first 4 bytes + matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" - // WEBP magic number: "RIFF....WEBP" - header.sliceArray(0..3).contentEquals("RIFF".toByteArray()) && - header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" + // 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 -> "jpg" // default fallback + else -> DEFAULT_EXTENSION } } } catch (e: IOException) { - Log.w("ShareHelper", "Could not determine image type, defaulting to jpg", e) - "jpg" + Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e) + DEFAULT_EXTENSION } - private fun prepareSharableImageFile( + 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 sharableFile = File(context.cacheDir, "shared_image.$extension") - originalFile.copyTo(sharableFile, overwrite = true) + 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 } - - private fun getSharableUri( - context: Context, - file: File, - ): Uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - file, - ) } 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 6f73b1b68..c3e78f334 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 @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent -import android.net.Uri import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -767,12 +766,7 @@ fun ShareImageAction( DropdownMenuItem( text = { Text(stringRes(R.string.share_image)) }, onClick = { - val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) - if (mimeType == null) { - shareMediaFile(context, uri, "image/$fileExtension") - } else { - shareMediaFile(context, uri, mimeType) - } + shareImageFile(context, videoUri, mimeType) onDismiss() }, ) @@ -783,14 +777,21 @@ fun ShareImageAction( } } -private fun shareMediaFile( +private fun shareImageFile( context: Context, - uri: Uri, - mimeType: String, + 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 = mimeType + type = determinedMimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } From 095d054d6216254e85817efec564de3fcd0e896c Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Tue, 13 May 2025 08:41:55 +0200 Subject: [PATCH 11/13] add support for gifs --- .../com/vitorpamplona/amethyst/ui/components/ShareHelper.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 index f4d29bbfa..3ebfdde0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -39,6 +39,7 @@ object ShareHelper { 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() fun getSharableUriFromUrl( context: Context, @@ -78,6 +79,9 @@ object ShareHelper { // 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 && From a848ea6135751aa615c9fd20afa189dc4637264d Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Tue, 13 May 2025 09:43:23 +0200 Subject: [PATCH 12/13] add content param when making call using a MediaPreloadedContent. Just in case --- .../vitorpamplona/amethyst/ui/components/ZoomableContentView.kt | 1 + 1 file changed, 1 insertion(+) 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 c3e78f334..af3627ada 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 @@ -697,6 +697,7 @@ fun ShareImageAction( hash = null, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } } From 3b5d12b477578139903b0bd4525099d5c2e12d82 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Wed, 14 May 2025 19:15:16 +0200 Subject: [PATCH 13/13] make helper method suspend, use different scope in View --- .../amethyst/ui/components/ShareHelper.kt | 35 ++++++++++--------- .../ui/components/ZoomableContentView.kt | 16 ++++----- 2 files changed, 27 insertions(+), 24 deletions(-) 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 index 3ebfdde0f..279308bfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -25,6 +25,8 @@ 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 @@ -41,25 +43,26 @@ object ShareHelper { private val WEBP_HEADER_END = "WEBP".toByteArray() private val GIF_MAGIC = "GIF8".toByteArray() - fun getSharableUriFromUrl( + suspend fun getSharableUriFromUrl( context: Context, imageUrl: String, - ): Pair { - // Safely get snapshot and file - Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> - val file = snapshot.data.toFile() + ): 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) + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableFile(context, file, fileExtension) - // Return sharable uri - return Pair( - FileProvider.getUriForFile(context, "${context.packageName}.provider", fileCopy), - fileExtension, - ) - } ?: throw IOException("Unable to open snapshot for: $imageUrl") - } + // 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 { 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 d6c611bf0..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 @@ -49,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,7 +105,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -716,6 +716,8 @@ fun ShareImageAction( onDismiss: () -> Unit, content: BaseMediaContent? = null, ) { + val scope = rememberCoroutineScope() + DropdownMenu( expanded = popupExpanded.value, onDismissRequest = onDismiss, @@ -767,7 +769,7 @@ fun ShareImageAction( DropdownMenuItem( text = { Text(stringRes(R.string.share_image)) }, onClick = { - shareImageFile(context, videoUri, mimeType) + scope.launch { shareImageFile(context, videoUri, mimeType) } onDismiss() }, ) @@ -778,7 +780,7 @@ fun ShareImageAction( } } -private fun shareImageFile( +private suspend fun shareImageFile( context: Context, videoUri: String, mimeType: String?, @@ -796,13 +798,11 @@ private fun shareImageFile( putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - // TODO is this the right scope to avoid leaks? - CoroutineScope(Dispatchers.Main).launch { - context.startActivity(Intent.createChooser(shareIntent, null)) - } + + context.startActivity(Intent.createChooser(shareIntent, null)) } -private suspend fun verifyHash(content: MediaUrlContent): Boolean? { +private fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot ->