Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 4 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/kotlin/com/miruplay/tv/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 32 additions & 26 deletions app/src/main/kotlin/com/miruplay/tv/StartupProbeProvider.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.miruplay.tv

import android.annotation.TargetApi
import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
Expand Down Expand Up @@ -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 {
Expand All @@ -227,26 +217,15 @@ internal object StartupProbe {
} ?: error("could not open startup probe download item")
}

@TargetApi(Build.VERSION_CODES.Q)
private fun createPublicDownloadMarkerWithMediaStore(
context: Context,
markerFileName: 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(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 {
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.player

import android.content.Context
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.player

import androidx.media3.common.Effect
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.player

import android.content.Context
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.player

import android.content.Context
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:android.annotation.SuppressLint("RestrictedApi")

package com.miruplay.tv.player

import android.content.Context
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.player

import android.content.Context
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.player

import androidx.media3.common.MimeTypes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.player

import androidx.media3.common.C
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UnsafeOptInUsageError")

package com.miruplay.tv.ui.player

import android.app.Activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -296,14 +305,20 @@ 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() },
resume = { playbackController.resume() },
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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()

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)
}
}
Loading