feat: unify audio and video attachment playback (#1188)

This commit is contained in:
M57 authored and GitHub committed 2026-09-18 20:20:34 +03:00
1 parent 89cd598c25
commit abce60ad7a
23 files changed
+2640 -496

No files matched your search

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,229 @@
package com.m57.hermescontrol.ui.chat
import android.content.ContentUris
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class MediaViewerDeviceTest {
@get:Rule
val compose = createComposeRule()
private fun open(
asset: String,
mime: String,
) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val file = File(instrumentation.targetContext.cacheDir, asset)
instrumentation.context.assets
.open(asset)
.use { input -> file.outputStream().use(input::copyTo) }
compose.setContent {
var visible by remember { mutableStateOf(true) }
HermesControlTheme {
if (visible) {
MediaViewerDialog(file.toURI().toString(), { visible = false }, asset, mime)
}
}
}
}
private fun awaitDescription(resource: Int) {
val text = InstrumentationRegistry.getInstrumentation().targetContext.getString(resource)
compose.waitUntil(15_000) {
compose.onAllNodesWithContentDescription(text).fetchSemanticsNodes().isNotEmpty()
}
}
private fun awaitTag(tag: String) {
compose.waitUntil(15_000) {
compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
private fun checkPlayback(
asset: String,
mime: String,
) {
open(asset, mime)
awaitDescription(R.string.media_player_pause)
awaitTag("media_play_pause_button")
compose.onNodeWithTag("media_play_pause_button").performClick()
awaitDescription(R.string.media_player_play)
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.uiAutomation.takeScreenshot().let { bitmap ->
File(instrumentation.targetContext.cacheDir, "$asset.png").outputStream().use {
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)
}
bitmap.recycle()
}
compose.onNodeWithTag("media_seek_slider").performTouchInput {
swipe(start = center.copy(x = width * 0.2f), end = center.copy(x = width * 0.9f))
}
compose.waitUntil(5_000) {
val progress =
compose
.onNodeWithTag("media_seek_slider")
.fetchSemanticsNode()
.config[SemanticsProperties.ProgressBarRangeInfo]
progress.current > progress.range.endInclusive * 0.75f
}
awaitTag("media_play_pause_button")
compose.onNodeWithTag("media_play_pause_button").performClick()
awaitDescription(R.string.media_player_replay)
awaitTag("media_play_pause_button")
compose.onNodeWithTag("media_play_pause_button").performClick()
awaitDescription(R.string.media_player_pause)
awaitTag("media_close_button")
compose.onNodeWithTag("media_close_button").performClick()
compose.waitUntil(15_000) {
compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty()
}
}
@Test
fun audioPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-tone.wav", "audio/wav")
@Test
fun videoPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-video.mp4", "video/mp4")
@Test
fun saveButtonStreamsToDownloadsAndVerifiesMediaStoreItem() {
open("player-tone.wav", "audio/wav")
awaitTag("media_save_button")
compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick()
val instrumentation = InstrumentationRegistry.getInstrumentation()
val resolver = instrumentation.targetContext.contentResolver
var savedUri: Uri? = null
compose.waitUntil(15_000) {
val cursor =
resolver.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.MIME_TYPE,
),
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf("player-tone.wav"),
"${MediaStore.MediaColumns._ID} DESC",
)
cursor?.use { c ->
if (c.moveToFirst()) {
val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
val name = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
val mime = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE))
assertEquals("player-tone.wav", name)
assertTrue(mime == "audio/wav" || mime == "audio/x-wav")
true
} else {
false
}
} ?: false
}
try {
assertNotNull(savedUri)
val expectedBytes =
instrumentation.context.assets
.open("player-tone.wav")
.use { it.readBytes() }
val actualBytes = resolver.openInputStream(savedUri!!)?.use { it.readBytes() }
assertArrayEquals(expectedBytes, actualBytes)
} finally {
savedUri?.let { resolver.delete(it, null, null) }
}
compose.onNodeWithTag("media_close_button").performClick()
}
@Test
fun shareMediaBuildsValidIntentAndReadableStream() =
runBlocking {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val file = File(context.cacheDir, "player-tone.wav")
instrumentation.context.assets.open("player-tone.wav").use { input ->
file.outputStream().use(input::copyTo)
}
val intent =
MediaExportHelper.shareMedia(
context = context,
uri = file.toURI().toString(),
fallbackMime = "audio/wav",
displayName = "player-tone.wav",
)
assertNotNull(intent)
assertEquals(Intent.ACTION_SEND, intent!!.action)
assertEquals("audio/wav", intent.type)
assertTrue(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0)
@Suppress("DEPRECATION")
val streamUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
assertNotNull(streamUri)
val expectedSize = file.length()
val actualSize =
context.contentResolver.openInputStream(streamUri!!)?.use { stream ->
var count = 0L
val buf = ByteArray(8192)
while (true) {
val r = stream.read(buf)
if (r < 0) break
count += r
}
count
}
assertEquals(expectedSize, actualSize)
}
@Test
fun invalidSourceShowsRetryAndCanClose() {
compose.setContent {
HermesControlTheme {
MediaViewerDialog("file:///missing-media-test.mp3", {}, mimeType = "audio/mpeg")
}
}
compose.waitUntil(15_000) {
compose.onAllNodesWithTag("media_retry_button").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag("media_retry_button").assertIsDisplayed().performClick()
compose.waitUntil(15_000) {
compose.onAllNodesWithTag("media_retry_button").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag("media_close_button").assertIsDisplayed()
}
}
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.classifyMedia
import kotlinx.serialization.Serializable
/**
@@ -25,22 +27,23 @@ data class Attachment(
val gatewayUrl: String? = null,
val source: AttachmentSource = AttachmentSource.LOCAL,
) {
val mediaKind: MediaKind
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
val isImage: Boolean
get() = mimeType.startsWith("image/")
get() = mediaKind == MediaKind.IMAGE
val isGif: Boolean
get() =
mimeType.equals("image/gif", ignoreCase = true) ||
fileExtension == "gif" ||
uri.contains(".gif", ignoreCase = true) ||
uri.startsWith("data:image/gif", ignoreCase = true)
val isVideo: Boolean
get() =
mimeType.startsWith("video/") ||
fileExtension in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") ||
uri.contains(".mp4", ignoreCase = true) ||
uri.contains(".webm", ignoreCase = true)
get() = mediaKind == MediaKind.VIDEO
val isAudio: Boolean
get() = mediaKind == MediaKind.AUDIO
val isGateway: Boolean
get() = source == AttachmentSource.GATEWAY
@@ -277,6 +277,7 @@ object GatewayFileClient {
coroutineContext.ensureActive()
val read = input.read(buffer)
if (read < 0) break
coroutineContext.ensureActive()
output.write(buffer, 0, read)
}
}
@@ -0,0 +1,73 @@
package com.m57.hermescontrol.data.remote
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
@OptIn(UnstableApi::class)
object MediaDataSourceProvider {
class SameOriginAuthInterceptor(
private val baseUrlProvider: () -> String = { runCatching { AuthManager.baseUrl() }.getOrDefault("") },
private val tokenProvider: () -> String? = { runCatching { AuthManager.getToken() }.getOrNull() },
private val isGatedModeProvider: () -> Boolean = {
runCatching { AuthManager.isGatedMode() }.getOrDefault(
false,
)
},
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// In gated mode (basic auth), the dashboard authenticates via session cookies;
// stamping an Authorization: Bearer header causes 401s (issue #470).
if (isGatedModeProvider()) {
return chain.proceed(request)
}
val url = request.url
val base = baseUrlProvider().toHttpUrlOrNull()
val isSameOrigin =
base != null &&
url.scheme.equals(base.scheme, ignoreCase = true) &&
url.host.equals(base.host, ignoreCase = true) &&
url.port == base.port
if (isSameOrigin) {
val token = tokenProvider()
if (!token.isNullOrBlank() && request.header("Authorization") == null) {
return chain.proceed(
request
.newBuilder()
.header("Authorization", "Bearer $token")
.build(),
)
}
}
return chain.proceed(request)
}
}
@Volatile
internal var testClient: OkHttpClient? = null
val mediaOkHttpClient: OkHttpClient
get() = testClient ?: defaultClient
private val defaultClient: OkHttpClient by lazy {
OkHttpProvider.base
.newBuilder()
.addInterceptor(SameOriginAuthInterceptor())
.build()
}
fun createDataSourceFactory(context: Context): DataSource.Factory {
val httpDataSourceFactory = OkHttpDataSource.Factory(mediaOkHttpClient)
return DefaultDataSource.Factory(context, httpDataSourceFactory)
}
}
@@ -593,16 +593,20 @@ internal fun InlineAttachment(
val isSaving = savingPath != null && savingPath == attachmentPath
val isOpening = openingPath != null && openingPath == attachmentPath
val clickable = Modifier.clickable { onOpen(attachment) }
if (attachment.isVideo) {
var showVideoDialog by remember { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer(
videoUri = attachment.uri,
onFullScreenClick = { showVideoDialog = true },
if (attachment.isVideo || attachment.isAudio) {
var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer(
uri = attachment.gatewayUrl ?: attachment.uri,
title = attachment.name,
mimeType = attachment.mimeType,
onFullScreenClick = { showMediaDialog = true },
)
if (showVideoDialog) {
com.m57.hermescontrol.ui.chat.components.VideoViewerDialog(
videoUri = attachment.uri,
onDismissRequest = { showVideoDialog = false },
if (showMediaDialog) {
com.m57.hermescontrol.ui.chat.components.MediaViewerDialog(
mediaUri = attachment.gatewayUrl ?: attachment.uri,
title = attachment.name,
mimeType = attachment.mimeType,
onDismissRequest = { showMediaDialog = false },
)
}
} else if (attachment.isImage) {
@@ -0,0 +1,241 @@
package com.m57.hermescontrol.ui.chat
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.content.FileProvider
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.remote.GatewayFileClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.util.UUID
enum class MediaSaveStrategy {
MEDIA_STORE,
CREATE_DOCUMENT,
}
fun mediaSaveStrategy(sdkInt: Int = Build.VERSION.SDK_INT): MediaSaveStrategy =
if (sdkInt >= Build.VERSION_CODES.Q) {
MediaSaveStrategy.MEDIA_STORE
} else {
MediaSaveStrategy.CREATE_DOCUMENT
}
object MediaExportHelper {
private const val SHARE_CACHE_TTL_MS = 24 * 60 * 60 * 1000L
fun resolveDisplayName(
displayName: String?,
mimeType: String?,
uri: String,
): String {
val fallbackCandidate = mediaNameFromPath(uri).takeIf { it.isNotBlank() && it != "file" } ?: "hermes-media"
val candidate = displayName?.takeIf { it.isNotBlank() } ?: fallbackCandidate
val leaf = candidate.split('/', '\\').lastOrNull { it.isNotBlank() } ?: "hermes-media"
val rawExt = extractFileExtension(leaf)
val hasValidExt = rawExt.isNotBlank() && rawExt.length <= 10
val finalExt =
if (hasValidExt) {
rawExt
} else {
mimeType?.let { extensionForMime(it) } ?: "bin"
}
val rawBase = if (hasValidExt) leaf.substringBeforeLast('.') else leaf
val cleanedBase =
rawBase
.replace(Regex("[^A-Za-z0-9_\\-]"), "_")
.trim('_')
.take(60)
.ifBlank { "hermes-media" }
val cleanedExt =
finalExt
.replace(Regex("[^A-Za-z0-9]"), "")
.take(10)
.ifBlank { "bin" }
return "$cleanedBase.$cleanedExt"
}
/**
* Best-effort cleanup of stale share directories older than 24 hours.
* Only touches subdirectories inside [sharedMediaDir].
*/
internal fun sweepOldShareFiles(
sharedMediaDir: File,
now: Long = System.currentTimeMillis(),
) {
val cutoff = now - SHARE_CACHE_TTL_MS
runCatching {
sharedMediaDir.listFiles()?.forEach { entry ->
if (entry.isDirectory) {
if (entry.lastModified() < cutoff) {
entry.deleteRecursively()
}
} else if (entry.isFile && entry.lastModified() < cutoff) {
entry.delete()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
internal fun createMediaStoreValues(
displayName: String,
mimeType: String,
length: Long?,
): ContentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (length != null && length > 0) {
put(MediaStore.MediaColumns.SIZE, length)
}
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Hermes")
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
/**
* Streams media directly to [MediaStore.Downloads] on API 29+ (Android Q+).
* Uses bounded 64 KiB chunks with periodic coroutine cancellation checks.
* Marked pending during write and un-pended on commit.
* Guarantees that any partial item is deleted if the write fails or is canceled.
*/
@RequiresApi(Build.VERSION_CODES.Q)
suspend fun saveMediaToDownloads(
context: Context,
uri: String,
fallbackMime: String = "application/octet-stream",
displayName: String? = null,
currentSdk: Int = Build.VERSION.SDK_INT,
valuesFactory: (String, String, Long?) -> ContentValues = ::createMediaStoreValues,
): String =
withContext(Dispatchers.IO) {
if (currentSdk < Build.VERSION_CODES.Q) {
throw UnsupportedOperationException("Direct MediaStore.Downloads requires API 29+")
}
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
val frameworkMime = normalizeFrameworkMime(streamInfo.mimeType)
val values = valuesFactory(finalName, frameworkMime, streamInfo.length)
val resolver = context.contentResolver
val targetUri =
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: throw IOException("Failed to insert into MediaStore.Downloads")
var success = false
try {
resolver.openOutputStream(targetUri)?.use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
} ?: throw IOException("Failed to open MediaStore output stream")
val publishValues = valuesFactory(finalName, frameworkMime, streamInfo.length)
publishValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
val updated = resolver.update(targetUri, publishValues, null, null)
if (updated <= 0) {
throw IOException("Failed to publish media item in MediaStore")
}
success = true
context.getString(R.string.media_player_saved)
} finally {
if (!success) {
runCatching { resolver.delete(targetUri, null, null) }
}
}
}
}
/**
* Streams media directly into a user-selected [targetUri] obtained via SAF (e.g. CreateDocument).
* Used for API 26-28 where MediaStore.Downloads is unavailable.
* Deletes partial documents if the stream write fails or is canceled.
*/
suspend fun saveMediaToUri(
context: Context,
sourceUri: String,
targetUri: Uri,
fallbackMime: String = "application/octet-stream",
): String =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
var success = false
try {
MediaStreamResolver.useStream(context, sourceUri, fallbackMime) { inputStream, _ ->
resolver.openOutputStream(targetUri)?.use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
} ?: throw IOException("Failed to open target document output stream")
}
success = true
context.getString(R.string.media_player_saved)
} finally {
if (!success) {
runCatching { DocumentsContract.deleteDocument(resolver, targetUri) }
.onFailure {
runCatching { resolver.delete(targetUri, null, null) }
}
}
}
}
/**
* Streams media into an internal cache file in `shared_media/<uuid>/<safeName>` and builds
* an ACTION_SEND Intent. Uses collision-proof session subdirectories and cleans stale entries
* older than 24 hours. Share files are preserved so receiving apps can read them.
*/
suspend fun shareMedia(
context: Context,
uri: String,
fallbackMime: String = "application/octet-stream",
displayName: String? = null,
fileUriProvider: (Context, File) -> Uri = { ctx, f ->
FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", f)
},
intentFactory: (Uri, String) -> Intent = { contentUri, mime ->
Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
},
): Intent? =
withContext(Dispatchers.IO) {
try {
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
val sharedMediaDir = File(context.cacheDir, "shared_media").also { it.mkdirs() }
sweepOldShareFiles(sharedMediaDir)
val sessionDir = File(sharedMediaDir, UUID.randomUUID().toString()).also { it.mkdirs() }
val targetFile = File(sessionDir, finalName)
var success = false
try {
targetFile.outputStream().use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
}
success = true
} finally {
if (!success) {
targetFile.delete()
sessionDir.delete()
}
}
val contentUri = fileUriProvider(context, targetFile)
val normalizedMime = normalizeFrameworkMime(streamInfo.mimeType)
intentFactory(contentUri, normalizedMime)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,240 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.net.Uri
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.util.Base64
object MediaStreamResolver {
data class StreamInfo(
val mimeType: String,
val extension: String,
val length: Long?,
)
/**
* Streams characters directly from a [CharSequence] without allocating substring copies.
* Skips whitespace to tolerate formatted Base64 data URLs.
*/
internal class CharSequenceInputStream(
private val value: CharSequence,
startIndex: Int,
) : InputStream() {
private var index = startIndex
override fun read(): Int {
while (index < value.length) {
val c = value[index++]
if (!c.isWhitespace()) {
return c.code and 0xFF
}
}
return -1
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
if (len == 0) return 0
var bytesRead = 0
while (bytesRead < len) {
val c = read()
if (c == -1) {
return if (bytesRead == 0) -1 else bytesRead
}
b[off + bytesRead] = c.toByte()
bytesRead++
}
return bytesRead
}
}
/**
* Resolves the media source identified by [uriString] and streams its content
* into [consumer]. Guarantees that all underlying streams and HTTP response bodies
* are strictly closed via [use], and that no whole-media buffering occurs.
*/
suspend fun <T> useStream(
context: Context,
uriString: String,
fallbackMime: String = "application/octet-stream",
consumer: suspend (InputStream, StreamInfo) -> T,
): T =
withContext(Dispatchers.IO) {
try {
when {
uriString.startsWith("data:", ignoreCase = true) -> {
openDataUrlStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("content://", ignoreCase = true) -> {
openContentStream(context, uriString, fallbackMime, consumer)
}
uriString.startsWith("file:", ignoreCase = true) -> {
openFileUriStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("/") -> {
openLocalFileStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("https://", ignoreCase = true) ||
uriString.startsWith("http://", ignoreCase = true) -> {
openHttpStream(uriString, fallbackMime, consumer)
}
else -> {
throw IOException("Unsupported media source: $uriString")
}
}
} catch (e: CancellationException) {
throw e
}
}
private suspend fun <T> openDataUrlStream(
dataUrl: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val comma = dataUrl.indexOf(',')
if (comma < 0) throw IOException("Malformed data URL")
val meta = dataUrl.substring(0, comma)
val mime =
meta
.substringAfter("data:", fallbackMime)
.substringBefore(";")
.ifBlank { fallbackMime }
if (!meta.contains(";base64", ignoreCase = true)) {
throw IOException("Only base64 data URLs are supported")
}
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
val decodingStream = Base64.getDecoder().wrap(rawStream)
return decodingStream.use { stream ->
consumer(
stream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = null,
),
)
}
}
private suspend fun <T> openContentStream(
context: Context,
uriString: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val uri = Uri.parse(uriString)
val resolvedMime = runCatching { context.contentResolver.getType(uri) }.getOrNull()
val mime = resolvedMime?.takeIf { it.isNotBlank() } ?: fallbackMime
val length =
runCatching {
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
pfd.statSize.takeIf { it >= 0 }
}
}.getOrNull()
val stream =
runCatching { context.contentResolver.openInputStream(uri) }.getOrNull()
?: throw IOException("Could not open content URI: $uriString")
return stream.use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = length,
),
)
}
}
private suspend fun <T> openFileUriStream(
uriString: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
return file.inputStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = file.length().takeIf { it >= 0 },
),
)
}
}
private suspend fun <T> openLocalFileStream(
filePath: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val file = File(filePath)
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: $filePath")
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
return file.inputStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = file.length().takeIf { it >= 0 },
),
)
}
}
private suspend fun <T> openHttpStream(
url: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val request = Request.Builder().url(url).build()
val response = MediaDataSourceProvider.mediaOkHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
response.close()
throw IOException("Download failed (HTTP ${response.code})")
}
val body = response.body
return response.use { resp ->
val headerMime = resp.header("Content-Type")?.substringBefore(";")?.trim()
val mime =
when {
!headerMime.isNullOrBlank() && headerMime != "application/octet-stream" -> headerMime
fallbackMime.isNotBlank() && fallbackMime != "application/octet-stream" -> fallbackMime
else -> mediaMimeForPath(url)
}
val length = body.contentLength().takeIf { it >= 0 }
body.byteStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = length,
),
)
}
}
}
}
@@ -4,12 +4,10 @@ import java.net.URLDecoder
import java.nio.charset.StandardCharsets
/**
* File-kind classification for gateway-hosted files, mirroring the desktop app's
* File-kind classification for media and gateway-hosted files, mirroring the desktop app's
* `MEDIA_BY_EXT` table (`apps/desktop/src/lib/media.ts`). Lets UI code decide
* how to present a downloaded file — inline image, audio player, video player,
* how to present a file — inline image, audio player, video player,
* or a generic file card — without re-deriving the mapping at every call site.
*
* Extend the table (not the call sites) when new extensions are needed.
*/
enum class MediaKind {
IMAGE,
@@ -20,6 +18,8 @@ enum class MediaKind {
private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
mapOf(
"3gp" to (MediaKind.VIDEO to "video/3gpp"),
"aac" to (MediaKind.AUDIO to "audio/aac"),
"avi" to (MediaKind.VIDEO to "video/x-msvideo"),
"bmp" to (MediaKind.IMAGE to "image/bmp"),
"csv" to (MediaKind.FILE to "text/csv"),
@@ -42,50 +42,189 @@ private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
"webp" to (MediaKind.IMAGE to "image/webp"),
)
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
fun mediaKindForPath(path: String): MediaKind {
val ext =
path
.split('?', limit = 2)[0]
.split('.')
.lastOrNull()
/**
* Extracts the file extension from a clean path or URL, ignoring query parameters and fragments.
* Never searches queries for extensions to avoid false positives.
*/
fun extractFileExtension(pathOrUrl: String): String {
val clean =
pathOrUrl
.substringBefore('#')
.substringBefore('?')
.trim()
val lastSegment = clean.substringAfterLast('/').substringAfterLast('\\')
val ext = lastSegment.substringAfterLast('.', "")
return if (ext == lastSegment) "" else ext.lowercase()
}
/**
* Extracts the file extension for a media source, handling gateway URLs where the path
* is encoded inside the `path=` query parameter.
*/
fun extractExtensionFromMediaSource(source: String): String {
val sourcePath = source.substringBefore('?').substringBefore('#')
val gatewayPath =
if (sourcePath.endsWith("/api/files/download") || sourcePath.endsWith("/api/files/stream")) {
gatewayPathFromUrl(source)
} else {
null
}
gatewayPath?.let { gwPath ->
val ext = extractFileExtension(gwPath)
if (ext.isNotBlank()) return ext
}
return extractFileExtension(source)
}
/**
* Robust classification of a media item by concrete MIME type first, then by file extension
* from [name] or [uri]. Concrete MIME types take precedence (e.g. "audio/mp4" is recognized as AUDIO).
*/
fun classifyMedia(
mimeType: String? = null,
name: String? = null,
uri: String? = null,
): MediaKind {
val cleanMime =
mimeType
?.substringBefore(';')
?.trim()
?.lowercase()
.orEmpty()
return MEDIA_BY_EXT[ext]?.first ?: MediaKind.FILE
if (cleanMime.isNotBlank() && cleanMime != "application/octet-stream" && cleanMime != "*/*") {
if (cleanMime.startsWith("audio/") || cleanMime == "application/ogg" || cleanMime == "application/x-flac") {
return MediaKind.AUDIO
}
if (cleanMime.startsWith("video/")) {
return MediaKind.VIDEO
}
if (cleanMime.startsWith("image/")) {
return MediaKind.IMAGE
}
return MediaKind.FILE
}
if (uri?.startsWith("data:", ignoreCase = true) == true) {
return classifyMedia(mimeType = uri.substringAfter(':').substringBefore(';').substringBefore(','))
}
val extFromName = name?.let { extractFileExtension(it) }.orEmpty()
if (extFromName.isNotBlank()) {
MEDIA_BY_EXT[extFromName]?.first?.let { return it }
}
val extFromUri = uri?.let { extractExtensionFromMediaSource(it) }.orEmpty()
if (extFromUri.isNotBlank()) {
MEDIA_BY_EXT[extFromUri]?.first?.let { return it }
}
return MediaKind.FILE
}
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
fun mediaKindForPath(path: String): MediaKind = classifyMedia(uri = path)
/** Best-guess MIME type for a path/URL by extension. Falls back to octet-stream. */
fun mediaMimeForPath(path: String): String {
val ext =
path
.split('?', limit = 2)[0]
.split('.')
.lastOrNull()
?.lowercase()
.orEmpty()
val ext = extractExtensionFromMediaSource(path)
return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream"
}
/** Decoded gateway file path carried in a `/api/files/download` URL. */
/**
* Normalizes a MIME type string for Android framework boundaries (MediaStore, Intent, SAF),
* stripping parameters such as codecs (e.g. `audio/ogg; codecs=opus` -> `audio/ogg`).
*/
fun normalizeFrameworkMime(mime: String?): String =
mime
?.substringBefore(';')
?.trim()
?.takeIf { it.isNotBlank() }
?: "application/octet-stream"
/** Returns the canonical file extension for a given MIME type. */
fun extensionForMime(mime: String): String {
val clean = mime.substringBefore(';').trim().lowercase()
for ((ext, pair) in MEDIA_BY_EXT) {
if (pair.second
.substringBefore(';')
.trim()
.lowercase() == clean
) {
return ext
}
}
return when (clean) {
"image/jpeg", "image/jpg" -> {
"jpg"
}
"video/mp4" -> {
"mp4"
}
"video/x-matroska" -> {
"mkv"
}
"video/quicktime" -> {
"mov"
}
"audio/mpeg", "audio/mp3" -> {
"mp3"
}
"audio/mp4", "audio/x-m4a" -> {
"m4a"
}
"audio/wav", "audio/x-wav", "audio/wave" -> {
"wav"
}
"audio/flac", "application/x-flac" -> {
"flac"
}
"audio/ogg", "application/ogg" -> {
"ogg"
}
"audio/aac" -> {
"aac"
}
else -> {
when {
clean.startsWith("video/") -> "mp4"
clean.startsWith("audio/") -> "mp3"
clean.startsWith("image/") -> "img"
else -> "bin"
}
}
}
}
/** Decoded gateway file path carried in a `/api/files/download` or `/api/files/stream` URL. */
fun gatewayPathFromUrl(url: String): String? =
Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let {
runCatching { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }.getOrDefault(it)
}
/** Trailing filename from a path or URL.
*
/**
* Trailing filename from a path or URL.
* Handles both a bare gateway path (`/tmp/report.pdf`) and a full
* `/api/files/download?path=<enc>` URL, where the real filename lives in the
* percent-encoded `path=` query parameter. */
* percent-encoded `path=` query parameter.
*/
fun mediaNameFromPath(path: String): String {
gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it }
val cleanPath = path.split('?', limit = 2)[0]
val cleanPath = path.substringBefore('#').substringBefore('?')
return runCatching {
java.net
.URI(cleanPath)
.path
.split('/')
.lastOrNull { it.isNotBlank() }
?.split('/')
?.lastOrNull { it.isNotBlank() }
}.getOrNull()
?: cleanPath.split('/', '\\').lastOrNull { it.isNotBlank() }
?: path
@@ -0,0 +1,211 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.classifyMedia
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
/**
* Shared inline media preview card for audio and video in chat bubbles and markdown blocks.
* Renders a compact file-style card for audio and a 16:9 preview for video.
*/
@Composable
fun InlineMediaPlayer(
uri: String,
modifier: Modifier = Modifier,
title: String? = null,
mimeType: String? = null,
onFullScreenClick: () -> Unit = {},
) {
val mediaKind =
remember(uri, mimeType, title) {
classifyMedia(mimeType = mimeType, name = title, uri = uri)
}
val displayName =
remember(uri, title) {
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(uri)
}
if (mediaKind == MediaKind.AUDIO) {
InlineAudioPreview(
name = displayName,
modifier = modifier,
onClick = onFullScreenClick,
)
} else {
InlineVideoPreview(
modifier = modifier,
onClick = onFullScreenClick,
)
}
}
@Composable
private fun InlineAudioPreview(
name: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
modifier
.fillMaxWidth()
.clickable { onClick() }
.testTag("inline_audio_player"),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier =
Modifier
.size(42.dp)
.testTag("audio_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Audiotrack,
contentDescription = stringResource(R.string.media_player_audio),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(R.string.media_player_audio).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_play_audio),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
@Composable
private fun InlineVideoPreview(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.clickable { onClick() }
.testTag("inline_video_player"),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(56.dp)
.testTag("video_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_play_video),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
}
}
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
shape = RoundedCornerShape(16.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_video),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
Text(
text = " " + stringResource(R.string.media_player_video).uppercase(),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 10.sp,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@@ -1,34 +1,10 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
/**
* Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722).
* Shows a video preview card with a play button badge that launches the full-screen video viewer.
* Backwards-compatible wrapper delegating to [InlineMediaPlayer].
*/
@Composable
fun InlineVideoPlayer(
@@ -36,65 +12,11 @@ fun InlineVideoPlayer(
modifier: Modifier = Modifier,
onFullScreenClick: () -> Unit = {},
) {
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.clickable { onFullScreenClick() }
.testTag("inline_video_player"),
) {
// Play icon badge in center
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(56.dp)
.testTag("video_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
}
}
// VIDEO label badge in bottom-right corner
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
shape = RoundedCornerShape(16.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Video",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
Text(
text = " VIDEO",
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 10.sp,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
InlineMediaPlayer(
uri = videoUri,
modifier = modifier,
onFullScreenClick = onFullScreenClick,
)
}
/** Formats milliseconds into `M:SS` or `H:MM:SS` */
@@ -0,0 +1,714 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableLongStateOf
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
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MediaExportHelper
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.MediaSaveStrategy
import com.m57.hermescontrol.ui.chat.acceptedSaveDestination
import com.m57.hermescontrol.ui.chat.classifyMedia
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
import com.m57.hermescontrol.ui.chat.mediaSaveStrategy
import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer.
*/
@OptIn(UnstableApi::class)
@Composable
fun MediaViewerDialog(
mediaUri: String,
onDismissRequest: () -> Unit,
title: String? = null,
mimeType: String? = null,
) {
key(mediaUri, mimeType) {
MediaViewerContent(mediaUri, onDismissRequest, title, mimeType)
}
}
@OptIn(UnstableApi::class)
@Composable
private fun MediaViewerContent(
mediaUri: String,
onDismissRequest: () -> Unit,
title: String?,
mimeType: String?,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val mediaKind =
remember(mediaUri, mimeType, title) {
classifyMedia(mimeType = mimeType, name = title, uri = mediaUri)
}
val isAudio = mediaKind == MediaKind.AUDIO
val displayName =
remember(mediaUri, title) {
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri)
}
var isPlaying by remember { mutableStateOf(false) }
var isBuffering by remember { mutableStateOf(true) }
var isEnded by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var isDragging by remember { mutableStateOf(false) }
var dragPositionMs by remember { mutableLongStateOf(0L) }
var showControls by remember { mutableStateOf(true) }
var isBusy by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
val saveFailed = stringResource(R.string.media_player_save_failed)
val shareFailed = stringResource(R.string.media_player_share_failed)
val shareTitle = stringResource(R.string.media_player_share_title)
val player =
remember(context, mediaUri) {
val dataSourceFactory = MediaDataSourceProvider.createDataSourceFactory(context)
val mediaSourceFactory = DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory)
val audioAttributes =
AudioAttributes
.Builder()
.setContentType(if (isAudio) C.AUDIO_CONTENT_TYPE_MUSIC else C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
ExoPlayer
.Builder(context)
.setRenderersFactory(
// Emulator guard: the goldfish "hardware" codecs crash on queueInputBuffer
// ("Decoder failed: c2.goldfish.h264.decoder"); deprioritize them so the
// reliable software codecs are selected instead. Real devices never ship
// goldfish codecs, so this has no effect on phones.
androidx.media3.exoplayer
.DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
androidx.media3.exoplayer.mediacodec.MediaCodecSelector.DEFAULT
.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
.sortedBy { it.name.contains("goldfish", ignoreCase = true) }
},
).setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(mediaSourceFactory)
.build()
.apply {
setMediaItem(MediaItem.fromUri(Uri.parse(mediaUri)))
}
}
DisposableEffect(player) {
val listener =
object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_BUFFERING -> {
isBuffering = true
}
Player.STATE_READY -> {
isBuffering = false
hasError = false
durationMs = player.duration.coerceAtLeast(0L)
}
Player.STATE_ENDED -> {
isBuffering = false
isPlaying = false
isEnded = true
currentPositionMs = durationMs
}
Player.STATE_IDLE -> {
isBuffering = false
}
}
}
override fun onIsPlayingChanged(isPlayingNow: Boolean) {
isPlaying = isPlayingNow
if (isPlayingNow) {
isEnded = false
}
}
override fun onPlayerError(error: PlaybackException) {
isBuffering = false
hasError = true
}
}
player.addListener(listener)
player.prepare()
player.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
onDispose {
player.removeListener(listener)
player.release()
}
}
DisposableEffect(lifecycleOwner, player) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
player.pause()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(player, isPlaying, isDragging) {
while (isPlaying && !isDragging) {
currentPositionMs = player.currentPosition.coerceAtLeast(0L)
if (durationMs <= 0L && player.duration > 0L) {
durationMs = player.duration
}
delay(200)
}
}
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
prepareForBackground = { NotificationHelper.start(context) },
cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
launch = launch,
)
}
val saveDocumentLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
try {
ExternalActivityLifecycleGuard.externalActivityReturned()
val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
if (destination != null) {
scope.launch {
try {
val msg =
MediaExportHelper.saveMediaToUri(
context = context,
sourceUri = mediaUri,
targetUri = destination,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
)
withContext(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
} else {
isBusy = false
}
} catch (e: Throwable) {
isBusy = false
if (e is CancellationException) throw e
}
}
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
when (mediaSaveStrategy()) {
MediaSaveStrategy.MEDIA_STORE -> {
scope.launch {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val msg =
MediaExportHelper.saveMediaToDownloads(
context = context,
uri = mediaUri,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
displayName = displayName,
)
withContext(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
}
MediaSaveStrategy.CREATE_DOCUMENT -> {
val suggestedName =
MediaExportHelper.resolveDisplayName(
displayName = displayName,
mimeType = mimeType,
uri = mediaUri,
)
val rawMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName)
val normalizedMime = normalizeFrameworkMime(rawMime)
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = normalizedMime
putExtra(Intent.EXTRA_TITLE, suggestedName)
}
try {
launchExternalActivity {
saveDocumentLauncher.launch(intent)
}
} catch (_: Exception) {
isBusy = false
scope.launch(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch {
try {
val intent =
MediaExportHelper.shareMedia(
context = context,
uri = mediaUri,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
displayName = displayName,
)
withContext(Dispatchers.Main) {
if (intent != null) {
val chooser = Intent.createChooser(intent, shareTitle)
try {
launchExternalActivity {
context.startActivity(chooser)
}
} catch (_: Exception) {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
}
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.testTag("media_viewer_dialog"),
) {
if (!isAudio) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier.fillMaxSize(),
)
// Keep the background gesture out of the controls' ancestor chain.
Box(
Modifier.fillMaxSize().clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (isPlaying && !isDragging) showControls = !showControls
},
)
} else if (!isBuffering && !hasError) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(bottom = 180.dp, start = 24.dp, end = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(100.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Audiotrack,
contentDescription = stringResource(R.string.media_player_audio),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}
Spacer(modifier = Modifier.size(16.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
// Top Bar Controls
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier =
Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.testTag("media_close_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.media_player_close),
tint = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = displayName,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
)
IconButton(
onClick = onSave,
enabled = !isBusy,
modifier = Modifier.testTag("media_save_button"),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.media_player_save),
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(
onClick = onShare,
enabled = !isBusy,
modifier = Modifier.testTag("media_share_button"),
) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.media_player_share),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
// Center play/pause/loading/error
if (isBuffering) {
CircularProgressIndicator(
modifier =
Modifier
.align(Alignment.Center)
.size(48.dp)
.testTag("media_buffering_indicator"),
color = MaterialTheme.colorScheme.primary,
)
} else if (hasError) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.media_player_load_failed, ""),
style = MaterialTheme.typography.bodyMedium,
color = statusColors.error,
modifier = Modifier.padding(bottom = 12.dp),
)
IconButton(
onClick = {
hasError = false
isBuffering = true
player.prepare()
player.play()
},
modifier = Modifier.size(56.dp).testTag("media_retry_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.media_player_retry),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp),
)
}
}
} else {
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
modifier =
Modifier
.size(64.dp)
.testTag("media_play_pause_button")
.clickable {
if (isEnded) {
player.seekTo(0)
player.play()
isEnded = false
} else if (isPlaying) {
player.pause()
} else {
player.play()
}
},
) {
Box(contentAlignment = Alignment.Center) {
val icon =
when {
isEnded -> Icons.Filled.Replay
isPlaying -> Icons.Filled.Pause
else -> Icons.Filled.PlayArrow
}
val desc =
when {
isEnded -> stringResource(R.string.media_player_replay)
isPlaying -> stringResource(R.string.media_player_pause)
else -> stringResource(R.string.media_player_play)
}
Icon(
imageVector = icon,
contentDescription = desc,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(40.dp),
)
}
}
}
}
// Bottom Bar Controls
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.fillMaxWidth(),
) {
val displayPosition = if (isDragging) dragPositionMs else currentPositionMs
Column(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatMediaDuration(displayPosition),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(48.dp),
)
Slider(
value = displayPosition.coerceIn(0L, durationMs.coerceAtLeast(1L)).toFloat(),
onValueChange = {
isDragging = true
dragPositionMs = it.toLong()
},
onValueChangeFinished = {
player.seekTo(dragPositionMs)
currentPositionMs = dragPositionMs
isEnded = false
isDragging = false
},
enabled = durationMs > 0 && !hasError && player.isCurrentMediaItemSeekable,
valueRange = 0f..durationMs.coerceAtLeast(1L).toFloat(),
modifier = Modifier.weight(1f).testTag("media_seek_slider"),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
),
)
Text(
text = formatMediaDuration(durationMs),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(48.dp),
)
}
}
}
}
}
}
@@ -1,382 +1,17 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import android.widget.Toast
import android.widget.VideoView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ImageBytesResolver
import com.m57.hermescontrol.ui.chat.MediaImageStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Fullscreen Video Viewer modal dialog with Save & Share (issue #722).
* Backwards-compatible wrapper delegating to [MediaViewerDialog].
*/
@Composable
fun VideoViewerDialog(
videoUri: String,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isPlaying by remember { mutableStateOf(true) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var videoViewRef by remember { mutableStateOf<VideoView?>(null) }
var showControls by remember { mutableStateOf(true) }
var isBusy by remember { mutableStateOf(false) }
val savedMsg = stringResource(R.string.image_viewer_saved)
val saveFailedMsg = stringResource(R.string.image_viewer_save_failed)
val loadFailedFmt = stringResource(R.string.image_viewer_load_failed)
val shareTitle = stringResource(R.string.image_viewer_share_title)
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val result =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
val uri =
MediaImageStore.saveToDownloads(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
if (uri != null) savedMsg else saveFailedMsg
}
is ImageBytesResolver.Result.Error -> {
String.format(loadFailedFmt, resolved.message)
}
}
withContext(Dispatchers.Main) {
isBusy = false
Toast.makeText(context, result, Toast.LENGTH_SHORT).show()
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val intent =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
MediaImageStore.buildShareIntent(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
}
is ImageBytesResolver.Result.Error -> {
null
}
}
withContext(Dispatchers.Main) {
isBusy = false
if (intent != null) {
context.startActivity(android.content.Intent.createChooser(intent, shareTitle))
} else {
Toast.makeText(context, saveFailedMsg, Toast.LENGTH_SHORT).show()
}
}
}
}
}
LaunchedEffect(isPlaying) {
while (isPlaying) {
videoViewRef?.let { view ->
if (view.isPlaying) {
currentPositionMs = view.currentPosition.toLong()
if (durationMs <= 0 && view.duration > 0) {
durationMs = view.duration.toLong()
}
}
}
delay(250)
}
}
DisposableEffect(videoUri) {
onDispose {
videoViewRef?.stopPlayback()
}
}
Dialog(
MediaViewerDialog(
mediaUri = videoUri,
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim)
.clickable { showControls = !showControls }
.testTag("video_viewer_dialog"),
) {
AndroidView(
factory = { ctx ->
VideoView(ctx).apply {
val uri = Uri.parse(videoUri)
val headers = mutableMapOf<String, String>()
val cookieVal =
com.m57.hermescontrol.data.remote.CookieManager
.getSessionCookie()
if (!cookieVal.isNullOrBlank()) {
headers["Cookie"] = "hermes_session_at=$cookieVal"
}
val token =
com.m57.hermescontrol.data.local.AuthManager
.getToken()
if (!token.isNullOrBlank()) {
headers["Authorization"] = "Bearer $token"
}
setVideoURI(uri, headers)
setOnPreparedListener { mp ->
durationMs = mp.duration.toLong()
start()
isPlaying = true
}
setOnCompletionListener {
isPlaying = false
currentPositionMs = durationMs
}
}
},
update = { view ->
videoViewRef = view
},
modifier = Modifier.fillMaxSize(),
)
// Center overlay play button when paused
if (!isPlaying) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(64.dp)
.clickable {
videoViewRef?.let { view ->
view.start()
isPlaying = true
}
},
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(40.dp),
)
}
}
}
// Top Bar with Back/Close, Save, Share
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.TopCenter),
) {
Surface(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.70f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.testTag("close_video_dialog_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.weight(1f))
if (isBusy) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(horizontal = 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
} else {
IconButton(onClick = onSave, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
IconButton(onClick = onShare, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = "Share",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}
}
// Floating Bottom Controls Bar
AnimatedVisibility(
visible = showControls || !isPlaying,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.80f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
videoViewRef?.let { view ->
if (isPlaying) {
view.pause()
isPlaying = false
} else {
view.start()
isPlaying = true
}
}
},
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatMediaDuration(currentPositionMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
Slider(
value = if (durationMs > 0) currentPositionMs.toFloat() / durationMs.toFloat() else 0f,
onValueChange = { fraction ->
val targetMs = (fraction * durationMs).toLong()
currentPositionMs = targetMs
videoViewRef?.seekTo(targetMs.toInt())
},
modifier =
Modifier
.weight(1f)
.padding(horizontal = 8.dp),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.inverseOnSurface,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.3f),
),
)
Text(
text = formatMediaDuration(durationMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
}
}
}
}
}
)
}
+20
View File
@@ -1478,6 +1478,26 @@
<string name="session_integrations_retry">إعادة المحاولة</string>
<string name="session_integrations_close">إغلاق</string>
<string name="session_integrations_menu_item">تكاملات الجلسة</string>
<!-- Media Player -->
<string name="media_player_play">تشغيل</string>
<string name="media_player_pause">إيقاف مؤقت</string>
<string name="media_player_replay">إعادة التشغيل</string>
<string name="media_player_retry">إعادة المحاولة</string>
<string name="media_player_audio">صوت</string>
<string name="media_player_video">فيديو</string>
<string name="media_player_close">إغلاق</string>
<string name="media_player_save">حفظ في الجهاز</string>
<string name="media_player_share">مشاركة</string>
<string name="media_player_share_title">مشاركة الوسائط</string>
<string name="media_player_saved">تم حفظ الوسائط</string>
<string name="media_player_save_failed">تعذر حفظ الوسائط</string>
<string name="media_player_load_failed">تعذر تحميل الوسائط: %1$s</string>
<string name="media_player_share_failed">تعذر مشاركة الوسائط</string>
<string name="media_player_buffering">جار التحميل…</string>
<string name="media_player_seek">تقديم/ترجيع</string>
<string name="media_player_play_audio">تشغيل الصوت</string>
<string name="media_player_play_video">تشغيل الفيديو</string>
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
</resources>
+20
View File
@@ -1162,6 +1162,26 @@
<string name="session_integrations_retry">다시 시도</string>
<string name="session_integrations_close">닫기</string>
<string name="session_integrations_menu_item">세션 연동</string>
<!-- Media Player -->
<string name="media_player_play">재생</string>
<string name="media_player_pause">일시중지</string>
<string name="media_player_replay">다시 재생</string>
<string name="media_player_retry">다시 시도</string>
<string name="media_player_audio">오디오</string>
<string name="media_player_video">비디오</string>
<string name="media_player_close">닫기</string>
<string name="media_player_save">기기에 저장</string>
<string name="media_player_share">공유</string>
<string name="media_player_share_title">미디어 공유</string>
<string name="media_player_saved">미디어가 저장되었습니다</string>
<string name="media_player_save_failed">미디어를 저장할 수 없습니다</string>
<string name="media_player_load_failed">미디어를 불러올 수 없습니다: %1$s</string>
<string name="media_player_share_failed">미디어를 공유할 수 없습니다</string>
<string name="media_player_buffering">로딩 중…</string>
<string name="media_player_seek">탐색</string>
<string name="media_player_play_audio">오디오 재생</string>
<string name="media_player_play_video">비디오 재생</string>
<string name="chat_session_preparing">세션 준비 중…</string>
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
</resources>
+20
View File
@@ -1431,6 +1431,26 @@
<string name="session_integrations_retry">重试</string>
<string name="session_integrations_close">关闭</string>
<string name="session_integrations_menu_item">会话集成</string>
<!-- Media Player -->
<string name="media_player_play">播放</string>
<string name="media_player_pause">暂停</string>
<string name="media_player_replay">重播</string>
<string name="media_player_retry">重试</string>
<string name="media_player_audio">音频</string>
<string name="media_player_video">视频</string>
<string name="media_player_close">关闭</string>
<string name="media_player_save">保存到设备</string>
<string name="media_player_share">分享</string>
<string name="media_player_share_title">分享媒体</string>
<string name="media_player_saved">媒体已保存</string>
<string name="media_player_save_failed">无法保存媒体</string>
<string name="media_player_load_failed">无法加载媒体: %1$s</string>
<string name="media_player_share_failed">无法分享媒体</string>
<string name="media_player_buffering">正在加载…</string>
<string name="media_player_seek">跳转进度</string>
<string name="media_player_play_audio">播放音频</string>
<string name="media_player_play_video">播放视频</string>
<string name="chat_session_preparing">正在准备会话…</string>
<string name="chat_session_not_ready">会话未就绪,请重试以继续</string>
</resources>
+20
View File
@@ -1645,6 +1645,26 @@
<string name="session_integrations_retry">Retry</string>
<string name="session_integrations_close">Close</string>
<string name="session_integrations_menu_item">Session Integrations</string>
<!-- Media Player -->
<string name="media_player_play">Play</string>
<string name="media_player_pause">Pause</string>
<string name="media_player_replay">Replay</string>
<string name="media_player_retry">Retry</string>
<string name="media_player_audio">Audio</string>
<string name="media_player_video">Video</string>
<string name="media_player_close">Close</string>
<string name="media_player_save">Save to device</string>
<string name="media_player_share">Share</string>
<string name="media_player_share_title">Share media</string>
<string name="media_player_saved">Media saved</string>
<string name="media_player_save_failed">Could not save media</string>
<string name="media_player_load_failed">Could not load media: %1$s</string>
<string name="media_player_share_failed">Could not share media</string>
<string name="media_player_buffering">Loading…</string>
<string name="media_player_seek">Seek</string>
<string name="media_player_play_audio">Play audio</string>
<string name="media_player_play_video">Play video</string>
<string name="chat_session_preparing">Preparing session…</string>
<string name="chat_session_not_ready">Session not ready — retry to continue</string>
</resources>
@@ -0,0 +1,599 @@
package com.m57.hermescontrol.ui.chat
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import java.util.Base64
class MediaExportStreamingTest {
@get:Rule
val tempFolder = TemporaryFolder()
private val context = mockk<Context>(relaxed = true)
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockWebServer = MockWebServer()
mockWebServer.start()
MediaDataSourceProvider.testClient =
okhttp3.OkHttpClient
.Builder()
.addInterceptor(
MediaDataSourceProvider.SameOriginAuthInterceptor(
baseUrlProvider = { mockWebServer.url("/").toString() },
tokenProvider = { "test-token" },
isGatedModeProvider = { false },
),
).build()
}
@After
fun tearDown() {
MediaDataSourceProvider.testClient = null
mockWebServer.shutdown()
}
// ── 1. API Level Strategy Tests ─────────────────────────────────────────
@Test
fun `mediaSaveStrategy resolves correctly for API 26-28 vs 29+`() {
assertEquals(MediaSaveStrategy.CREATE_DOCUMENT, mediaSaveStrategy(26))
assertEquals(MediaSaveStrategy.CREATE_DOCUMENT, mediaSaveStrategy(27))
assertEquals(MediaSaveStrategy.CREATE_DOCUMENT, mediaSaveStrategy(28))
assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(29))
assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(30))
assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(34))
assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(37))
}
// ── 2. MIME Normalization Tests ─────────────────────────────────────────
@Test
fun `normalizeFrameworkMime strips parameters and handles fallbacks`() {
assertEquals("audio/ogg", normalizeFrameworkMime("audio/ogg; codecs=opus"))
assertEquals("video/mp4", normalizeFrameworkMime("video/mp4"))
assertEquals("audio/mpeg", normalizeFrameworkMime(" audio/mpeg "))
assertEquals("application/octet-stream", normalizeFrameworkMime(null))
assertEquals("application/octet-stream", normalizeFrameworkMime(""))
assertEquals("application/octet-stream", normalizeFrameworkMime(" "))
}
// ── 3. Filename Sanitization Tests ──────────────────────────────────────
@Test
fun `resolveDisplayName cleans traversal and respects extensions`() {
assertEquals(
"video.mp4",
MediaExportHelper.resolveDisplayName("../../evil/video.mp4", "video/mp4", "https://x/"),
)
assertEquals(
"recording.wav",
MediaExportHelper.resolveDisplayName("C:\\Users\\x\\recording.wav", "audio/wav", "https://x/"),
)
assertEquals("clip.mp4", MediaExportHelper.resolveDisplayName("clip", "video/mp4", "https://x/"))
assertEquals("recording.mp3", MediaExportHelper.resolveDisplayName("recording", "audio/mpeg", "https://x/"))
assertEquals("song.m4a", MediaExportHelper.resolveDisplayName("song.m4a", "audio/mp4", "https://x/"))
assertEquals("hermes-media.wav", MediaExportHelper.resolveDisplayName("", "audio/wav", ""))
}
// ── 4. Local File & Directory Source Validation ─────────────────────────
@Test
fun `useStream streams local file without whole-media buffering`() =
runTest {
val file = tempFolder.newFile("sample.wav")
file.writeText("RIFF....WAVEfmt ")
var readString = ""
MediaStreamResolver.useStream(context, file.absolutePath, "audio/wav") { stream, info ->
assertEquals("audio/wav", info.mimeType)
assertEquals("wav", info.extension)
assertEquals(file.length(), info.length)
readString = stream.bufferedReader().readText()
}
assertEquals("RIFF....WAVEfmt ", readString)
}
@Test
fun `useStream rejects directories cleanly for absolute and file URI sources`() =
runTest {
val dir = tempFolder.newFolder("test-dir")
try {
MediaStreamResolver.useStream(context, dir.absolutePath, "audio/wav") { _, _ -> }
fail("Expected FileNotFoundException for directory path")
} catch (e: FileNotFoundException) {
assertTrue(e.message?.contains("Not a valid file") == true)
}
try {
MediaStreamResolver.useStream(context, dir.toURI().toString(), "audio/wav") { _, _ -> }
fail("Expected FileNotFoundException for directory file URI")
} catch (e: FileNotFoundException) {
assertTrue(e.message?.contains("Not a valid file") == true)
}
}
// ── 5. Data URL Streaming Tests ─────────────────────────────────────────
@Test
fun `useStream decodes data URL on the fly without substring duplication`() =
runTest {
val expected = "streaming-test-content-audio-without-substring-allocations"
val encoded = Base64.getEncoder().encodeToString(expected.toByteArray())
val dataUrl = "data:audio/mp3;base64, ${encoded.take(6)} \r\n ${encoded.drop(6)} "
var result = ""
MediaStreamResolver.useStream(context, dataUrl, "audio/mp3") { stream, info ->
assertEquals("audio/mp3", info.mimeType)
assertEquals("mp3", info.extension)
assertNull(info.length)
result = stream.bufferedReader().readText()
}
assertEquals(expected, result)
}
// ── 6. MockWebServer HTTP Streaming Tests ───────────────────────────────
@Test
fun `useStream streams large HTTP response with verified digest and length`() =
runTest {
val chunkSize = 64 * 1024
val chunkCount = 4 // 256 KB payload
val expectedDigest = MessageDigest.getInstance("SHA-256")
val buffer = okio.Buffer()
val chunkPattern = ByteArray(chunkSize) { (it % 128).toByte() }
repeat(chunkCount) {
buffer.write(chunkPattern)
expectedDigest.update(chunkPattern)
}
val expectedSha = expectedDigest.digest().joinToString("") { "%02x".format(it) }
mockWebServer.enqueue(
MockResponse()
.setHeader("Content-Type", "video/mp4; codecs=avc1")
.setBody(buffer),
)
val url = mockWebServer.url("/media/video.mp4").toString()
val actualDigest = MessageDigest.getInstance("SHA-256")
val copyBuffer = ByteArray(16 * 1024)
MediaStreamResolver.useStream(context, url, "video/mp4") { stream, info ->
assertEquals("video/mp4", info.mimeType)
assertEquals("mp4", info.extension)
assertEquals((chunkSize * chunkCount).toLong(), info.length)
while (true) {
val read = stream.read(copyBuffer)
if (read < 0) break
actualDigest.update(copyBuffer, 0, read)
}
}
val actualSha = actualDigest.digest().joinToString("") { "%02x".format(it) }
assertEquals(expectedSha, actualSha)
}
@Test
fun `useStream handles chunked HTTP transfer with null length`() =
runTest {
val content = "chunked-audio-data-payload"
mockWebServer.enqueue(
MockResponse()
.setHeader("Content-Type", "audio/mpeg")
.setChunkedBody(content, 4),
)
val url = mockWebServer.url("/media/stream").toString()
var result = ""
MediaStreamResolver.useStream(context, url, "audio/mpeg") { stream, info ->
assertEquals("audio/mpeg", info.mimeType)
assertNull(info.length)
result = stream.bufferedReader().readText()
}
assertEquals(content, result)
}
@Test
fun `useStream falls back from application octet-stream to provided fallback or URL inference`() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setHeader("Content-Type", "application/octet-stream")
.setBody("payload"),
)
val url1 = mockWebServer.url("/file/track").toString()
MediaStreamResolver.useStream(context, url1, fallbackMime = "audio/ogg") { _, info ->
assertEquals("audio/ogg", info.mimeType)
assertEquals("ogg", info.extension)
}
mockWebServer.enqueue(
MockResponse()
.setHeader("Content-Type", "application/octet-stream")
.setBody("payload"),
)
val url2 = mockWebServer.url("/downloads/song.mp3").toString()
MediaStreamResolver.useStream(context, url2, fallbackMime = "application/octet-stream") { _, info ->
assertEquals("audio/mpeg", info.mimeType)
assertEquals("mp3", info.extension)
}
}
@Test
fun `useStream throws IOException and closes resources on non-success HTTP status`() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(404).setBody("Not Found"))
val url = mockWebServer.url("/missing.mp4").toString()
try {
MediaStreamResolver.useStream(context, url, "video/mp4") { _, _ -> }
fail("Expected IOException on 404")
} catch (e: IOException) {
assertTrue(e.message?.contains("HTTP 404") == true)
}
}
// ── 7. Real Copy Cancellation Tests ─────────────────────────────────────
@Test
fun `copyChunked cancels during copy and rethrows CancellationException`() =
runTest {
var chunksRead = 0
var job: kotlinx.coroutines.Job? = null
val slowInputStream =
object : InputStream() {
override fun read(): Int = 42
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
chunksRead++
if (chunksRead == 3) {
job?.cancel()
}
val toRead = minOf(len, 1024)
b.fill(1, off, off + toRead)
return toRead
}
}
val outStream = ByteArrayOutputStream()
var caughtCancellation: CancellationException? = null
job =
launch {
try {
GatewayFileClient.copyChunked(slowInputStream, outStream)
} catch (e: CancellationException) {
caughtCancellation = e
throw e
}
}
job.join()
assertNotNull(caughtCancellation)
assertTrue(outStream.size() > 0)
}
// ── 8. Finalization & Cleanup Failure Tests ──────────────────────────────
@Test
fun `saveMediaToUri cleans up partial target if stream close throws`() =
runTest {
val sourceFile = tempFolder.newFile("source.mp3").also { it.writeText("sample-data") }
val targetUri = mockk<Uri>(relaxed = true)
val throwingStream =
object : OutputStream() {
override fun write(b: Int) {}
override fun close(): Unit = throw IOException("Disk flush failure on close")
}
val mockResolver = mockk<ContentResolver>(relaxed = true)
every { mockResolver.openOutputStream(targetUri) } returns throwingStream
every { context.contentResolver } returns mockResolver
try {
MediaExportHelper.saveMediaToUri(context, sourceFile.absolutePath, targetUri, "audio/mpeg")
fail("Expected IOException")
} catch (_: IOException) {
verify { mockResolver.delete(targetUri, null, null) }
}
}
@Test
fun `saveMediaToDownloads deletes item if MediaStore update returns zero`() =
runTest {
val mockValues = mockk<ContentValues>(relaxed = true)
val sourceFile = tempFolder.newFile("track.wav").also { it.writeText("riff-data") }
val insertedUri = mockk<Uri>(relaxed = true)
val mockResolver = mockk<ContentResolver>(relaxed = true)
every { mockResolver.insert(any(), any()) } returns insertedUri
every { mockResolver.openOutputStream(insertedUri) } returns ByteArrayOutputStream()
// Simulate update failure (row could not be published)
every { mockResolver.update(insertedUri, any(), null, null) } returns 0
every { context.contentResolver } returns mockResolver
try {
MediaExportHelper.saveMediaToDownloads(
context = context,
uri = sourceFile.absolutePath,
fallbackMime = "audio/wav",
currentSdk = 29,
valuesFactory = { _, _, _ -> mockValues },
)
fail("Expected IOException on publication failure")
} catch (_: IOException) {
verify { mockResolver.delete(insertedUri, null, null) }
}
}
@Test
fun `saveMediaToDownloads throws UnsupportedOperationException on API below 29`() =
runTest {
val sourceFile = tempFolder.newFile("track28.wav").also { it.writeText("riff-data") }
try {
MediaExportHelper.saveMediaToDownloads(
context = context,
uri = sourceFile.absolutePath,
fallbackMime = "audio/wav",
currentSdk = 28,
)
fail("Expected UnsupportedOperationException on API 28")
} catch (e: UnsupportedOperationException) {
assertTrue(e.message?.contains("API 29+") == true)
}
}
// ── 9. Share Cache Collisions & TTL Sweep Tests ──────────────────────────
@Test
fun `shareMedia generates collision-proof distinct paths for identical filenames`() =
runTest {
val cacheDir = tempFolder.newFolder("cache")
every { context.cacheDir } returns cacheDir
every { context.packageName } returns "com.m57.hermescontrol"
val sourceFile = tempFolder.newFile("song.mp3").also { it.writeText("audio-data") }
val fakeUri = mockk<Uri>(relaxed = true)
val fakeIntent = mockk<Intent>(relaxed = true)
// First share
val intent1 =
MediaExportHelper.shareMedia(
context = context,
uri = sourceFile.absolutePath,
fallbackMime = "audio/mpeg",
displayName = "song.mp3",
fileUriProvider = { _, _ -> fakeUri },
intentFactory = { _, _ -> fakeIntent },
)
assertNotNull(intent1)
val sharedDir = File(cacheDir, "shared_media")
val sessionDirs = sharedDir.listFiles { f -> f.isDirectory } ?: emptyArray()
assertEquals(1, sessionDirs.size)
val file1 = File(sessionDirs[0], "song.mp3")
assertTrue(file1.exists())
// Second share of identical filename
val intent2 =
MediaExportHelper.shareMedia(
context = context,
uri = sourceFile.absolutePath,
fallbackMime = "audio/mpeg",
displayName = "song.mp3",
fileUriProvider = { _, _ -> fakeUri },
intentFactory = { _, _ -> fakeIntent },
)
assertNotNull(intent2)
val sessionDirsAfter = sharedDir.listFiles { f -> f.isDirectory } ?: emptyArray()
assertEquals(2, sessionDirsAfter.size)
assertNotEquals(sessionDirsAfter[0].absolutePath, sessionDirsAfter[1].absolutePath)
}
@Test
fun `sweepOldShareFiles deletes expired sessions but preserves fresh ones`() {
val sharedDir = tempFolder.newFolder("shared_media")
val now = 100_000_000L
val ttl = 24 * 60 * 60 * 1000L
val oldDir = File(sharedDir, "old-session").also { it.mkdirs() }
File(oldDir, "old.mp4").writeText("old")
oldDir.setLastModified(now - ttl - 5000L)
val freshDir = File(sharedDir, "fresh-session").also { it.mkdirs() }
File(freshDir, "fresh.mp4").writeText("fresh")
freshDir.setLastModified(now - 1000L)
MediaExportHelper.sweepOldShareFiles(sharedDir, now)
assertFalse(oldDir.exists())
assertTrue(freshDir.exists())
assertTrue(File(freshDir, "fresh.mp4").exists())
}
// ── 10. Complete Auth Matrix Tests ───────────────────────────────────────
private fun testAuthInterceptor(
baseUrl: String,
token: String?,
isGated: Boolean,
requestUrl: String,
existingHeader: String? = null,
): String? {
val interceptor =
MediaDataSourceProvider.SameOriginAuthInterceptor(
baseUrlProvider = { baseUrl },
tokenProvider = { token },
isGatedModeProvider = { isGated },
)
var executedRequest: Request? = null
val chain =
mockk<Interceptor.Chain> {
val reqBuilder = Request.Builder().url(requestUrl)
if (existingHeader != null) {
reqBuilder.header("Authorization", existingHeader)
}
every { request() } returns reqBuilder.build()
every { proceed(any()) } answers {
executedRequest = firstArg()
Response
.Builder()
.request(firstArg())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody("text/plain".toMediaType()))
.build()
}
}
interceptor.intercept(chain)
return executedRequest?.header("Authorization")
}
@Test
fun `SameOriginAuthInterceptor satisfies complete authorization matrix`() {
// 1. gated mode, same origin -> no Bearer
assertNull(
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = "secret",
isGated = true,
requestUrl = "http://192.168.1.50:9119/api/files/stream?path=audio.mp3",
),
)
// 2. gated mode, external origin -> no Bearer
assertNull(
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = "secret",
isGated = true,
requestUrl = "https://external.com/audio.mp3",
),
)
// 3. token mode, same scheme/host/port, token set -> Bearer added
assertEquals(
"Bearer valid-token",
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = "valid-token",
isGated = false,
requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4",
),
)
// 4. token mode, token blank -> no Bearer
assertNull(
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = " ",
isGated = false,
requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4",
),
)
// 5. different host -> no Bearer
assertNull(
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = "valid-token",
isGated = false,
requestUrl = "http://192.168.1.99:9119/api/files/download?path=video.mp4",
),
)
// 6. same host but different port -> no Bearer
assertNull(
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = "valid-token",
isGated = false,
requestUrl = "http://192.168.1.50:8080/api/files/download?path=video.mp4",
),
)
// 7. same host/port but different scheme (https vs http) -> no Bearer
assertNull(
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = "valid-token",
isGated = false,
requestUrl = "https://192.168.1.50:9119/api/files/download?path=video.mp4",
),
)
// 8. request already contains Authorization -> preserve existing value
assertEquals(
"Basic credentials",
testAuthInterceptor(
baseUrl = "http://192.168.1.50:9119/",
token = "valid-token",
isGated = false,
requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4",
existingHeader = "Basic credentials",
),
)
// 9. base URL invalid -> no Bearer
assertNull(
testAuthInterceptor(
baseUrl = "not-a-valid-url",
token = "valid-token",
isGated = false,
requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4",
),
)
}
}
@@ -0,0 +1,42 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.Attachment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaPlaybackClassificationTest {
@Test
fun `audio MIME wins over mp4 extension`() {
val attachment = Attachment("content://media/123", "recording.mp4", "audio/mp4")
assertTrue(attachment.isAudio)
assertFalse(attachment.isVideo)
}
@Test
fun `audio extensions route generic attachments into player`() {
for (ext in listOf("mp3", "m4a", "wav", "ogg", "opus", "flac", "aac")) {
assertTrue(Attachment("content://media/123", "recording.$ext", "application/octet-stream").isAudio)
}
}
@Test
fun `video signed URL ignores query and fragment`() {
assertEquals(MediaKind.VIDEO, classifyMedia(uri = "https://example.org/clip.MP4?token=test#fragment"))
assertEquals(MediaKind.FILE, classifyMedia(uri = "https://example.org/report.txt?note=clip.mp4"))
}
@Test
fun `gateway encoded path classifies audio`() {
assertEquals(MediaKind.AUDIO, classifyMedia(uri = "https://gateway/api/files/download?path=%2Ftmp%2Fa.mp3"))
}
@Test
fun `media exports use media extensions not image fallback`() {
assertEquals("mp3", extensionForMime("audio/mpeg"))
assertEquals("m4a", extensionForMime("audio/mp4"))
assertEquals("mp4", extensionForMime("video/mp4"))
assertEquals("wav", extensionForMime("audio/x-wav"))
}
}