From 023ed8e31d2dcf403b474081a5eea56c49d934db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:20:58 +0000 Subject: [PATCH 1/2] android: publish the library and the queue to other apps (MediaLibraryService) PlaybackService is now a Media3 MediaLibraryService. Android Auto (declared via automotive_app_desc.xml), Wear, Assistant and any MediaBrowser can browse the first server's library: Albums, Artists, Playlists and Genres, each album or playlist with its tracks, and each artist or genre with its albums, plus local search. Playing an item sends the command the UI would send. An album, artist, playlist or genre plays as that context (starting at the tapped track). Search results play through PlayTracks. Added items become PlayNext or PlayLater. Voice "play ..." requests resolve to the best match and honour EXTRA_MEDIA_FOCUS. The session player's playlist is now the core's queue: recent history, the current entry, playing next and upcoming. Controllers show it as the session queue. Picking an entry sends JumpToQueueItem, and moving or removing upcoming entries sends MoveQueueItem or RemoveQueueItems. Cover art reaches controllers through ArtworkProvider (content URIs). It serves only this app and the controllers the session accepted, and only read-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BFy2RUWjfmXxF8wCXTm7HM --- android/README.md | 26 +- android/app/src/main/AndroidManifest.xml | 5 + .../src/main/res/xml/automotive_app_desc.xml | 4 + .../app/hocket/config/ManifestRulesTest.kt | 24 ++ android/playback/src/main/AndroidManifest.xml | 8 + .../app/hocket/playback/ArtworkProvider.kt | 69 ++++ .../app/hocket/playback/CoreSessionPlayer.kt | 140 ++++++-- .../app/hocket/playback/LibraryBrowser.kt | 299 ++++++++++++++++++ .../main/java/app/hocket/playback/MediaIds.kt | 89 ++++++ .../app/hocket/playback/MediaSessionBridge.kt | 90 +++++- .../app/hocket/playback/PlaybackService.kt | 34 +- .../playback/src/main/res/values/strings.xml | 9 + .../app/hocket/playback/ConnectRoutesTest.kt | 4 +- .../hocket/playback/CoreSessionQueueTest.kt | 137 ++++++++ .../app/hocket/playback/LibraryBrowserTest.kt | 163 ++++++++++ .../java/app/hocket/playback/MediaIdsTest.kt | 33 ++ 16 files changed, 1098 insertions(+), 36 deletions(-) create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 android/playback/src/main/java/app/hocket/playback/ArtworkProvider.kt create mode 100644 android/playback/src/main/java/app/hocket/playback/LibraryBrowser.kt create mode 100644 android/playback/src/main/java/app/hocket/playback/MediaIds.kt create mode 100644 android/playback/src/main/res/values/strings.xml create mode 100644 android/playback/src/test/java/app/hocket/playback/CoreSessionQueueTest.kt create mode 100644 android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt create mode 100644 android/playback/src/test/java/app/hocket/playback/MediaIdsTest.kt diff --git a/android/README.md b/android/README.md index 7edd816..cff3fc0 100644 --- a/android/README.md +++ b/android/README.md @@ -53,7 +53,7 @@ fixture so serde/kotlinx drift fails a JVM test. ## Service and backend bridge -`PlaybackService` (a Media3 `MediaSessionService`, `foregroundServiceType="mediaPlayback"`) owns +`PlaybackService` (a Media3 `MediaLibraryService`, `foregroundServiceType="mediaPlayback"`) owns the one core for the process through `CoreHost`: - `Event.Backend(BackendCommand)` -> `ExoBackend` -> ExoPlayer. `Load` builds a per-item media @@ -89,6 +89,30 @@ the one core for the process through `CoreHost`: Media3; the service `addSession`s the session in `onCreate` (the UI is not a Media3 controller, so `onGetSession` alone would never register it and no notification or foreground promotion would happen). +- Queue: `CoreSessionPlayer`'s playlist is the core's `QueueView` (`Event.QueueChanged`): the last 25 + history entries, the current one, playing next and upcoming, media ids `queue/`, so Auto's + queue view, Wear and the legacy `MediaSession.setQueue` show it. Picking an entry is + `JumpToQueueItem`; moving an upcoming entry is `MoveQueueItem` (index into playing next + upcoming); + removing is `RemoveQueueItems` (never the current entry); next/previous stay the core's. When the + queue's current entry is not the session's track yet, the playlist is that one track. +- Library browsing: the session is a `MediaLibrarySession`, so Android Auto (the app declares + `automotive_app_desc.xml`), Wear, Assistant and any `MediaBrowser` can browse and search the first + server's library. `LibraryBrowser` answers from core queries: the root has Albums, Artists, + Playlists and Genres (Auto's tabs, with grid/list content-style hints); an album or playlist lists + its tracks, an artist or genre its albums; pages map to `Page`. Search is local only + (`includeServer = false`). Ids (`MediaIds.kt`) are self-contained and URL-encoded + (`album///` and so on), so an id kept from an earlier connection still plays. + Playing one sends what the UI would send: an album, artist, playlist or genre plays as that context + (from the tapped track's index for a track inside one), a search result track via `PlayTracks`. + Adding items is `PlayNext` (right after the current entry) or `PlayLater`; artists and genres are + too broad to enqueue. Voice "play …" requests (`onAddMediaItems` with a search query) resolve to the + best match, honouring `EXTRA_MEDIA_FOCUS`; an empty query resumes. Subscribed browsers hear about + library changes (debounced 2 s). The "recent" root is refused, as resumption is. +- Artwork for controllers: they cannot read the core's cache files, so browse and queue items carry + `content://.artwork//` URIs served by `ArtworkProvider`, which resolves + them with `Query.Artwork` and opens the cached file read-only. It is exported (the controller opens + the URI with its own identity) but only serves this app and packages the session accepted in + `onConnect`. - Remote output: while another Connect device plays, `CoreSessionPlayer` reports `DeviceInfo(PLAYBACK_TYPE_REMOTE, routingControllerId = "hocket-connect")` (fixed volume: Connect volume is per device) and `ConnectRouteProvider` (a `MediaRoute2ProviderService`, API 30+) keeps a diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 78955c5..dc39a95 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,11 @@ ExoPlayer never follows an https->http redirect (ExoBackend), and the core only attempts the native-API password login over https or loopback. See android/README.md. --> + + + + + + diff --git a/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt b/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt index 60803bd..b8e27bd 100644 --- a/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt +++ b/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt @@ -63,4 +63,28 @@ class ManifestRulesTest { assertEquals("$path declares no broadcast receiver", 0, receivers.length) } } + + @Test + fun theLibraryIsPublishedToMediaBrowsersAndAndroidAuto() { + val service = parse("android/playback/src/main/AndroidManifest.xml").getElementsByTagName("service").let { list -> + (0 until list.length).map { list.item(it) as Element }.single { it.getAttribute("android:name") == "app.hocket.playback.PlaybackService" } + } + val actions = service.getElementsByTagName("action").let { list -> (0 until list.length).map { (list.item(it) as Element).getAttribute("android:name") } } + assertTrue(actions.containsAll(listOf("androidx.media3.session.MediaLibraryService", "android.media.browse.MediaBrowserService"))) + val meta = parse("android/app/src/main/AndroidManifest.xml").getElementsByTagName("meta-data").let { list -> (0 until list.length).map { list.item(it) as Element } } + assertEquals("@xml/automotive_app_desc", meta.single { it.getAttribute("android:name") == "com.google.android.gms.car.application" }.getAttribute("android:resource")) + val uses = parse("android/app/src/main/res/xml/automotive_app_desc.xml").documentElement.children("uses").map { it.getAttribute("name") } + assertEquals(listOf("media"), uses) + } + + @Test + fun theArtworkProviderIsTheOnlyProviderAndCannotBeWrittenOrGranted() { + // Exported so controllers can open artwork; ArtworkProvider itself checks the caller. + val providers = listOf("android/playback/src/main/AndroidManifest.xml", "android/app/src/main/AndroidManifest.xml") + .flatMap { path -> parse(path).getElementsByTagName("provider").let { list -> (0 until list.length).map { list.item(it) as Element } } } + val provider = providers.single() + assertEquals("app.hocket.playback.ArtworkProvider", provider.getAttribute("android:name")) + assertFalse(provider.hasAttribute("android:grantUriPermissions")) + assertFalse(provider.hasAttribute("android:writePermission")) + } } diff --git a/android/playback/src/main/AndroidManifest.xml b/android/playback/src/main/AndroidManifest.xml index f32ef52..1769bc0 100644 --- a/android/playback/src/main/AndroidManifest.xml +++ b/android/playback/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ android:exported="true" android:foregroundServiceType="mediaPlayback"> + @@ -34,6 +35,13 @@ + + + Hocket + Albums + Artists + Playlists + Genres + diff --git a/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt b/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt index 8d5a576..2108894 100644 --- a/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt +++ b/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt @@ -68,7 +68,9 @@ class ConnectRoutesTest { @Test fun remotePlaybackCarriesTheRoutingSessionId() { val player = CoreSessionPlayer(Looper.getMainLooper(), {}) - val bridge = MediaSessionBridge(ApplicationProvider.getApplicationContext(), player, {}, null) + val context = ApplicationProvider.getApplicationContext() + val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined) + val bridge = MediaSessionBridge(context, player, {}, null, LibraryBrowser(context, { error("unused") }, { null }, scope), scope) try { bridge.apply(state(owns = false), 0.0, remote = true) shadowOf(Looper.getMainLooper()).idle() diff --git a/android/playback/src/test/java/app/hocket/playback/CoreSessionQueueTest.kt b/android/playback/src/test/java/app/hocket/playback/CoreSessionQueueTest.kt new file mode 100644 index 0000000..7f4b123 --- /dev/null +++ b/android/playback/src/test/java/app/hocket/playback/CoreSessionQueueTest.kt @@ -0,0 +1,137 @@ +package app.hocket.playback + +import android.app.Application +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.api.Command +import app.hocket.core.api.MediaSessionAction +import app.hocket.core.api.MediaSessionMetadata +import app.hocket.core.api.MediaSessionState +import app.hocket.core.api.OfflineState +import app.hocket.core.api.PositionStamp +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * The session's playlist is the core's queue: controllers see history, the current entry and what + * comes next, and picking, moving, removing or adding entries sends the queue commands. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34], application = Application::class) +class CoreSessionQueueTest { + private val sent = mutableListOf() + private val requests = mutableListOf>>() + private val media = object : CoreSessionPlayer.MediaRequests { + override fun play(mediaIds: List, startIndex: Int) { requests += "play@$startIndex" to mediaIds } + override fun enqueue(mediaIds: List, next: Boolean) { requests += (if (next) "next" else "later") to mediaIds } + } + private val player = CoreSessionPlayer(Looper.getMainLooper(), { sent += it }, media = media) + + private fun entry(n: Int) = QueueEntry( + QueueItem("k$n", "t$n", QueueSource.Inserted, false), + TrackSummary("t$n", "s1", "Track $n", "Artist", "Album", "al1", "ar1", 180_000u, null, 0u, false, OfflineState.None), + ) + + private fun state(trackId: String) = MediaSessionState( + MediaSessionMetadata("Track now", "Artist", "Album", 200_000u, null, trackId, false, 0u), true, + PositionStamp(0u, 0.0, 1.0, true), false, RepeatMode.Off, 1.0, + listOf(MediaSessionAction.Play, MediaSessionAction.Pause, MediaSessionAction.Next, MediaSessionAction.Previous, MediaSessionAction.Seek), true, + ) + + /** History k0,k1; current k2; playing next k3; upcoming k4,k5. */ + private val queue = QueueView("Album", listOf(entry(0), entry(1)), entry(2), listOf(entry(3)), listOf(entry(4), entry(5)), false, RepeatMode.Off, false, QueueMode.Apple, 2u) + + private fun settle() = shadowOf(Looper.getMainLooper()).idle() + + private fun setUp() { + player.apply(state("t2")) + player.applyQueue(queue) + settle() + } + + @Test + fun thePlaylistIsTheQueueAroundTheCurrentEntry() { + setUp() + assertEquals(6, player.mediaItemCount) + assertEquals(2, player.currentMediaItemIndex) + assertEquals("Track now", player.currentMediaItem?.mediaMetadata?.title.toString()) + assertEquals("Track 4", player.getMediaItemAt(4).mediaMetadata.title.toString()) + assertEquals(MediaId.QueueItem("k5").format(), player.getMediaItemAt(5).mediaId) + } + + @Test + fun aQueueThatHasNotCaughtUpShowsOnlyTheCurrentTrack() { + player.apply(state("t9")) + player.applyQueue(queue) + settle() + assertEquals(1, player.mediaItemCount) + assertEquals("t9", player.currentMediaItem?.mediaId) + } + + @Test + fun historyIsCapped() { + val long = queue.copy(history = (100 until 160).map(::entry)) + assertEquals(CoreSessionPlayer.HISTORY_SHOWN, CoreSessionPlayer.timeline(long, "t2").currentIndex) + assertEquals("k159", CoreSessionPlayer.timeline(long, "t2").entries[CoreSessionPlayer.HISTORY_SHOWN - 1].item.key) + } + + @Test + fun pickingAnEntryJumpsToIt() { + setUp() + player.seekTo(4, 0) + assertEquals("k4", (sent.single() as Command.JumpToQueueItem).data.key) + sent.clear() + player.seekTo(0, 0) + assertEquals("k0", (sent.single() as Command.JumpToQueueItem).data.key) + } + + @Test + fun nextAndPreviousStayTheCoresEvenWithAPlaylist() { + setUp() + player.seekToNext() + assertEquals(MediaSessionAction.Next, (sent.single() as Command.MediaSessionCommand).data.action) + } + + @Test + fun movingAnUpcomingEntryUsesTheCombinedUpcomingIndex() { + setUp() + player.moveMediaItem(5, 3) + val move = sent.single() as Command.MoveQueueItem + assertEquals("k5", move.data.key) + assertEquals(0u, move.data.to_index) + sent.clear() + setUp() + player.moveMediaItem(3, 1) + assertTrue("history and the current entry are not reorderable", sent.isEmpty()) + } + + @Test + fun removingSkipsTheCurrentEntry() { + setUp() + player.removeMediaItems(2, 5) + assertEquals(listOf("k3", "k4"), (sent.single() as Command.RemoveQueueItems).data.keys) + } + + @Test + fun addedAndSetItemsGoToTheLibrary() { + setUp() + player.addMediaItem(3, MediaItem.Builder().setMediaId("track/s1/t9").build()) + assertEquals("next" to listOf("track/s1/t9"), requests.last()) + player.addMediaItem(MediaItem.Builder().setMediaId("track/s1/t8").build()) + assertEquals("later" to listOf("track/s1/t8"), requests.last()) + player.setMediaItems(listOf(MediaItem.Builder().setMediaId("album/s1/al1").build()), 0, 0) + assertEquals("play@0" to listOf("album/s1/al1"), requests.last()) + } +} diff --git a/android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt b/android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt new file mode 100644 index 0000000..f7cd576 --- /dev/null +++ b/android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt @@ -0,0 +1,163 @@ +package app.hocket.playback + +import android.app.Application +import android.os.Bundle +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.CoreHandle +import app.hocket.core.api.Command +import app.hocket.core.api.ContextKind +import app.hocket.core.api.MediaSessionAction +import app.hocket.core.fake.FakeCore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * The browse tree, search and play/enqueue resolution over the fake core's library: what Android + * Auto or any MediaBrowser sees, and the commands a tap there sends. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34], application = Application::class) +class LibraryBrowserTest { + private val app: Application get() = ApplicationProvider.getApplicationContext() + private val fake = FakeCore(timers = false) + private val sent = mutableListOf() + private val core = object : CoreHandle by fake { + override fun dispatch(command: Command) { sent += command } + } + private val server = fake.library.serverId + private var serverId: String? = server + private val browser = LibraryBrowser(app, { core }, { serverId }, CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) + + @Test + fun rootListsTheFourSections() = runBlocking { + val sections = browser.children(MediaId.ROOT, 0, 100)!! + assertEquals(listOf("albums", "artists", "playlists", "genres"), sections.map { it.mediaId }) + assertTrue(sections.all { it.mediaMetadata.isBrowsable == true && it.mediaMetadata.isPlayable == false }) + assertEquals("Albums", sections[0].mediaMetadata.title.toString()) + } + + @Test + fun sectionsPageThroughTheLibrary() = runBlocking { + val first = browser.children("albums", 0, 5)!! + val second = browser.children("albums", 1, 5)!! + assertEquals(5, first.size) + assertTrue("pages do not overlap", first.map { it.mediaId }.intersect(second.map { it.mediaId }.toSet()).isEmpty()) + val album = fake.library.albums.first() + val item = first.first { it.mediaId == MediaId.Album(server, album.id).format() } + assertEquals(album.name, item.mediaMetadata.title.toString()) + assertTrue(item.mediaMetadata.isBrowsable == true && item.mediaMetadata.isPlayable == true) + assertEquals("content", item.mediaMetadata.artworkUri?.scheme) + assertEquals(fake.library.genres.size, browser.children("genres", 0, 1000)!!.size) + assertEquals(fake.library.playlists.size, browser.children("playlists", 0, 1000)!!.size) + assertTrue(browser.children("artists", 0, 10)!!.isNotEmpty()) + } + + @Test + fun noServerMeansEmptySectionsAndUnknownIdsAreErrors() = runBlocking { + serverId = null + assertEquals(emptyList(), browser.children("albums", 0, 10)) + assertNull(browser.children("nonsense", 0, 10)) + assertNull(browser.item("album/x")) + } + + @Test + fun anAlbumsChildrenAreItsTracksInOrder() = runBlocking { + val album = fake.library.albums.first() + val tracks = browser.children(MediaId.Album(server, album.id).format(), 0, 100)!! + assertEquals(fake.library.albumTracks(album.id).map { it.title }, tracks.map { it.mediaMetadata.title.toString() }) + assertEquals(MediaId.AlbumTrack(server, album.id, 1).format(), tracks[1].mediaId) + assertTrue(tracks.all { it.mediaMetadata.isPlayable == true && it.mediaMetadata.isBrowsable == false }) + assertEquals(tracks[1].mediaMetadata.title, browser.item(tracks[1].mediaId)?.mediaMetadata?.title) + } + + @Test + fun aTrackInsideAnAlbumPlaysTheAlbumFromThere() = runBlocking { + val album = fake.library.albums.first() + assertTrue(browser.playNow(listOf(MediaId.AlbumTrack(server, album.id, 2).format()), 0)) + val play = sent.single() as Command.PlayContext + assertEquals(2u, play.data.args.startIndex) + assertEquals(album.name, play.data.args.context.label) + assertEquals(album.id, (play.data.args.context.kind as ContextKind.Album).data.id) + } + + @Test + fun playlistsGenresAndQueueEntriesPlay() = runBlocking { + val playlist = fake.library.playlists.first() + assertTrue(browser.playNow(listOf(MediaId.Playlist(server, playlist.id).format()), 0)) + assertTrue((sent.last() as Command.PlayContext).data.args.context.kind is ContextKind.Playlist) + assertTrue(browser.playNow(listOf(MediaId.Genre(server, "Rock").format()), 0)) + assertEquals("Rock", ((sent.last() as Command.PlayContext).data.args.context.kind as ContextKind.Genre).data.name) + assertTrue(browser.playNow(listOf(MediaId.QueueItem("k1").format()), 0)) + assertEquals("k1", (sent.last() as Command.JumpToQueueItem).data.key) + assertFalse("a section is not playable", browser.playNow(listOf("albums"), 0)) + } + + @Test + fun aListOfLoneTracksPlaysAsOneList() = runBlocking { + val ids = fake.library.tracks.take(3).map { MediaId.Track(server, it.id).format() } + assertTrue(browser.playNow(ids, 1)) + val play = sent.single() as Command.PlayTracks + assertEquals(fake.library.tracks.take(3).map { it.id }, play.data.track_ids) + assertEquals(1u, play.data.start_index) + } + + @Test + fun enqueueingAnAlbumAddsItsTracks() = runBlocking { + val album = fake.library.albums.first() + assertTrue(browser.enqueueNow(listOf(MediaId.Album(server, album.id).format()), next = true)) + assertEquals(fake.library.albumTracks(album.id).map { it.id }, (sent.single() as Command.PlayNext).data.track_ids) + val t = fake.library.tracks.first() + assertTrue(browser.enqueueNow(listOf(MediaId.Track(server, t.id).format()), next = false)) + assertEquals(listOf(t.id), (sent.last() as Command.PlayLater).data.track_ids) + assertFalse("artists are too broad to enqueue", browser.enqueueNow(listOf(MediaId.Artist(server, "ar0").format()), next = false)) + } + + @Test + fun searchReturnsPlayableLocalResults() = runBlocking { + val track = fake.library.tracks.first() + val results = browser.search(track.title) + assertTrue(results.any { it.mediaId == MediaId.Track(server, track.id).format() }) + assertTrue(browser.search(" ").isEmpty()) + } + + @Test + fun voiceRequestsResolveToOneItem() = runBlocking { + assertEquals(LibraryBrowser.RESUME, browser.resolveSearch("", null)?.mediaId) + assertTrue(browser.playNow(listOf(LibraryBrowser.RESUME), 0)) + assertEquals(MediaSessionAction.Play, (sent.last() as Command.MediaSessionCommand).data.action) + + val album = fake.library.albums.first() + val focusAlbum = Bundle().apply { putString(MediaStore.EXTRA_MEDIA_FOCUS, MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE) } + assertEquals(MediaId.Album(server, album.id).format(), browser.resolveSearch(album.name, focusAlbum)?.mediaId) + + val genre = fake.library.genres.first() + val focusGenre = Bundle().apply { putString(MediaStore.EXTRA_MEDIA_FOCUS, MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE); putString(MediaStore.EXTRA_MEDIA_GENRE, genre.name.uppercase()) } + assertEquals(MediaId.Genre(server, genre.name).format(), browser.resolveSearch(genre.name, focusGenre)?.mediaId) + + assertNotNull(browser.resolveSearch(fake.library.tracks.first().title, null)) + assertNull(browser.resolveSearch("zzzz-no-such-thing", null)) + } + + @Test + fun artworkIsOnlyForThisAppAndConnectedControllers() { + ArtworkProvider.resetForTests() + assertTrue(ArtworkProvider.isAllowed(app.packageName, app.packageName)) + assertFalse(ArtworkProvider.isAllowed("com.example.snoop", app.packageName)) + assertFalse(ArtworkProvider.isAllowed(null, app.packageName)) + ArtworkProvider.allow("com.google.android.projection.gearhead") + assertTrue(ArtworkProvider.isAllowed("com.google.android.projection.gearhead", app.packageName)) + assertEquals("content://${app.packageName}.artwork/320/al%2F1", ArtworkProvider.uri(app, "al/1").toString()) + } +} diff --git a/android/playback/src/test/java/app/hocket/playback/MediaIdsTest.kt b/android/playback/src/test/java/app/hocket/playback/MediaIdsTest.kt new file mode 100644 index 0000000..0996f35 --- /dev/null +++ b/android/playback/src/test/java/app/hocket/playback/MediaIdsTest.kt @@ -0,0 +1,33 @@ +package app.hocket.playback + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** The ids other apps hold on to: every kind round-trips, whatever its segments contain. */ +class MediaIdsTest { + @Test + fun everyKindRoundTrips() { + val ids = listOf( + MediaId.Root, + MediaId.Section(LibrarySection.Albums), MediaId.Section(LibrarySection.Genres), + MediaId.Album("s1", "al1"), MediaId.AlbumTrack("s1", "al1", 3), + MediaId.Artist("s1", "ar/1"), MediaId.Playlist("s1", "pl:9"), MediaId.PlaylistTrack("s1", "pl:9", 0), + MediaId.Genre("s1", "Drum/Bass & Jungle"), MediaId.Genre("s1", "Électro 100%"), + MediaId.Track("server with spaces", "t1"), MediaId.QueueItem("k-42"), + ) + for (id in ids) assertEquals(id, MediaId.parse(id.format())) + } + + @Test + fun segmentsAreEncodedSoSlashesCannotShiftFields() { + assertEquals("genre/s1/Drum%2FBass", MediaId.Genre("s1", "Drum/Bass").format()) + assertEquals(MediaId.Artist("a/b", "c"), MediaId.parse(MediaId.Artist("a/b", "c").format())) + } + + @Test + fun foreignOrMalformedIdsAreRejected() { + for (bad in listOf("", "album", "album/s1", "album/s1/al1/x", "album/s1/al1/-1", "album//al1", "track/s1", "queue", "unknown/a/b", "album/s1/%zz")) + assertNull(bad, MediaId.parse(bad)) + } +} From e5afa4f45588ec64ca408fd330ea69b7a5002f0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 03:20:30 +0000 Subject: [PATCH 2/2] Add "Allow control by other apps" setting (off by default) A new device-local core setting, media.externalControl, decides whether other apps (Android Auto, Wear, media browsers, automation apps) may connect to the media session to browse the library, see the queue and control playback. It is shown in Settings > Playback. While it is off, the session accepts only the system's own controls. These are this app, Media3's notification controller, and callers holding MEDIA_CONTENT_CONTROL (SystemUI, Bluetooth). Before API 28 the platform's anonymous legacy controller is also accepted, because it cannot be identified. Every browse, search, add and custom command is checked again, so turning the setting off also strips already-connected apps of their commands and revokes their artwork access. PlaybackService keeps a copy of the value (excluded from backups), so a controller that connects before the core's snapshot is judged by the last choice. Also fixes library browsing: onConnect granted DEFAULT_SESSION_COMMANDS, which leaves out the library commands, so browsers would have been refused. It now grants DEFAULT_SESSION_AND_LIBRARY_COMMANDS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BFy2RUWjfmXxF8wCXTm7HM --- android/README.md | 9 +++ .../settings/SettingsCategoryScreens.kt | 8 ++- android/app/src/main/res/values/strings.xml | 2 + android/app/src/main/res/xml/backup_rules.xml | 5 +- .../main/res/xml/data_extraction_rules.xml | 2 + .../app/hocket/config/ManifestRulesTest.kt | 4 +- .../app/hocket/ui/SettingsCategoriesTest.kt | 4 +- .../main/java/app/hocket/core/SettingKeys.kt | 2 + .../java/app/hocket/core/fake/FakeCore.kt | 1 + .../app/hocket/playback/ArtworkProvider.kt | 5 +- .../app/hocket/playback/ExternalControl.kt | 64 +++++++++++++++++++ .../app/hocket/playback/MediaSessionBridge.kt | 62 ++++++++++++++++-- .../app/hocket/playback/PlaybackService.kt | 11 +++- .../app/hocket/playback/ConnectRoutesTest.kt | 2 +- .../hocket/playback/ExternalControlTest.kt | 59 +++++++++++++++++ .../app/hocket/playback/LibraryBrowserTest.kt | 2 +- crates/hocket-core/src/settings/registry.rs | 7 ++ crates/hocket-core/src/settings/strings.rs | 1 + desktop/src/main/fake-core/index.ts | 1 + 19 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 android/playback/src/main/java/app/hocket/playback/ExternalControl.kt create mode 100644 android/playback/src/test/java/app/hocket/playback/ExternalControlTest.kt diff --git a/android/README.md b/android/README.md index cff3fc0..549aedc 100644 --- a/android/README.md +++ b/android/README.md @@ -108,6 +108,15 @@ the one core for the process through `CoreHost`: too broad to enqueue. Voice "play …" requests (`onAddMediaItems` with a search query) resolve to the best match, honouring `EXTRA_MEDIA_FOCUS`; an empty query resumes. Subscribed browsers hear about library changes (debounced 2 s). The "recent" root is refused, as resumption is. +- "Allow control by other apps" (`media.externalControl`, device-local, off by default; Settings > + Playback) gates all of the above: while off, `onConnect` accepts only the system's own controls + (`ExternalControl`: this app, Media3's notification controller, and uids holding the privileged + `MEDIA_CONTENT_CONTROL`: SystemUI, Bluetooth; before API 28 the platform's anonymous legacy + controller too, since it cannot be identified), and every browse/search/add/custom request is + checked again. Turning it off strips already-connected apps of all commands and revokes their + artwork access. `PlaybackService` mirrors the value in SharedPreferences (`hocket-media-control`, + excluded from backups) so a controller connecting before the core's snapshot is judged by the + last choice, not the default. - Artwork for controllers: they cannot read the core's cache files, so browse and queue items carry `content://.artwork//` URIs served by `ArtworkProvider`, which resolves them with `Query.Artwork` and opens the cached file read-only. It is exported (the controller opens diff --git a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt index e173313..a57c619 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt @@ -167,7 +167,10 @@ private fun AccentChoices(dynamicLabel: String, swatches: List minuteResources.getQuantityString(R.plurals.a11y_minutes, m, m) }) SwitchRow(stringResource(R.string.sleep_end_of_track), sleepEnd.bool ?: true, { sleepEnd.setBool(it) }, scope = sleepEnd.scope, tag = "sleep.stopAtEndOfTrack") + SwitchRow(stringResource(R.string.settings_external_control), externalControl.bool ?: false, { externalControl.setBool(it) }, + subtitle = stringResource(R.string.settings_external_control_summary), scope = externalControl.scope, tag = "media.externalControl") } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index eb0602e..c30ab61 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -244,6 +244,8 @@ Off %1$d min Stop at end of track + Allow control by other apps + Let apps like Android Auto, Wear and media browsers browse your library, see the queue and control playback. Notification, lock screen and Bluetooth controls always work. Stops in %1$s Stops at end of track Start diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml index e7d4835..393cf6b 100644 --- a/android/app/src/main/res/xml/backup_rules.xml +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -5,7 +5,9 @@ downloads and stream cache, the device id (must be unique per install: Connect leases and elections; a fresh one is generated when the prefs file is missing) and the keystore ciphertext plus names (the key never leaves the device, so the ciphertext is useless elsewhere - and the plaintext keys would leak the server address and username). --> + and the plaintext keys would leak the server address and username), and the mirror of + "allow control by other apps" (a device-local choice: restoring it must not let other apps + in on a new device). --> @@ -16,4 +18,5 @@ + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml index 04086bc..617e362 100644 --- a/android/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -12,6 +12,7 @@ + @@ -24,5 +25,6 @@ + diff --git a/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt b/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt index b8e27bd..fa341b3 100644 --- a/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt +++ b/android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt @@ -28,11 +28,13 @@ class ManifestRulesTest { (0 until childNodes.length).map { childNodes.item(it) }.filterIsInstance().filter { it.tagName == tag } /** Everything the device must keep to itself: the library mirror and its backups (large, and a - * cache), the Connect device id (must be unique per install) and the keystore ciphertext and names. */ + * cache), the Connect device id (must be unique per install), the keystore ciphertext and names, and + * the "allow control by other apps" mirror (device-local). */ private val mustExclude = setOf( "file:hocket/hocket.sqlite", "file:hocket/hocket.sqlite-wal", "file:hocket/hocket.sqlite-shm", "file:hocket/backups", "file:hocket/downloads", "file:hocket/cache", "sharedpref:hocket-core.xml", "sharedpref:hocket-credentials.xml", "sharedpref:hocket-credential-names.xml", + "sharedpref:hocket-media-control.xml", ) private fun excludes(section: Element): Set = section.children("exclude").map { it.getAttribute("domain") + ":" + it.getAttribute("path") }.toSet() diff --git a/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt b/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt index 03360ba..091a7de 100644 --- a/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt +++ b/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt @@ -56,7 +56,7 @@ class SettingsCategoriesTest { /** Registry settings the old page did not show, now in their category. */ private val added = setOf("queue.mode", "sleep.defaultMinutes", "sleep.stopAtEndOfTrack", "downloads.wifiOnly", "lyrics.defaultOffsetMs", "lyrics.showTranslations", - "storage.cacheUsage", "storage.cacheMaxBytes", "storage.prefetchOnMobileData", "storage.dataSaved") + "media.externalControl", "storage.cacheUsage", "storage.cacheMaxBytes", "storage.prefetchOnMobileData", "storage.dataSaved") /** Categories that open one of the older sub-screens: the old page's row for it. */ private val screenCategories = mapOf( @@ -67,7 +67,7 @@ class SettingsCategoriesTest { private val expected = mapOf( SettingsCategory.Account to setOf("sync.master", "server.info", "server.syncNow", "server.fullSync", "server.remove"), SettingsCategory.Appearance to setOf("display.theme", "display.accent", "display.artworkColour", "display.animatedBackground"), - SettingsCategory.Playback to setOf("queue.mode", "queue.savedCap", "queue.autoplay", "sleep.defaultMinutes", "sleep.stopAtEndOfTrack"), + SettingsCategory.Playback to setOf("queue.mode", "queue.savedCap", "queue.autoplay", "sleep.defaultMinutes", "sleep.stopAtEndOfTrack", "media.externalControl"), SettingsCategory.Downloads to setOf("open.downloads", "downloads.wifiOnly", "storage.clearCache", "storage.warnThreshold", "storage.cacheUsage", "storage.cacheMaxBytes", "storage.prefetchOnMobileData", "storage.dataSaved"), SettingsCategory.Lyrics to setOf("lyrics.external", "lyrics.defaultOffsetMs", "lyrics.showTranslations"), SettingsCategory.Library to setOf("ratings.loveThreshold", "open.filters", "open.stats"), diff --git a/android/core/src/main/java/app/hocket/core/SettingKeys.kt b/android/core/src/main/java/app/hocket/core/SettingKeys.kt index 61209af..a8f7cd4 100644 --- a/android/core/src/main/java/app/hocket/core/SettingKeys.kt +++ b/android/core/src/main/java/app/hocket/core/SettingKeys.kt @@ -19,6 +19,8 @@ object SettingKeys { const val RATINGS_LOVE_BRIDGE_ENABLED = "ratings.loveBridge.enabled" const val RATINGS_LOVE_BRIDGE_THRESHOLD = "ratings.loveBridge.threshold" const val BATTERY_AUTO_ENGAGE = "battery.autoEngage" + /** Other apps (Auto, Wear, media browsers) may browse and control playback. Off by default. */ + const val MEDIA_EXTERNAL_CONTROL = "media.externalControl" const val BATTERY_LYRICS_FPS = "battery.lyricsFps" const val BATTERY_SMALL_ARTWORK = "battery.smallArtwork" const val BATTERY_PAUSE_PREFETCH = "battery.pausePrefetch" diff --git a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt index f2482ce..6222a8b 100644 --- a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt +++ b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt @@ -138,6 +138,7 @@ class FakeCore( def(SettingKeys.RATINGS_LOVE_BRIDGE_ENABLED, "false", SettingScope.AccountSynced) def(SettingKeys.RATINGS_LOVE_BRIDGE_THRESHOLD, "4", SettingScope.AccountSynced) def(SettingKeys.BATTERY_AUTO_ENGAGE, "true", SettingScope.DeviceLocal) + def(SettingKeys.MEDIA_EXTERNAL_CONTROL, "false", SettingScope.DeviceLocal) def(SettingKeys.BATTERY_LYRICS_FPS, "30", SettingScope.DeviceLocal) def(SettingKeys.BATTERY_SMALL_ARTWORK, "true", SettingScope.DeviceLocal) def(SettingKeys.BATTERY_PAUSE_PREFETCH, "true", SettingScope.DeviceLocal) diff --git a/android/playback/src/main/java/app/hocket/playback/ArtworkProvider.kt b/android/playback/src/main/java/app/hocket/playback/ArtworkProvider.kt index 115f303..6be39fc 100644 --- a/android/playback/src/main/java/app/hocket/playback/ArtworkProvider.kt +++ b/android/playback/src/main/java/app/hocket/playback/ArtworkProvider.kt @@ -36,12 +36,13 @@ class ArtworkProvider : ContentProvider() { fun uri(context: Context, coverArt: String, size: Int = ArtworkSizes.GRID): Uri = Uri.Builder().scheme("content").authority(authority(context)).appendPath(size.toString()).appendPath(coverArt).build() - /** Lets [packageName] (a connected media controller) open artwork URIs for this process's life. */ + /** Lets [packageName] (a connected media controller) open artwork URIs until [revokeAll]. */ fun allow(packageName: String) { allowed += packageName } internal fun isAllowed(packageName: String?, own: String): Boolean = packageName != null && (packageName == own || packageName in allowed) - internal fun resetForTests() = allowed.clear() + /** Nobody but this app may open artwork again until controllers reconnect. */ + fun revokeAll() = allowed.clear() } override fun onCreate(): Boolean = true diff --git a/android/playback/src/main/java/app/hocket/playback/ExternalControl.kt b/android/playback/src/main/java/app/hocket/playback/ExternalControl.kt new file mode 100644 index 0000000..80e4b3a --- /dev/null +++ b/android/playback/src/main/java/app/hocket/playback/ExternalControl.kt @@ -0,0 +1,64 @@ +package app.hocket.playback + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Process + +/** + * "Allow control by other apps" (`media.externalControl`): whether apps other than the system may + * connect to the media session, i.e. browse the library, see the queue and control playback + * (Android Auto, Wear, media browsers, automation apps). Off by default. + * + * The system's own controls always work, whatever the setting: this app, and callers holding + * `MEDIA_CONTENT_CONTROL` (a privileged permission: SystemUI's notification and lock-screen + * controls, Bluetooth AVRCP, the system itself; checked by uid). Before Android 9 the platform does + * not say which app sent a legacy `MediaController` command, so those anonymous callers are let + * through there; otherwise the system's controls would stop working on those versions. Media3's + * notification controller is checked by the session ([MediaSessionBridge]). + * + * The value belongs to the core; this class keeps a copy in SharedPreferences so that a controller + * connecting while the service starts (before the core's snapshot arrives) is judged by the last + * known choice rather than by the default. The copy is excluded from backups, like the core's own + * store. + */ +class ExternalControl( + context: Context, + private val sdk: Int = Build.VERSION.SDK_INT, + /** True when [uid] holds `MEDIA_CONTENT_CONTROL` (or is the system). */ + private val hasMediaContentControl: (uid: Int) -> Boolean = { uid -> + context.applicationContext.checkPermission(MEDIA_CONTENT_CONTROL, -1, uid) == PackageManager.PERMISSION_GRANTED + }, +) { + companion object { + const val PREFS = "hocket-media-control" + private const val KEY_ALLOWED = "allowed" + const val MEDIA_CONTENT_CONTROL = "android.permission.MEDIA_CONTENT_CONTROL" + /** What Media3 reports for a legacy controller the platform did not identify (before API 28). */ + const val LEGACY_CONTROLLER = "android.media.session.MediaController" + } + + private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** Whether other apps are allowed in now. */ + var allowed: Boolean = prefs.getBoolean(KEY_ALLOWED, false) + private set + + /** Applies the core's value; true when it changed. */ + fun update(allowed: Boolean): Boolean { + if (allowed == this.allowed) return false + this.allowed = allowed + prefs.edit().putBoolean(KEY_ALLOWED, allowed).apply() + return true + } + + /** This app or the system (see the class docs): never subject to the setting. */ + fun isSystem(packageName: String, uid: Int): Boolean = when { + uid == Process.myUid() -> true + uid >= 0 && hasMediaContentControl(uid) -> true + sdk < Build.VERSION_CODES.P && packageName == LEGACY_CONTROLLER -> true + else -> false + } + + fun permits(packageName: String, uid: Int): Boolean = allowed || isSystem(packageName, uid) +} diff --git a/android/playback/src/main/java/app/hocket/playback/MediaSessionBridge.kt b/android/playback/src/main/java/app/hocket/playback/MediaSessionBridge.kt index bda244d..51eba68 100644 --- a/android/playback/src/main/java/app/hocket/playback/MediaSessionBridge.kt +++ b/android/playback/src/main/java/app/hocket/playback/MediaSessionBridge.kt @@ -5,11 +5,13 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.session.CommandButton import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionCommands import androidx.media3.session.SessionError import androidx.media3.session.SessionResult import app.hocket.core.Commands @@ -36,6 +38,10 @@ import kotlinx.coroutines.launch * library item here, before the player dispatches it. Every connected controller may open artwork * URIs ([ArtworkProvider.allow]). The "recent" root (system playback resumption) is refused, as * resumption is. + * + * Who may connect is [control]'s ("Allow control by other apps"): with it off, only the system's own + * controls and Media3's notification controller are accepted, and every request is checked again, + * so turning it off also cuts off controllers that are already connected ([setExternalControl]). */ class MediaSessionBridge( context: Context, @@ -44,6 +50,7 @@ class MediaSessionBridge( launchIntent: Intent?, private val browser: LibraryBrowser, private val scope: CoroutineScope, + private val control: ExternalControl, ) { companion object { const val CMD_LOVE = "app.hocket.LOVE" @@ -54,6 +61,13 @@ class MediaSessionBridge( } private val customCommands = listOf(CMD_LOVE, CMD_SHUFFLE, CMD_REPEAT, CMD_RATE).map { SessionCommand(it, Bundle.EMPTY) } + /** Library commands too: without them a browser's root and children requests are refused. */ + private val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon().apply { customCommands.forEach { add(it) } }.build() + + private fun permitted(session: MediaSession, controller: MediaSession.ControllerInfo): Boolean = + session.isMediaNotificationController(controller) || control.permits(controller.packageName, controller.uid) + + private fun refused(): ListenableFuture> = Futures.immediateFuture(LibraryResult.ofError(SessionError.ERROR_PERMISSION_DENIED)) /** Runs a browse request on [scope] and completes the future with its result. */ private fun async(block: suspend () -> T): ListenableFuture { @@ -64,15 +78,16 @@ class MediaSessionBridge( val session: MediaLibraryService.MediaLibrarySession = MediaLibraryService.MediaLibrarySession.Builder(context, player, object : MediaLibraryService.MediaLibrarySession.Callback { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + if (!permitted(session, controller)) return MediaSession.ConnectionResult.reject() ArtworkProvider.allow(controller.packageName) - val commands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().apply { customCommands.forEach { add(it) } }.build() return MediaSession.ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(commands) + .setAvailableSessionCommands(sessionCommands) .setMediaButtonPreferences(buttons(player.state)) .build() } override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture { + if (!permitted(session, controller)) return Futures.immediateFuture(SessionResult(SessionError.ERROR_PERMISSION_DENIED)) when (customCommand.customAction) { CMD_LOVE -> dispatch(Commands.mediaSessionCommand(MediaSessionAction.Love)) CMD_SHUFFLE -> dispatch(Commands.mediaSessionCommand(MediaSessionAction.Shuffle)) @@ -90,20 +105,26 @@ class MediaSessionBridge( } override fun onGetLibraryRoot(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, params: MediaLibraryService.LibraryParams?): ListenableFuture> { + if (!permitted(session, browser)) return refused() if (params?.isRecent == true) return Futures.immediateFuture(LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED)) return Futures.immediateFuture(LibraryResult.ofItem(this@MediaSessionBridge.browser.root(), MediaLibraryService.LibraryParams.Builder().setExtras(LibraryBrowser.rootExtras()).build())) } - override fun onGetChildren(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, page: Int, pageSize: Int, params: MediaLibraryService.LibraryParams?): ListenableFuture>> = async { + override fun onGetChildren(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, page: Int, pageSize: Int, params: MediaLibraryService.LibraryParams?): ListenableFuture>> { + if (!permitted(session, browser)) return refused() + return async { val children = this@MediaSessionBridge.browser.children(parentId, page, pageSize) if (children == null) LibraryResult.ofError(SessionError.ERROR_BAD_VALUE) else LibraryResult.ofItemList(children, params) + } } - override fun onGetItem(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String): ListenableFuture> = async { - this@MediaSessionBridge.browser.item(mediaId)?.let { LibraryResult.ofItem(it, null) } ?: LibraryResult.ofError(SessionError.ERROR_BAD_VALUE) + override fun onGetItem(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String): ListenableFuture> { + if (!permitted(session, browser)) return refused() + return async { this@MediaSessionBridge.browser.item(mediaId)?.let { LibraryResult.ofItem(it, null) } ?: LibraryResult.ofError(SessionError.ERROR_BAD_VALUE) } } override fun onSearch(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, params: MediaLibraryService.LibraryParams?): ListenableFuture> { + if (!permitted(session, browser)) return refused() scope.launch { val count = runCatching { this@MediaSessionBridge.browser.search(query).size }.getOrDefault(0) session.notifySearchResultChanged(browser, query, count, params) @@ -111,13 +132,18 @@ class MediaSessionBridge( return Futures.immediateFuture(LibraryResult.ofVoid()) } - override fun onGetSearchResult(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, page: Int, pageSize: Int, params: MediaLibraryService.LibraryParams?): ListenableFuture>> = async { + override fun onGetSearchResult(session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, page: Int, pageSize: Int, params: MediaLibraryService.LibraryParams?): ListenableFuture>> { + if (!permitted(session, browser)) return refused() + return async { val all = this@MediaSessionBridge.browser.search(query) val from = (page.toLong() * pageSize).coerceIn(0, all.size.toLong()).toInt() LibraryResult.ofItemList(all.subList(from, (from + pageSize.coerceAtLeast(0)).coerceAtMost(all.size)), params) + } } - override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList): ListenableFuture> = async { + override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList): ListenableFuture> { + if (!permitted(mediaSession, controller)) return Futures.immediateFailedFuture(SecurityException("control by other apps is off")) + return async { // Library ids pass through to the player; a search request ("play X") becomes the item // it names. Anything unresolvable is dropped (the player then does nothing). mediaItems.mapNotNull { item -> @@ -128,6 +154,7 @@ class MediaSessionBridge( else -> null } }.toMutableList() + } } }) .setId("hocket") @@ -138,6 +165,27 @@ class MediaSessionBridge( } .build() + /** + * The core's "Allow control by other apps" value. Turning it off revokes artwork access and + * strips every connected controller that is not the system's of all commands; turning it on + * gives those controllers their commands back. Main thread. + */ + fun setExternalControl(allowed: Boolean) { + if (!control.update(allowed)) return + if (!allowed) ArtworkProvider.revokeAll() + for (controller in session.connectedControllers) { + val system = session.isMediaNotificationController(controller) || control.isSystem(controller.packageName, controller.uid) + when { + system -> ArtworkProvider.allow(controller.packageName) + allowed -> { + ArtworkProvider.allow(controller.packageName) + session.setAvailableCommands(controller, sessionCommands, MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS) + } + else -> session.setAvailableCommands(controller, SessionCommands.EMPTY, Player.Commands.EMPTY) + } + } + } + /** Push a new queue view to the player (its playlist). Main thread. */ fun applyQueue(queue: QueueView) = player.applyQueue(queue) diff --git a/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt b/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt index eb87862..b751745 100644 --- a/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt +++ b/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt @@ -30,7 +30,8 @@ import kotlinx.coroutines.launch * `Event.Backend` -> [ExoBackend], `Event.MediaSession` and `Event.QueueChanged` -> * [MediaSessionBridge] (the session's state and its queue timeline). * - Publishes the library to other apps (Android Auto, Wear, Assistant, `MediaBrowser`s) through - * [LibraryBrowser] on the first server, telling subscribed browsers when it changes. + * [LibraryBrowser] on the first server, telling subscribed browsers when it changes, only while + * "Allow control by other apps" (`media.externalControl`, [ExternalControl]) is on. * - Registers the [NetworkMonitor] and [BatterySaverMonitor]. * - Feeds [ConnectRoutes] (devices, lease owner, session state) so that while another device plays * the session reports remote playback and [ConnectRouteProvider] names that device as the output; @@ -94,7 +95,7 @@ class PlaybackService : MediaLibraryService() { val launch = packageManager.getLaunchIntentForPackage(packageName) val browser = LibraryBrowser(this, { core }, { serverId }, scope) val player = CoreSessionPlayer(Looper.getMainLooper(), ::dispatch, media = browser, artwork = { t -> t.coverArt?.let { ArtworkProvider.uri(this, it) } }) - bridge = MediaSessionBridge(this, player, ::dispatch, launch, browser, scope) + bridge = MediaSessionBridge(this, player, ::dispatch, launch, browser, scope, ExternalControl(this)) addSession(bridge.session) network = NetworkMonitor(this, ::dispatch) battery = BatterySaverMonitor(this, ::dispatch) @@ -134,7 +135,10 @@ class PlaybackService : MediaLibraryService() { is Event.ConnectionChanged -> clockOffsetMs = event.data.state.clockOffsetMs is Event.Started -> applySnapshot(event.data.snapshot) is Event.Snapshot -> applySnapshot(event.data.snapshot) - is Event.SettingChanged -> if (event.data.setting.key == SettingKeys.BATTERY_AUTO_ENGAGE) battery.automatic = event.data.setting.value.trim() != "false" + is Event.SettingChanged -> when (event.data.setting.key) { + SettingKeys.BATTERY_AUTO_ENGAGE -> battery.automatic = event.data.setting.value.trim() != "false" + SettingKeys.MEDIA_EXTERNAL_CONTROL -> bridge.setExternalControl(event.data.setting.value.trim() == "true") + } else -> Unit } } @@ -148,6 +152,7 @@ class PlaybackService : MediaLibraryService() { ConnectRoutes.onOwner(snapshot.transport.lease.owner) applySession(snapshot.mediaSession) battery.automatic = snapshot.settings.firstOrNull { it.key == SettingKeys.BATTERY_AUTO_ENGAGE }?.value?.trim() != "false" + bridge.setExternalControl(snapshot.settings.firstOrNull { it.key == SettingKeys.MEDIA_EXTERNAL_CONTROL }?.value?.trim() == "true") } /** Session state to the bridge, remote when another Connect device plays it. */ diff --git a/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt b/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt index 2108894..c2dcbbd 100644 --- a/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt +++ b/android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt @@ -70,7 +70,7 @@ class ConnectRoutesTest { val player = CoreSessionPlayer(Looper.getMainLooper(), {}) val context = ApplicationProvider.getApplicationContext() val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined) - val bridge = MediaSessionBridge(context, player, {}, null, LibraryBrowser(context, { error("unused") }, { null }, scope), scope) + val bridge = MediaSessionBridge(context, player, {}, null, LibraryBrowser(context, { error("unused") }, { null }, scope), scope, ExternalControl(context)) try { bridge.apply(state(owns = false), 0.0, remote = true) shadowOf(Looper.getMainLooper()).idle() diff --git a/android/playback/src/test/java/app/hocket/playback/ExternalControlTest.kt b/android/playback/src/test/java/app/hocket/playback/ExternalControlTest.kt new file mode 100644 index 0000000..d9d801d --- /dev/null +++ b/android/playback/src/test/java/app/hocket/playback/ExternalControlTest.kt @@ -0,0 +1,59 @@ +package app.hocket.playback + +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Process +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * "Allow control by other apps": off by default; the system's own controls always get in; the last + * value survives a restart so an early controller is judged by it. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34], application = Application::class) +class ExternalControlTest { + private val app: Application get() = ApplicationProvider.getApplicationContext() + private val systemUiUid = 10_050 + private val otherAppUid = 10_200 + + private fun control(sdk: Int = 34) = ExternalControl(app, sdk) { uid -> uid == systemUiUid } + + @Before + fun clear() { app.getSharedPreferences(ExternalControl.PREFS, Context.MODE_PRIVATE).edit().clear().commit() } + + @Test + fun otherAppsAreRefusedByDefault() { + val c = control() + assertFalse(c.allowed) + assertFalse(c.permits("com.google.android.projection.gearhead", otherAppUid)) + assertFalse("an unknown uid is not trusted", c.permits("com.example", -1)) + } + + @Test + fun theSystemsControlsAlwaysWork() { + val c = control() + assertTrue("SystemUI / Bluetooth hold MEDIA_CONTENT_CONTROL", c.permits("com.android.systemui", systemUiUid)) + assertTrue("this app", c.permits(app.packageName, Process.myUid())) + assertFalse("an anonymous legacy controller is identifiable from API 28", c.permits(ExternalControl.LEGACY_CONTROLLER, -1)) + assertTrue("before API 28 it cannot be told apart from the system", control(Build.VERSION_CODES.O_MR1).permits(ExternalControl.LEGACY_CONTROLLER, -1)) + } + + @Test + fun turningItOnLetsOtherAppsInAndIsRemembered() { + val c = control() + assertTrue(c.update(true)) + assertFalse("no change", c.update(true)) + assertTrue(c.permits("com.example.browser", otherAppUid)) + assertTrue("a restarted service starts from the last value", control().allowed) + assertTrue(c.update(false)) + assertFalse(control().permits("com.example.browser", otherAppUid)) + } +} diff --git a/android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt b/android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt index f7cd576..2f6f53f 100644 --- a/android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt +++ b/android/playback/src/test/java/app/hocket/playback/LibraryBrowserTest.kt @@ -152,7 +152,7 @@ class LibraryBrowserTest { @Test fun artworkIsOnlyForThisAppAndConnectedControllers() { - ArtworkProvider.resetForTests() + ArtworkProvider.revokeAll() assertTrue(ArtworkProvider.isAllowed(app.packageName, app.packageName)) assertFalse(ArtworkProvider.isAllowed("com.example.snoop", app.packageName)) assertFalse(ArtworkProvider.isAllowed(null, app.packageName)) diff --git a/crates/hocket-core/src/settings/registry.rs b/crates/hocket-core/src/settings/registry.rs index c5d210e..a201eb5 100644 --- a/crates/hocket-core/src/settings/registry.rs +++ b/crates/hocket-core/src/settings/registry.rs @@ -63,6 +63,9 @@ pub mod keys { pub const RATINGS_LOVE_BRIDGE_ENABLED: &str = "ratings.loveBridge.enabled"; pub const RATINGS_LOVE_BRIDGE_THRESHOLD: &str = "ratings.loveBridge.threshold"; pub const BATTERY_AUTO_ENGAGE: &str = "battery.autoEngage"; + /// Whether other apps (Android Auto, Wear, media browsers) may browse the library and control + /// playback. The system's own controls (notification, lock screen, Bluetooth) always work. + pub const MEDIA_EXTERNAL_CONTROL: &str = "media.externalControl"; pub const BATTERY_LYRICS_FPS: &str = "battery.lyricsFps"; pub const BATTERY_SMALL_ARTWORK: &str = "battery.smallArtwork"; pub const BATTERY_PAUSE_PREFETCH: &str = "battery.pausePrefetch"; @@ -335,6 +338,10 @@ pub static REGISTRY: &[SettingDef] = &[ def(BATTERY_AUTO_ENGAGE, Local, SettingKind::Bool, || { json!(true) }), + // Device-local: letting other apps in is a decision about this device, never synced. + def(MEDIA_EXTERNAL_CONTROL, Local, SettingKind::Bool, || { + json!(false) + }), def( BATTERY_LYRICS_FPS, Local, diff --git a/crates/hocket-core/src/settings/strings.rs b/crates/hocket-core/src/settings/strings.rs index 8e05c65..17217de 100644 --- a/crates/hocket-core/src/settings/strings.rs +++ b/crates/hocket-core/src/settings/strings.rs @@ -23,6 +23,7 @@ pub const TEXTS: &[SettingText] = &[ t("lyrics.showTranslations", "Show translations", "Show translated lines under the original when the server provides them."), t("ratings.loveBridge.enabled", "Love highly rated tracks", "When you rate a track at or above the threshold, also mark it loved. One-way; lowering a rating never unloves."), t("ratings.loveBridge.threshold", "Love threshold", "Star rating at or above which a track is marked loved."), + t("media.externalControl", "Allow control by other apps", "Let other apps such as Android Auto, Wear and media browsers browse your library, see the queue and control playback. The system's own media controls always work."), t("battery.autoEngage", "Battery saver on battery", "Engage battery saver automatically when running on battery."), t("battery.lyricsFps", "Battery saver lyrics frame rate", "Frame rate cap for lyric animations while battery saver is engaged."), t("battery.smallArtwork", "Battery saver small artwork", "Use the smaller artwork size while battery saver is engaged."), diff --git a/desktop/src/main/fake-core/index.ts b/desktop/src/main/fake-core/index.ts index a26a0b5..cd723be 100644 --- a/desktop/src/main/fake-core/index.ts +++ b/desktop/src/main/fake-core/index.ts @@ -1789,6 +1789,7 @@ export class FakeCore implements CoreHandle { ["ratings.loveBridge.enabled", false, "accountSynced"], ["ratings.loveBridge.threshold", 4, "accountSynced"], ["battery.autoEngage", true, "deviceLocal"], + ["media.externalControl", false, "deviceLocal"], ["battery.lyricsFps", 30, "deviceLocal"], ["battery.smallArtwork", true, "deviceLocal"], ["battery.pausePrefetch", true, "deviceLocal"],