From abce60ad7af88ad34c477282fd0b10b3137f39b8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:20:34 +0300 Subject: [PATCH] feat: unify audio and video attachment playback (#1188) --- .github/workflows/android.yml | 2 +- app/build.gradle.kts | 5 + app/src/androidTest/assets/player-tone.wav | Bin 0 -> 705678 bytes app/src/androidTest/assets/player-video.mp4 | Bin 0 -> 115151 bytes .../ui/chat/MediaViewerDeviceTest.kt | 229 ++++++ .../hermescontrol/data/model/Attachment.kt | 17 +- .../data/remote/GatewayFileClient.kt | 1 + .../data/remote/MediaDataSourceProvider.kt | 73 ++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 22 +- .../ui/chat/MediaExportHelper.kt | 241 ++++++ .../ui/chat/MediaStreamResolver.kt | 240 ++++++ .../m57/hermescontrol/ui/chat/MediaTypes.kt | 191 ++++- .../ui/chat/components/InlineMediaPlayer.kt | 211 ++++++ .../ui/chat/components/InlineVideoPlayer.kt | 90 +-- .../ui/chat/components/MediaViewerDialog.kt | 714 ++++++++++++++++++ .../ui/chat/components/VideoViewerDialog.kt | 373 +-------- app/src/main/res/values-ar/strings.xml | 20 + app/src/main/res/values-ko/strings.xml | 20 + app/src/main/res/values-zh/strings.xml | 20 + app/src/main/res/values/strings.xml | 20 + .../ui/chat/MediaExportStreamingTest.kt | 599 +++++++++++++++ .../chat/MediaPlaybackClassificationTest.kt | 42 ++ gradle/libs.versions.toml | 6 + 23 files changed, 2640 insertions(+), 496 deletions(-) create mode 100644 app/src/androidTest/assets/player-tone.wav create mode 100644 app/src/androidTest/assets/player-video.mp4 create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5a4281eb..e876544a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -162,7 +162,7 @@ jobs: needs: [changes] if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v7 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5c7e594..010ab018 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -176,6 +176,11 @@ dependencies { implementation(libs.coil.gif) implementation(libs.coil.network.okhttp) + // Media3 (ExoPlayer) + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + implementation(libs.media3.datasource.okhttp) + // Encrypted storage implementation(libs.androidx.security.crypto) implementation(libs.androidx.datastore) diff --git a/app/src/androidTest/assets/player-tone.wav b/app/src/androidTest/assets/player-tone.wav new file mode 100644 index 0000000000000000000000000000000000000000..38cd26329e2d35f1b79c41802654a825a07bb5e8 GIT binary patch literal 705678 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 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(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() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt index 79e1f095..f9b2bf17 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt index be915019..ed330bfa 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt @@ -277,6 +277,7 @@ object GatewayFileClient { coroutineContext.ensureActive() val read = input.read(buffer) if (read < 0) break + coroutineContext.ensureActive() output.write(buffer, 0, read) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt new file mode 100644 index 00000000..08a23ba5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 2923f3cb..106360c2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt new file mode 100644 index 00000000..a1b1ab28 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt @@ -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//` 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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt new file mode 100644 index 00000000..423971ab --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt @@ -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 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 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 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 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 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 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, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt index bfa16d3b..227447d8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt @@ -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> = 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> = "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=` 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt new file mode 100644 index 00000000..972f4399 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt index a7ea4335..15ed878c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt @@ -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` */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt new file mode 100644 index 00000000..b8666b08 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt @@ -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), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt index c6612817..429b5230 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt @@ -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(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() - 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, - ) - } - } - } - } - } + ) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 16eb0ee8..349fd3dd 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1478,6 +1478,26 @@ إعادة المحاولة إغلاق تكاملات الجلسة + + + تشغيل + إيقاف مؤقت + إعادة التشغيل + إعادة المحاولة + صوت + فيديو + إغلاق + حفظ في الجهاز + مشاركة + مشاركة الوسائط + تم حفظ الوسائط + تعذر حفظ الوسائط + تعذر تحميل الوسائط: %1$s + تعذر مشاركة الوسائط + جار التحميل… + تقديم/ترجيع + تشغيل الصوت + تشغيل الفيديو جارٍ تجهيز الجلسة… الجلسة غير جاهزة — أعد المحاولة للمتابعة \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f81901cf..7133a64a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1162,6 +1162,26 @@ 다시 시도 닫기 세션 연동 + + + 재생 + 일시중지 + 다시 재생 + 다시 시도 + 오디오 + 비디오 + 닫기 + 기기에 저장 + 공유 + 미디어 공유 + 미디어가 저장되었습니다 + 미디어를 저장할 수 없습니다 + 미디어를 불러올 수 없습니다: %1$s + 미디어를 공유할 수 없습니다 + 로딩 중… + 탐색 + 오디오 재생 + 비디오 재생 세션 준비 중… 세션이 준비되지 않았습니다. 다시 시도하세요 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a47ee16a..36383e7a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1431,6 +1431,26 @@ 重试 关闭 会话集成 + + + 播放 + 暂停 + 重播 + 重试 + 音频 + 视频 + 关闭 + 保存到设备 + 分享 + 分享媒体 + 媒体已保存 + 无法保存媒体 + 无法加载媒体: %1$s + 无法分享媒体 + 正在加载… + 跳转进度 + 播放音频 + 播放视频 正在准备会话… 会话未就绪,请重试以继续 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e9eb13f..89a19465 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1645,6 +1645,26 @@ Retry Close Session Integrations + + + Play + Pause + Replay + Retry + Audio + Video + Close + Save to device + Share + Share media + Media saved + Could not save media + Could not load media: %1$s + Could not share media + Loading… + Seek + Play audio + Play video Preparing session… Session not ready — retry to continue diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt new file mode 100644 index 00000000..60ff014b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt @@ -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(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(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(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(relaxed = true) + val sourceFile = tempFolder.newFile("track.wav").also { it.writeText("riff-data") } + val insertedUri = mockk(relaxed = true) + + val mockResolver = mockk(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(relaxed = true) + val fakeIntent = mockk(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 { + 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", + ), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt new file mode 100644 index 00000000..877bb236 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt @@ -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")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c8735f4..510dfb36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ androidGradlePlugin = "9.1.1" androidxBrowser = "1.10.0" androidxCore = "1.19.0" androidxLifecycle = "2.11.0" +androidxMedia3 = "1.11.1" androidxActivity = "1.13.0" androidxComposeBom = "2026.08.00" androidxTest = "1.7.0" @@ -91,6 +92,11 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +# Media (ExoPlayer Media3) +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidxMedia3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidxMedia3" } +media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidxMedia3" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }