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