From f2e484b18ef78ddf579e5ea27ba852f3a394c463 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 18 Jan 2026 20:04:27 +0100 Subject: [PATCH] URL videos: Downloads video to temp file using streaming (avoids OOM), shows progress indicator, shares via Intent, cleans up after 60 seconds Local videos: Shares directly without downloading Video format detection: Supports MP4, WebM, MKV, AVI, MOV based on file magic numbers --- .../amethyst/ui/components/ShareHelper.kt | 114 ++++++++++- .../ui/components/ZoomableContentView.kt | 189 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 3 + 3 files changed, 290 insertions(+), 16 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 c88f6d6fe..39d7c28ba 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,16 +33,31 @@ import java.io.IOException object ShareHelper { private const val TAG = "ShareHelper" - private const val DEFAULT_EXTENSION = "jpg" + private const val DEFAULT_IMAGE_EXTENSION = "jpg" + private const val DEFAULT_VIDEO_EXTENSION = "mp4" private const val SHARED_FILE_PREFIX = "shared_media" - // Media type magic numbers + // Image 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() + // Video type magic numbers + // Note: WebM and MKV share the same EBML header (Matroska container) + private val WEBM_MAGIC = byteArrayOf(0x1A, 0x45, 0xDF.toByte(), 0xA3.toByte()) + private val AVI_HEADER_START = "RIFF".toByteArray() + private val AVI_HEADER_END = "AVI ".toByteArray() + private val MOV_FTYP = "ftyp".toByteArray() + private val MOV_MOOV = "moov".toByteArray() + private val MOV_MDAT = "mdat".toByteArray() + private val MOV_FREE = "free".toByteArray() + private val MP4_BRAND_ISOM = "isom".toByteArray() + private val MP4_BRAND_MP41 = "mp41".toByteArray() + private val MP4_BRAND_MP42 = "mp42".toByteArray() + private val MOV_BRAND_QT = "qt ".toByteArray() + suspend fun getSharableUriFromUrl( context: Context, imageUrl: String, @@ -64,18 +79,26 @@ object ShareHelper { } ?: throw IOException("Unable to open snapshot for: $imageUrl") } - private fun getImageExtension(file: File): String = - try { + private fun getImageExtension(file: File): String = getMediaExtension(file, isVideo = false) + + fun getVideoExtension(file: File): String = getMediaExtension(file, isVideo = true) + + private fun getMediaExtension( + file: File, + isVideo: Boolean, + ): String { + val defaultExtension = if (isVideo) DEFAULT_VIDEO_EXTENSION else DEFAULT_IMAGE_EXTENSION + return try { FileInputStream(file).use { inputStream -> - val header = ByteArray(12) + val header = ByteArray(16) val bytesRead = inputStream.read(header) if (bytesRead < 4) { - // If we couldn't read at least 4 bytes, default to jpg - return DEFAULT_EXTENSION + return defaultExtension } when { + // Image formats // JPEG: Check first 2 bytes matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" @@ -90,13 +113,45 @@ object ShareHelper { bytesRead >= 12 && matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" - else -> DEFAULT_EXTENSION + // Video formats + // WebM/MKV: Check first 4 bytes (EBML header) + // Both use Matroska container; default to webm as it's more common on web + matchesMagicNumbers(header, 0, WEBM_MAGIC) -> "webm" + + // AVI: Check "RIFF" (bytes 0-3) and "AVI " (bytes 8-11) + matchesMagicNumbers(header, 0, AVI_HEADER_START) && + bytesRead >= 12 && + matchesMagicNumbers(header, 8, AVI_HEADER_END) -> "avi" + + // MP4/MOV: Check for ftyp box (bytes 4-7 should be "ftyp") + bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header) + + // MP4/MOV alternative: moov, mdat, or free at offset 4 + bytesRead >= 8 && ( + matchesMagicNumbers(header, 4, MOV_MOOV) || + matchesMagicNumbers(header, 4, MOV_MDAT) || + matchesMagicNumbers(header, 4, MOV_FREE) + ) -> "mp4" + + else -> defaultExtension } } } catch (e: IOException) { - Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e) - DEFAULT_EXTENSION + Log.w(TAG, "Could not determine media type for ${file.name}, defaulting to $defaultExtension", e) + defaultExtension } + } + + private fun detectMp4OrMov(header: ByteArray): String { + // Check brand at bytes 8-11 to differentiate MP4 from MOV + return when { + matchesMagicNumbers(header, 8, MP4_BRAND_ISOM) -> "mp4" + matchesMagicNumbers(header, 8, MP4_BRAND_MP41) -> "mp4" + matchesMagicNumbers(header, 8, MP4_BRAND_MP42) -> "mp4" + matchesMagicNumbers(header, 8, MOV_BRAND_QT) -> "mov" + else -> "mp4" // Default to mp4 for unknown ftyp brands + } + } private fun matchesMagicNumbers( data: ByteArray, @@ -132,4 +187,43 @@ object ShareHelper { return sharableFile } + + suspend fun getSharableUriForLocalVideo( + context: Context, + localFile: File, + ): Pair = + withContext(Dispatchers.IO) { + val fileExtension = getVideoExtension(localFile) + val sharableFile = prepareSharableFile(context, localFile, fileExtension) + Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile), + fileExtension, + ) + } + + fun createTempVideoFile(context: Context): File { + val timestamp = System.currentTimeMillis() + return File(context.cacheDir, "${SHARED_FILE_PREFIX}_video_$timestamp.tmp") + } + + fun prepareTempVideoForSharing( + context: Context, + tempFile: File, + ): Pair { + val extension = getVideoExtension(tempFile) + val timestamp = System.currentTimeMillis() + val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension") + + try { + tempFile.renameTo(sharableFile) + } catch (e: Exception) { + Log.e(TAG, "Failed to rename temp video file for sharing", e) + throw e + } + + return Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile), + extension, + ) + } } 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 222898aa8..0127af4e3 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,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent +import android.os.Handler +import android.os.Looper import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size @@ -108,10 +111,16 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import okio.sink +import java.io.File import kotlin.time.Duration.Companion.seconds @Composable @@ -765,19 +774,75 @@ fun ShareImageAction( } content?.let { - if (content is MediaUrlImage) { - val context = LocalContext.current - videoUri?.let { - if (videoUri.isNotEmpty()) { + val context = LocalContext.current + + when (content) { + is MediaUrlImage -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text(stringRes(R.string.share_image)) }, + onClick = { + scope.launch { shareImageFile(context, videoUri, mimeType) } + onDismiss() + }, + ) + } + } + } + is MediaUrlVideo -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + // State is read via Compose recomposition in text{} and enabled parameter + val isDownloading = remember { mutableStateOf(false) } + + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringRes(R.string.share_video)) + if (isDownloading.value) { + Spacer(modifier = Modifier.width(8.dp)) + LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp) + } + } + }, + enabled = !isDownloading.value, + onClick = { + isDownloading.value = true + scope.launch { + shareVideoFile( + context = context, + videoUrl = videoUri, + mimeType = mimeType, + okHttpClient = { url -> + accountViewModel.httpClientBuilder.okHttpClientForVideo(url) + }, + onComplete = { + isDownloading.value = false + onDismiss() + }, + onError = { + isDownloading.value = false + }, + ) + } + }, + ) + } + } + } + is MediaLocalVideo -> { + content.localFile?.let { localFile -> DropdownMenuItem( - text = { Text(stringRes(R.string.share_image)) }, + text = { Text(stringRes(R.string.share_video)) }, onClick = { - scope.launch { shareImageFile(context, videoUri, mimeType) } + scope.launch { shareLocalVideoFile(context, localFile, mimeType) } onDismiss() }, ) } } + else -> { /* No share option for other types */ } } } } @@ -810,6 +875,118 @@ private suspend fun shareImageFile( } } +private suspend fun shareVideoFile( + context: Context, + videoUrl: String, + mimeType: String?, + okHttpClient: (String) -> OkHttpClient, + onComplete: () -> Unit, + onError: () -> Unit, +) { + val tempFile = ShareHelper.createTempVideoFile(context) + try { + withContext(Dispatchers.IO) { + // Download video using streaming + val client = okHttpClient(videoUrl) + val request = + Request + .Builder() + .get() + .url(videoUrl) + .build() + + client.newCall(request).executeAsync().use { response -> + check(response.isSuccessful) { "Download failed: ${response.code}" } + + // Stream the response to the temp file + tempFile.outputStream().use { outputStream -> + response.body.source().readAll(outputStream.sink()) + } + } + + // Prepare the temp file for sharing (determines extension and creates sharable URI) + val (uri, extension) = ShareHelper.prepareTempVideoForSharing(context, tempFile) + + // Determine mime type + val determinedMimeType = mimeType ?: "video/$extension" + + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = determinedMimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + withContext(Dispatchers.Main) { + context.startActivity(Intent.createChooser(shareIntent, null)) + } + + // Schedule cleanup after 60 seconds to allow the receiving app time to copy the file + Handler(Looper.getMainLooper()).postDelayed({ + tempFile.delete() + }, 60_000) + } + + withContext(Dispatchers.Main) { + onComplete() + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("ZoomableContentView", "Failed to share video: $videoUrl", e) + + // Clean up temp file on error + tempFile.delete() + + withContext(Dispatchers.Main) { + Toast + .makeText( + context, + context.getString(R.string.unable_to_share_video), + Toast.LENGTH_SHORT, + ).show() + onError() + } + } +} + +private suspend fun shareLocalVideoFile( + context: Context, + localFile: File, + mimeType: String?, +) { + try { + withContext(Dispatchers.IO) { + // Get sharable URI for the local file + val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile) + + // Determine mime type + val determinedMimeType = mimeType ?: "video/$extension" + + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = determinedMimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + withContext(Dispatchers.Main) { + context.startActivity(Intent.createChooser(shareIntent, null)) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("ZoomableContentView", "Failed to share local video: ${localFile.path}", e) + Toast + .makeText( + context, + context.getString(R.string.unable_to_share_video), + Toast.LENGTH_SHORT, + ).show() + } +} + private fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a78c01f31..b93fa4d57 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1426,6 +1426,9 @@ The relay that all users of this chat connect to Share image… Unable to share image, please try again later… + Share video… + Unable to share video, please try again later… + Downloading video… Search hashtag: #%1$s