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

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

No files matched your search

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