diff --git a/android/README.md b/android/README.md index 7edd816..549aedc 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,39 @@ 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. +- "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 + 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. --> + + + 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/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..e64f442 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + 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 60803bd..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() @@ -63,4 +65,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/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/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..c2dcbbd 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, 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/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/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 new file mode 100644 index 0000000..2f6f53f --- /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.revokeAll() + 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)) + } +} 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"],