diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8acbca1b..80666802 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,22 +56,8 @@ jobs: - name: Build debug APK run: ./gradlew assembleDebug -PBUILD_NUMBER=${{ github.run_number }} --no-daemon --stacktrace - - name: Run shared JVM checks - run: > - ./gradlew - :core:model:test - :media-source-api:test - :metadata-core:test - :repository-api:test - :metadata:test - :scraper-core:test - :scraper:test - :sync-engine:test - :web-control:test - :cloud-drive-api:test - :sync-engine-shared:test - --no-daemon - --stacktrace + - name: Run unit tests + run: ./gradlew test --no-daemon --stacktrace - name: Upload debug APK uses: actions/upload-artifact@v4 @@ -90,6 +76,7 @@ jobs: build-release: runs-on: ubuntu-latest + needs: [build, lint] permissions: contents: write if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' @@ -250,7 +237,6 @@ jobs: - name: Run lint run: ./gradlew lint --no-daemon - continue-on-error: true - name: Upload lint reports uses: actions/upload-artifact@v4 @@ -310,7 +296,7 @@ jobs: nightly: runs-on: ubuntu-latest - needs: nightly-meta + needs: [build, lint, nightly-meta] concurrency: group: nightly-android-${{ github.ref }} cancel-in-progress: true diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3a07a65f..5ff6a21c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,6 +113,7 @@ dependencies { implementation(libs.okhttp.logging.interceptor) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) + implementation(libs.androidx.work.runtime.ktx) implementation(libs.dagger.hilt.android) ksp(libs.dagger.hilt.compiler) diff --git a/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt b/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt index cccfe842..d68a3394 100644 --- a/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt @@ -1023,6 +1023,12 @@ private fun MiruPlayNavigation( ), ) } + WebControlNavigator.TYPE_CLOSE_PLAYER -> { + if (shouldReplaceExistingPlayerRoute(navController.currentDestination?.route)) { + MiruLog.i("MiruPlayNavigation", "Web control requested player close") + navController.popBackStack() + } + } WebControlNavigator.TYPE_APP_RESTART -> { MiruLog.i("MiruPlayNavigation", "Web control requested app restart") (navController.context as? MainActivity)?.requestAppShutdown(restart = true) diff --git a/app/src/main/kotlin/com/miruplay/tv/StartupProbeProvider.kt b/app/src/main/kotlin/com/miruplay/tv/StartupProbeProvider.kt index 1dda61aa..8dcb08da 100644 --- a/app/src/main/kotlin/com/miruplay/tv/StartupProbeProvider.kt +++ b/app/src/main/kotlin/com/miruplay/tv/StartupProbeProvider.kt @@ -1,5 +1,6 @@ package com.miruplay.tv +import android.annotation.TargetApi import android.content.ContentProvider import android.content.ContentUris import android.content.ContentValues @@ -195,23 +196,12 @@ internal object StartupProbe { File(diagnosticDir, markerFileName).writeText("", Charsets.UTF_8) } + @TargetApi(Build.VERSION_CODES.Q) private fun appendPublicDownloadWithMediaStore(context: Context, line: String) { val resolver = context.contentResolver val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI val relativePath = "${Environment.DIRECTORY_DOWNLOADS}/$PUBLIC_DIRECTORY_NAME/" - val existingUri = resolver.query( - collection, - arrayOf(MediaStore.Downloads._ID), - "${MediaStore.Downloads.DISPLAY_NAME}=? AND ${MediaStore.Downloads.RELATIVE_PATH}=?", - arrayOf(DIAGNOSTIC_FILE_NAME, relativePath), - null, - )?.use { cursor -> - if (cursor.moveToFirst()) { - ContentUris.withAppendedId(collection, cursor.getLong(0)) - } else { - null - } - } + val existingUri = findPublicDownloadUri(context, DIAGNOSTIC_FILE_NAME, relativePath) val targetUri = existingUri ?: resolver.insert( collection, ContentValues().apply { @@ -227,6 +217,7 @@ internal object StartupProbe { } ?: error("could not open startup probe download item") } + @TargetApi(Build.VERSION_CODES.Q) private fun createPublicDownloadMarkerWithMediaStore( context: Context, markerFileName: String, @@ -234,19 +225,7 @@ internal object StartupProbe { val resolver = context.contentResolver val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI val relativePath = "${Environment.DIRECTORY_DOWNLOADS}/$PUBLIC_DIRECTORY_NAME/" - val existingUri = resolver.query( - collection, - arrayOf(MediaStore.Downloads._ID), - "${MediaStore.Downloads.DISPLAY_NAME}=? AND ${MediaStore.Downloads.RELATIVE_PATH}=?", - arrayOf(markerFileName, relativePath), - null, - )?.use { cursor -> - if (cursor.moveToFirst()) { - ContentUris.withAppendedId(collection, cursor.getLong(0)) - } else { - null - } - } + val existingUri = findPublicDownloadUri(context, markerFileName, relativePath) val targetUri = existingUri ?: resolver.insert( collection, ContentValues().apply { @@ -262,6 +241,33 @@ internal object StartupProbe { } ?: error("could not open startup probe marker") } + @TargetApi(Build.VERSION_CODES.Q) + private fun findPublicDownloadUri( + context: Context, + displayName: String, + relativePath: String, + ): Uri? { + val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI + return context.contentResolver.query( + collection, + arrayOf(MediaStore.Downloads._ID, MediaStore.Downloads.RELATIVE_PATH), + "${MediaStore.Downloads.DISPLAY_NAME}=?", + arrayOf(displayName), + null, + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Downloads._ID) + val relativePathColumn = cursor.getColumnIndexOrThrow(MediaStore.Downloads.RELATIVE_PATH) + var matchingId: Long? = null + while (cursor.moveToNext()) { + if (cursor.getString(relativePathColumn).orEmpty().equals(relativePath, ignoreCase = true)) { + matchingId = cursor.getLong(idColumn) + break + } + } + matchingId?.let { id -> ContentUris.withAppendedId(collection, id) } + } + } + private fun appendFileLine(file: File, line: String) { FileOutputStream(file, true).use { stream -> stream.write(line.toByteArray(Charsets.UTF_8)) diff --git a/data/src/main/kotlin/com/miruplay/tv/data/logging/EarlyStartupDiagnostics.kt b/data/src/main/kotlin/com/miruplay/tv/data/logging/EarlyStartupDiagnostics.kt index 7ab2efdb..b1d32d3b 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/logging/EarlyStartupDiagnostics.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/logging/EarlyStartupDiagnostics.kt @@ -1,5 +1,6 @@ package com.miruplay.tv.data.logging +import android.annotation.TargetApi import android.content.ContentUris import android.content.ContentValues import android.content.Context @@ -175,22 +176,29 @@ internal class ExternalStartupDiagnosticsWriter( } } + @TargetApi(Build.VERSION_CODES.Q) private fun appendPublicDownloadWithMediaStore(line: String) { val resolver = context.contentResolver val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI val relativePath = "${Environment.DIRECTORY_DOWNLOADS}/$PUBLIC_DIRECTORY_NAME/" val existingUri = resolver.query( collection, - arrayOf(MediaStore.Downloads._ID), - "${MediaStore.Downloads.DISPLAY_NAME}=? AND ${MediaStore.Downloads.RELATIVE_PATH}=?", - arrayOf(DIAGNOSTIC_FILE_NAME, relativePath), + arrayOf(MediaStore.Downloads._ID, MediaStore.Downloads.RELATIVE_PATH), + "${MediaStore.Downloads.DISPLAY_NAME}=?", + arrayOf(DIAGNOSTIC_FILE_NAME), null, )?.use { cursor -> - if (cursor.moveToFirst()) { - ContentUris.withAppendedId(collection, cursor.getLong(0)) - } else { - null + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Downloads._ID) + val relativePathColumn = cursor.getColumnIndexOrThrow(MediaStore.Downloads.RELATIVE_PATH) + var matchingId: Long? = null + while (cursor.moveToNext()) { + val existingRelativePath = cursor.getString(relativePathColumn).orEmpty() + if (existingRelativePath.equals(relativePath, ignoreCase = true)) { + matchingId = cursor.getLong(idColumn) + break + } } + matchingId?.let { id -> ContentUris.withAppendedId(collection, id) } } val targetUri = existingUri ?: resolver.insert( collection, diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt index 72c9e3e0..83ab62ef 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.player import android.content.Context diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoVideoEffectMapping.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoVideoEffectMapping.kt index 362c2849..8dd067b3 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoVideoEffectMapping.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoVideoEffectMapping.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.player import androidx.media3.common.Effect diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSdrVideoGraphFactory.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSdrVideoGraphFactory.kt index 100410fd..fa9a2027 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSdrVideoGraphFactory.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSdrVideoGraphFactory.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.player import android.content.Context diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSurfaceMediaCodecVideoRenderer.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSurfaceMediaCodecVideoRenderer.kt index 8a73494b..cfca8ac2 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSurfaceMediaCodecVideoRenderer.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalHdrSurfaceMediaCodecVideoRenderer.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.player import android.content.Context diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt index caecce77..1afe5a25 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt @@ -1,3 +1,5 @@ +@file:android.annotation.SuppressLint("RestrictedApi") + package com.miruplay.tv.player import android.content.Context diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt index 6b144fb2..2336555b 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.player import android.content.Context diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackMediaCodecSelector.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackMediaCodecSelector.kt index 47adbe61..8dacc4a8 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackMediaCodecSelector.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackMediaCodecSelector.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.player import androidx.media3.common.MimeTypes diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/VideoSignalFormatResolver.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/VideoSignalFormatResolver.kt index ecd49d5e..ed957cc5 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/VideoSignalFormatResolver.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/VideoSignalFormatResolver.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.player import androidx.media3.common.C diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt index c6210528..feab3e0b 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnsafeOptInUsageError") + package com.miruplay.tv.ui.player import android.app.Activity diff --git a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlNavigator.kt b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlNavigator.kt index 8016f15f..5e2e1a98 100644 --- a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlNavigator.kt +++ b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlNavigator.kt @@ -20,6 +20,9 @@ class WebControlNavigator @Inject constructor() { ) ).isSuccess + fun closePlayer(): Boolean = + _commands.trySend(NavigationCommand(type = TYPE_CLOSE_PLAYER)).isSuccess + fun requestAppRestart(): Boolean = _commands.trySend(NavigationCommand(type = TYPE_APP_RESTART)).isSuccess @@ -28,6 +31,7 @@ class WebControlNavigator @Inject constructor() { companion object { const val TYPE_OPEN_PLAYER = "open_player" + const val TYPE_CLOSE_PLAYER = "close_player" const val TYPE_APP_RESTART = "app_restart" const val TYPE_APP_EXIT = "app_exit" } diff --git a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt index 5adf9a00..7d7ec797 100644 --- a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt +++ b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt @@ -63,6 +63,15 @@ import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton +internal suspend fun stopWebControlPlayback( + pausePlayback: suspend () -> Unit, + closePlayer: () -> Boolean, +): PlaybackStatusDto { + pausePlayback() + check(closePlayer()) { "无法关闭播放器" } + return idleWebControlPlaybackStatus() +} + @Singleton class WebControlService @Inject constructor( @dagger.hilt.android.qualifiers.ApplicationContext private val appContext: Context, @@ -296,6 +305,12 @@ class WebControlService @Inject constructor( } override suspend fun playbackCommandResolved(request: PlaybackCommandRequest): PlaybackStatusDto { + if (request.playbackCommandKind() == WebControlPlaybackCommandKind.STOP) { + return stopWebControlPlayback( + pausePlayback = playbackController::pause, + closePlayer = navigator::closePlayer, + ) + } request.executeWebControlPlaybackCommand( webControlPlaybackCommandTarget( pause = { playbackController.pause() }, @@ -303,7 +318,7 @@ class WebControlService @Inject constructor( toggle = { if (playbackController.isPlaying()) playbackController.pause() else playbackController.resume() }, - stop = { playbackController.stop() }, + stop = { error("STOP command must be handled before dispatch") }, seekTo = { positionMs -> playbackController.seekTo(positionMs) }, setPlaybackSpeed = { speed -> playbackController.setPlaybackSpeed(speed) }, currentPositionMs = { diff --git a/web-control/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlNavigatorTest.kt b/web-control/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlNavigatorTest.kt index 501acf89..285ab888 100644 --- a/web-control/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlNavigatorTest.kt +++ b/web-control/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlNavigatorTest.kt @@ -33,6 +33,19 @@ class WebControlNavigatorTest { assertEquals("anime-1:episode-1", payload.getValue("episodeId").jsonPrimitive.content) } + @Test + fun `close player command survives until first collector attaches`() = runBlocking { + val navigator = WebControlNavigator() + + assertTrue(navigator.closePlayer()) + + val command = withTimeout(1_000) { + navigator.commands.first() + } + + assertEquals(WebControlNavigator.TYPE_CLOSE_PLAYER, command.type) + } + @Test fun `app restart command survives until first collector attaches`() = runBlocking { val navigator = WebControlNavigator() diff --git a/web-control/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlPlaybackStopTest.kt b/web-control/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlPlaybackStopTest.kt new file mode 100644 index 00000000..3d6d4b43 --- /dev/null +++ b/web-control/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlPlaybackStopTest.kt @@ -0,0 +1,41 @@ +package com.miruplay.tv.webcontrol + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebControlPlaybackStopTest { + @Test + fun `stop pauses before requesting player close and returns idle`() = runBlocking { + val events = mutableListOf() + + val status = stopWebControlPlayback( + pausePlayback = { events += "pause" }, + closePlayer = { + events += "close" + true + }, + ) + + assertEquals(listOf("pause", "close"), events) + assertEquals("Idle", status.state) + assertFalse(status.isPlaying) + } + + @Test + fun `stop fails when player close cannot be queued`() = runBlocking { + var paused = false + + val error = runCatching { + stopWebControlPlayback( + pausePlayback = { paused = true }, + closePlayer = { false }, + ) + }.exceptionOrNull() + + assertTrue(paused) + assertTrue(error is IllegalStateException) + } +}