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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<key>`, 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/<server>/<id>/<index>` 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://<package>.artwork/<size>/<coverArt>` 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
Expand Down
5 changes: 5 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->

<!-- Android Auto lists media apps that declare this; the browse tree is PlaybackService's. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />

<activity
android:name="app.hocket.MainActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ private fun AccentChoices(dynamicLabel: String, swatches: List<Pair<String, Stri
}
}

/** Playback & queue: queue mode, how many recent queues to keep, autoplay, sleep-timer defaults. */
/**
* Playback & queue: queue mode, how many recent queues to keep, autoplay, sleep-timer defaults, and
* whether other apps (Android Auto, Wear, media browsers) may browse and control playback.
*/
@Composable
fun PlaybackSettingsScreen(nav: NavHostController) {
val client = LocalCoreClient.current
Expand All @@ -176,6 +179,7 @@ fun PlaybackSettingsScreen(nav: NavHostController) {
val mode = setting(SettingKeys.QUEUE_MODE)
val sleepMinutes = setting(SettingKeys.SLEEP_DEFAULT_MINUTES)
val sleepEnd = setting(SettingKeys.SLEEP_STOP_AT_END_OF_TRACK)
val externalControl = setting(SettingKeys.MEDIA_EXTERNAL_CONTROL)
SubScreen(nav, stringResource(R.string.settings_category_playback)) {
val current = queue.mode
val appleLabel = stringResource(R.string.queue_mode_apple)
Expand All @@ -202,6 +206,8 @@ fun PlaybackSettingsScreen(nav: NavHostController) {
ChoiceRow(listOf(15, 30, 45, 60, 90).map { it to it.toString() }, isSelected = { minutes == it }, onSelect = { sleepMinutes.setInt(it) }, modifier = Modifier.padding(horizontal = 16.dp),
describe = { m -> 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")
}
}

Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@
<string name="sleep_off">Off</string>
<string name="sleep_minutes">%1$d min</string>
<string name="sleep_end_of_track">Stop at end of track</string>
<string name="settings_external_control">Allow control by other apps</string>
<string name="settings_external_control_summary">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.</string>
<string name="sleep_active">Stops in %1$s</string>
<string name="sleep_active_end_of_track">Stops at end of track</string>
<string name="sleep_start">Start</string>
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/res/xml/automotive_app_desc.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
5 changes: 4 additions & 1 deletion android/app/src/main/res/xml/backup_rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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). -->
<exclude domain="file" path="hocket/cache" />
<exclude domain="file" path="hocket/downloads" />
<exclude domain="file" path="hocket/hocket.sqlite" />
Expand All @@ -16,4 +18,5 @@
<exclude domain="sharedpref" path="hocket-core.xml" />
<exclude domain="sharedpref" path="hocket-credentials.xml" />
<exclude domain="sharedpref" path="hocket-credential-names.xml" />
<exclude domain="sharedpref" path="hocket-media-control.xml" />
</full-backup-content>
2 changes: 2 additions & 0 deletions android/app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<exclude domain="sharedpref" path="hocket-core.xml" />
<exclude domain="sharedpref" path="hocket-credentials.xml" />
<exclude domain="sharedpref" path="hocket-credential-names.xml" />
<exclude domain="sharedpref" path="hocket-media-control.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="hocket/cache" />
Expand All @@ -24,5 +25,6 @@
<exclude domain="sharedpref" path="hocket-core.xml" />
<exclude domain="sharedpref" path="hocket-credentials.xml" />
<exclude domain="sharedpref" path="hocket-credential-names.xml" />
<exclude domain="sharedpref" path="hocket-media-control.xml" />
</device-transfer>
</data-extraction-rules>
28 changes: 27 additions & 1 deletion android/app/src/test/java/app/hocket/config/ManifestRulesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ class ManifestRulesTest {
(0 until childNodes.length).map { childNodes.item(it) }.filterIsInstance<Element>().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<String> = section.children("exclude").map { it.getAttribute("domain") + ":" + it.getAttribute("path") }.toSet()
Expand Down Expand Up @@ -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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions android/core/src/main/java/app/hocket/core/SettingKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions android/playback/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
Expand All @@ -34,6 +35,13 @@
<action android:name="android.media.MediaRoute2ProviderService" />
</intent-filter>
</service>
<!-- Cover art for the apps browsing the library (Auto, Wear, MediaBrowsers): they cannot read
the core's cache files. Exported because a controller opens the URI itself, but
ArtworkProvider only serves callers the session accepted as controllers, read-only. -->
<provider
android:name="app.hocket.playback.ArtworkProvider"
android:authorities="${applicationId}.artwork"
android:exported="true" />
<!-- Media buttons: Media3 declares its own androidx.media3.session.MediaButtonReceiver (it
FGS-starts the service only for a PLAY key while nothing plays). The legacy
androidx.media.session.MediaButtonReceiver must not be declared as well: exported with a
Expand Down
Loading
Loading