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
This commit is contained in:
davotoula
2026-01-18 20:04:27 +01:00
parent 6f62e2c7b1
commit f2e484b18e
3 changed files with 290 additions and 16 deletions
@@ -33,16 +33,31 @@ import java.io.IOException
object ShareHelper { object ShareHelper {
private const val TAG = "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" 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 JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte())
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.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_START = "RIFF".toByteArray()
private val WEBP_HEADER_END = "WEBP".toByteArray() private val WEBP_HEADER_END = "WEBP".toByteArray()
private val GIF_MAGIC = "GIF8".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( suspend fun getSharableUriFromUrl(
context: Context, context: Context,
imageUrl: String, imageUrl: String,
@@ -64,18 +79,26 @@ object ShareHelper {
} ?: throw IOException("Unable to open snapshot for: $imageUrl") } ?: throw IOException("Unable to open snapshot for: $imageUrl")
} }
private fun getImageExtension(file: File): String = private fun getImageExtension(file: File): String = getMediaExtension(file, isVideo = false)
try {
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 -> FileInputStream(file).use { inputStream ->
val header = ByteArray(12) val header = ByteArray(16)
val bytesRead = inputStream.read(header) val bytesRead = inputStream.read(header)
if (bytesRead < 4) { if (bytesRead < 4) {
// If we couldn't read at least 4 bytes, default to jpg return defaultExtension
return DEFAULT_EXTENSION
} }
when { when {
// Image formats
// JPEG: Check first 2 bytes // JPEG: Check first 2 bytes
matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg"
@@ -90,13 +113,45 @@ object ShareHelper {
bytesRead >= 12 && bytesRead >= 12 &&
matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" 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) { } catch (e: IOException) {
Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e) Log.w(TAG, "Could not determine media type for ${file.name}, defaulting to $defaultExtension", e)
DEFAULT_EXTENSION 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( private fun matchesMagicNumbers(
data: ByteArray, data: ByteArray,
@@ -132,4 +187,43 @@ object ShareHelper {
return sharableFile return sharableFile
} }
suspend fun getSharableUriForLocalVideo(
context: Context,
localFile: File,
): Pair<Uri, String> =
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<Uri, String> {
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,
)
}
} }
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope 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.Box
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -108,10 +111,16 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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 import kotlin.time.Duration.Companion.seconds
@Composable @Composable
@@ -765,19 +774,75 @@ fun ShareImageAction(
} }
content?.let { content?.let {
if (content is MediaUrlImage) { val context = LocalContext.current
val context = LocalContext.current
videoUri?.let { when (content) {
if (videoUri.isNotEmpty()) { 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( DropdownMenuItem(
text = { Text(stringRes(R.string.share_image)) }, text = { Text(stringRes(R.string.share_video)) },
onClick = { onClick = {
scope.launch { shareImageFile(context, videoUri, mimeType) } scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
onDismiss() 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? { private fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null if (content.hash == null) return null
+3
View File
@@ -1426,6 +1426,9 @@
<string name="group_relay_explanation">The relay that all users of this chat connect to</string> <string name="group_relay_explanation">The relay that all users of this chat connect to</string>
<string name="share_image">Share image…</string> <string name="share_image">Share image…</string>
<string name="unable_to_share_image">Unable to share image, please try again later…</string> <string name="unable_to_share_image">Unable to share image, please try again later…</string>
<string name="share_video">Share video…</string>
<string name="unable_to_share_video">Unable to share video, please try again later…</string>
<string name="downloading_video_for_sharing">Downloading video…</string>
<string name="search_by_hashtag">Search hashtag: #%1$s</string> <string name="search_by_hashtag">Search hashtag: #%1$s</string>