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 00000000..38cd2632 Binary files /dev/null and b/app/src/androidTest/assets/player-tone.wav differ diff --git a/app/src/androidTest/assets/player-video.mp4 b/app/src/androidTest/assets/player-video.mp4 new file mode 100644 index 00000000..bdc10396 Binary files /dev/null and b/app/src/androidTest/assets/player-video.mp4 differ diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt new file mode 100644 index 00000000..49617fb4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt @@ -0,0 +1,229 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.ContentUris +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class MediaViewerDeviceTest { + @get:Rule + val compose = createComposeRule() + + private fun open( + asset: String, + mime: String, + ) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val file = File(instrumentation.targetContext.cacheDir, asset) + instrumentation.context.assets + .open(asset) + .use { input -> file.outputStream().use(input::copyTo) } + compose.setContent { + var visible by remember { mutableStateOf(true) } + HermesControlTheme { + if (visible) { + MediaViewerDialog(file.toURI().toString(), { visible = false }, asset, mime) + } + } + } + } + + private fun awaitDescription(resource: Int) { + val text = InstrumentationRegistry.getInstrumentation().targetContext.getString(resource) + compose.waitUntil(15_000) { + compose.onAllNodesWithContentDescription(text).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun awaitTag(tag: String) { + compose.waitUntil(15_000) { + compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun checkPlayback( + asset: String, + mime: String, + ) { + open(asset, mime) + awaitDescription(R.string.media_player_pause) + awaitTag("media_play_pause_button") + compose.onNodeWithTag("media_play_pause_button").performClick() + awaitDescription(R.string.media_player_play) + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.takeScreenshot().let { bitmap -> + File(instrumentation.targetContext.cacheDir, "$asset.png").outputStream().use { + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() + } + compose.onNodeWithTag("media_seek_slider").performTouchInput { + swipe(start = center.copy(x = width * 0.2f), end = center.copy(x = width * 0.9f)) + } + compose.waitUntil(5_000) { + val progress = + compose + .onNodeWithTag("media_seek_slider") + .fetchSemanticsNode() + .config[SemanticsProperties.ProgressBarRangeInfo] + progress.current > progress.range.endInclusive * 0.75f + } + awaitTag("media_play_pause_button") + compose.onNodeWithTag("media_play_pause_button").performClick() + awaitDescription(R.string.media_player_replay) + awaitTag("media_play_pause_button") + compose.onNodeWithTag("media_play_pause_button").performClick() + awaitDescription(R.string.media_player_pause) + awaitTag("media_close_button") + compose.onNodeWithTag("media_close_button").performClick() + compose.waitUntil(15_000) { + compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty() + } + } + + @Test + fun audioPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-tone.wav", "audio/wav") + + @Test + fun videoPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-video.mp4", "video/mp4") + + @Test + fun saveButtonStreamsToDownloadsAndVerifiesMediaStoreItem() { + open("player-tone.wav", "audio/wav") + awaitTag("media_save_button") + compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick() + + val instrumentation = InstrumentationRegistry.getInstrumentation() + val resolver = instrumentation.targetContext.contentResolver + + var savedUri: Uri? = null + compose.waitUntil(15_000) { + val cursor = + resolver.query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.MIME_TYPE, + ), + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf("player-tone.wav"), + "${MediaStore.MediaColumns._ID} DESC", + ) + cursor?.use { c -> + if (c.moveToFirst()) { + val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) + val name = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) + val mime = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)) + assertEquals("player-tone.wav", name) + assertTrue(mime == "audio/wav" || mime == "audio/x-wav") + true + } else { + false + } + } ?: false + } + + try { + assertNotNull(savedUri) + val expectedBytes = + instrumentation.context.assets + .open("player-tone.wav") + .use { it.readBytes() } + val actualBytes = resolver.openInputStream(savedUri!!)?.use { it.readBytes() } + assertArrayEquals(expectedBytes, actualBytes) + } finally { + savedUri?.let { resolver.delete(it, null, null) } + } + + compose.onNodeWithTag("media_close_button").performClick() + } + + @Test + fun shareMediaBuildsValidIntentAndReadableStream() = + runBlocking { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val file = File(context.cacheDir, "player-tone.wav") + instrumentation.context.assets.open("player-tone.wav").use { input -> + file.outputStream().use(input::copyTo) + } + + val intent = + MediaExportHelper.shareMedia( + context = context, + uri = file.toURI().toString(), + fallbackMime = "audio/wav", + displayName = "player-tone.wav", + ) + + assertNotNull(intent) + assertEquals(Intent.ACTION_SEND, intent!!.action) + assertEquals("audio/wav", intent.type) + assertTrue(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + + @Suppress("DEPRECATION") + val streamUri = intent.getParcelableExtra(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" }