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:
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-6
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user