How Linthra integrates with Android Auto, why a sideloaded build may not appear at first, how to test it on a real head unit or the Desktop Head Unit, and how to troubleshoot.
TL;DR — the most common reason Linthra "doesn't show up in Android Auto" is not a bug in Linthra: Android Auto hides media apps that were not installed from the Play Store until you enable Developer mode → Add unknown sources in the Android Auto settings. Linthra ships via F-Droid / GitHub Releases, so a sideloaded build is invisible to Android Auto until that toggle is on. See Troubleshooting → "Linthra doesn't appear".
| Capability | Status |
|---|---|
| Listed as an Android Auto media app | ✅ (manifest + automotive descriptor; needs "unknown sources" for sideloaded builds — see below) |
| Browsable tree (Songs / Albums / Artists / Playlists / Favorites / Offline / Queue) | ✅ |
| Album / artist grouping in the car | ✅ (derived from the synced catalog, same grouping as the phone) |
| Play a track from the car; queue the rest | ✅ |
| Transport controls (play / pause / next / previous / seek) | ✅ |
| Hardware / steering-wheel / Bluetooth Next / Previous | ✅ (same media session) |
| Lock-screen / notification Next / Previous | ✅ |
| Now-playing queue / Up Next on the head unit | ✅ (mirrors the app's queue) |
| Tap a row in the car's Up Next list (skip-to-queue-item) | ✅ |
| Shuffle / repeat from the car | ✅ |
| Now-playing metadata (title / artist / album / artwork) | ✅ (Jellyfin / local / Subsonic / Plex covers; the Subsonic & Plex covers are fetched + cached to a private file so no credential reaches artUri) |
| Offline / downloaded section | ✅ (user downloads only — smart pre-cache is not listed) |
| Cast-safe (no duplicate local playback while casting) | ✅ |
| Search from Android Auto | ❌ (not implemented — follow-up) |
| Folder grouping in the car | ❌ (Songs is a flat list; albums/artists are grouped) |
| Recently added / smart mixes in the car | ❌ (follow-up) |
| Custom car screens / content-style hints | ❌ (intentionally — safe browsing only) |
Linthra exposes a standard MediaBrowserService + media session via the
audio_service plugin. There is no
custom car UI and no driving-distraction surface: Android Auto renders the
browse tree and the now-playing card from the media session, which is the safe,
recommended model for a media app.
android/app/src/main/AndroidManifest.xmldeclares:- the
com.ryanheise.audioservice.AudioServiceservice withforegroundServiceType="mediaPlayback",android:exported="true", and theandroid.media.browse.MediaBrowserServiceintent-filter that Android Auto binds to; - the
com.ryanheise.audioservice.MediaButtonReceiverfor hardware / Bluetooth / Android Auto media-button intents; - the
com.google.android.gms.car.applicationmeta-data pointing atres/xml/automotive_app_desc.xml, which declares<uses name="media" />— this is what makes Android Auto treat Linthra as a media app.
- the
android/app/.../MainActivity.ktextendsAudioServiceActivity(not the plainFlutterActivity) so the Flutter activity binds to the session.lib/main.dartcallsconnectMediaSession(...)beforerunApp(...), so the handler registers as the app'smain()runs. When Android Auto starts the service cold (app never opened in this process), the samemain()runs and the browse tree is answerable from the persisted catalog/playlists/favourites — it does not wait for any phone screen to be built.lib/core/services/media_browser_tree.dart(MediaBrowserTree) is pure Dart: it builds the browse tree from theMusicLibraryRepository, aPlaybackStatesnapshot, and (when wired) thePlaylistRepository/FavoritesRepository/DownloadRepository. Noaudio_servicetype and no widget dependency, so it is fully unit-tested.- Albums and artists are derived from the track catalog (Linthra stores no
album/artist ids), via the shared grouping in
lib/core/catalog/library_grouping.dart— the same grouping the in-app Library tabs use, so the car and the phone show identical albums/artists. Browsing reads only the local synced catalog; it never calls a remote server or mints a stream URL. - The Offline section reads
DownloadRepository.downloadedTrackIds(), which reports only user-initiated downloads — smart pre-cached tracks are deliberately not marked downloaded, so they never appear here (it mirrors the in-app Downloads screen exactly). lib/core/services/linthra_audio_handler.dart(LinthraAudioHandler) is the only file that importsaudio_service. It mapsMediaNodes to media items, forwards transport commands to the singlePlaybackController, and turns a selected item into aPlaybackController.playTracks(...)call. It also mirrors the controller's queue out as the session's Up Next list and maps a tapped queue row (skipToQueueItem) back onto the controller's history / up-next jumps — so the car's queue stays in sync and is navigable without the handler ever touchingjust_audioor the cast SDK.
The car's Next / Previous / Play / Pause — whether tapped on the head-unit
screen, pressed on the steering wheel, or sent over Bluetooth / a wired headset
— all arrive as the same media-session callbacks (onSkipToNext,
onSkipToPrevious, onPlay, …). audio_service forwards them to
LinthraAudioHandler, which calls the matching PlaybackController method
(skipToNext, skipToPrevious, play, pause, seek, skipToQueueItem).
There is no separate Android-Auto playback path: the car drives the same
controller as the in-app player, so playback, queue, history, shuffle, and
repeat stay consistent however you press a button.
- The handler advertises the transport capabilities it implements (skip,
skip-to-queue-item, seek, shuffle, repeat) steadily in
systemActions, so a head unit that caches the capability set when it connects keeps its Next / Previous and queue-row buttons live regardless of where you are in the queue. - The visible notification / lock-screen buttons are still gated: the
skipToPreviousbutton only appears once a previous track exists andskipToNextonly while one is queued, so no dead button is ever shown. - At a queue boundary (Next on the last track, Previous on the first) the action is a safe no-op — the queue and the now-playing state are left untouched.
- Previous always steps to the previous track (it does not restart the current track first); there is no "double-press to go back" behaviour.
root
├── Songs → every catalog track (flat list)
├── Albums → albums (browsable)
│ └── <album> → that album's tracks, in track-number order
├── Artists → artists (browsable)
│ └── <artist> → that artist's tracks, album by album
├── Playlists → your playlists (only shown when you have some)
│ └── <playlist> → that playlist's tracks
├── Favorites → your liked tracks (only shown when you have some)
├── Offline → your downloaded tracks (only shown when you have some)
└── Queue → current track + up-next (only populated while something plays)
Selecting a track plays it and queues the rest of the list it was opened from (the album's tracks for an album track, the artist's for an artist track, the playlist's for a playlist track, …) — exactly like tapping a track in that screen on the phone.
Stable media IDs:
| Node | ID form |
|---|---|
| Songs category | library |
| Song / library track | library/<trackId> |
| Albums category | albums |
| An album (container) | album/<albumId> |
| Album track | album/<albumId>/<index> |
| Artists category | artists |
| An artist (container) | artist/<artistId> |
| Artist track | artist/<artistId>/<index> |
| Playlists category | playlists |
| A playlist (container) | playlist/<playlistId> |
| Playlist track | playlist/<playlistId>/<index> |
| Favorites category | favorites |
| Favorite track | favorite/<index> |
| Offline category | offline |
| Offline track | offline/<index> |
| Queue category | queue |
| Queue item | queue/<index> |
| Empty-state placeholder | empty |
<albumId> / <artistId> are URL-safe, opaque grouping ids (a base64url
token, or an unknown-album / unknown-artist sentinel) — never a name, path,
or token.
- Songs / Albums / Artists are always shown (they reflect the catalog). When the catalog is empty, opening one shows a friendly placeholder ("Sync your library first", "No albums yet", …) instead of a blank screen.
- Playlists / Favorites / Offline only appear when you actually have some, so the car never shows an empty dead-end. A favourite / playlist / downloaded track id that isn't in the on-device catalog yet is skipped (it can't be played until synced).
Android Auto media items are deliberately secret-free:
- A media item's id is built only from opaque catalog/track/playlist ids and small integer indices — never a Jellyfin/Subsonic access token or an authenticated stream URL.
Track.uriis the opaquejellyfin:<id>/subsonic:<id>scheme (or a local file/SAF path), not a stream URL. The authenticated stream URL is minted lazily at play time by the resolver inside the playback engine, and is never stored on a track or handed to the media browser.MediaItem.artUri(cover art) is only ever a credential-free, platform- loadable URI: the token-free image endpoint for Jellyfin, a privatecontent://for a local embedded cover or a fetched-and-cached Subsonic/Plex cover, ornull. An authenticated cover URL that embeds a credential (Subsonic's salt+tokengetCoverArt, or Plex'sX-Plex-Tokencover-art URL) is never put inartUri: Linthra fetches that cover itself and hands the session only the resulting privatecontent://(see the now-playing artwork note), so no credential rides along in artwork.- The diagnostic log (see below) prints only the category of a media id
(e.g.
library,album,artist,playlist,favorite,offline) and small counts — never a raw id, title, URI, or token. - Album/artist grouping ids and the cover-art URL never carry a token: browse
art is the token-free
Track.artworkUri(a public image endpoint, a privatefile:, or null). A credential-free reference (e.g. Subsonic'ssubsonic-cover:<id>) that hasn't been fetched to a local file is dropped to null for browse rows rather than handed over unloadable — only the now-playing cover is fetched and cached locally.
Because Android Auto visibility can't be asserted in CI, the integration logs a
small, secret-free trace under the Linthra.AndroidAuto tag. View it while
testing:
adb logcat | grep Linthra.AndroidAutoYou should see, in order:
media session attached (Android Auto browser ready)—AudioService.initsucceeded. If you instead seemedia session init failed: <Type>, the media session never attached (so the app won't appear / won't browse) — investigate that first.browse: root -> N children— Android Auto bound and requested the root. If you never see abrowse:line after connecting, Android Auto isn't binding (usually the "unknown sources" gate — see troubleshooting).browse: albums -> N children(orlibrary,artists,offline, …) — a category was opened;Ntells you whether the catalog is populated.play: album-track resolved=true— a selection resolved to something playable.resolved=falsemeans a stale id resolved to nothing.
flutter pub get
flutter build apk --debug # or: flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-debug.apkThen open Linthra once on the phone and (if you use Jellyfin/Navidrome) sign in and let the library sync. The Android Auto browse tree reads the persisted on-device catalog, so it is empty until the app has synced at least once.
The DHU is Google's official Android Auto emulator and is the fastest way to verify the browse tree and playback from a desk.
- Install Android Auto on the phone (Play Store) or use a phone with it built in.
- On the phone: open Android Auto settings → tap the version ~10 times to unlock Developer mode → in the overflow menu enable Add unknown sources (required so a sideloaded Linthra is listed) and Start head unit server.
- On the computer: install the Desktop Head Unit from the Android SDK
(
Android SDK → extras → Android Auto Desktop Head Unit emulator, binary at$ANDROID_SDK/extras/google/auto/desktop-head-unit). - Connect the phone by USB, enable USB debugging, then run:
adb forward tcp:5277 tcp:5277 $ANDROID_SDK/extras/google/auto/desktop-head-unit - In the DHU, open the media app launcher and confirm Linthra is listed. Open it, browse Songs / Albums / Artists (and Playlists / Favorites / Offline if you have any), play a track, and test the transport controls.
Reference: https://developer.android.com/training/cars/testing/dhu
Use the manual checklist below. You still need Developer mode → Add unknown sources enabled for a sideloaded build.
- Build and install the debug or release APK (see Build / install).
- Open Linthra once on the phone.
- Sign in / sync Jellyfin or Navidrome if you use one (so the catalog persists); download a couple of tracks for offline if you want to test the Offline row.
- Enable Android Auto Developer mode → Add unknown sources (sideloaded builds).
- Connect the phone to Android Auto (USB or wireless) — or start the DHU.
- Open the Android Auto app launcher / media list and confirm Linthra appears; open it.
- Browse Songs and play a track — confirm it starts and the rest of the list becomes up-next.
- Browse Albums, open an album, play a track — confirm the queue is that album, in track order.
- Browse Artists, open an artist, play a track — confirm the queue is that artist's tracks.
- Browse Playlists, open a playlist, play a track (if you have any).
- Browse Favorites and play a track (if you have any liked tracks).
- Browse Offline and play a downloaded track (if you have any) — confirm it plays (ideally with the phone offline, to prove no network is needed).
- Press the car / head-unit Next / Previous — confirm Linthra skips correctly (Previous steps back; it does not restart the current track first).
- Open the car's Up Next list and tap a row — confirm playback jumps to it.
- Test shuffle / repeat and Play / Pause — confirm both stay in sync.
- Lock the phone screen and press car Next — confirm music keeps playing and skips correctly (no drop-out during the track change).
- Reopen Linthra on the phone — confirm it shows the correct current track and no duplicate playback.
- With a Cast session active, select a track (and press Next) from Android Auto and confirm no duplicate audio starts on the phone (it follows the receiver).
- On the car screen, confirm no private server URL, token, or file path is ever shown (only titles, artists, albums).
- Skim
adb logcat | grep Linthra.AndroidAuto— confirm no tokens or authenticated stream URLs appear (only category labels and counts).
- Enable "unknown sources" (most common fix). Android Auto only lists Play-Store media apps unless you turn on Developer mode → Add unknown sources. Linthra is sideloaded (F-Droid / GitHub Releases), so this is required. Steps: Android Auto settings → tap Version ~10× → overflow menu → Developer settings → enable Add unknown sources → fully restart Android Auto (or reconnect).
- Open Linthra once on the phone after installing, so the app and its media service have run at least once.
- Confirm the session attached:
adb logcat | grep Linthra.AndroidAutoshould showmedia session attached. If it showsmedia session init failed, the media session isn't starting — that's the blocker. - Re-install and reconnect. Android Auto caches its media-app list; toggling the connection or restarting Android Auto refreshes it.
- The browse tree reads the persisted catalog. Open Linthra on the phone and let it scan a local folder and/or sync your Jellyfin/Navidrome library first. Until then, Songs / Albums / Artists show a friendly placeholder ("Sync your library first", "No albums yet", …) rather than a blank screen.
Playlists/Favorites/Offlineonly show at the root when you have some; a favourite / playlist / downloaded track that hasn't been synced to the on-device catalog is skipped.- Offline lists only user-downloaded tracks. Smart pre-cached tracks are not counted as downloads, so they don't appear here — download a track from the app to see it in the car.
- Check
adb logcat | grep Linthra.AndroidAutoforbrowse: albums -> 0 children— that confirms the bind works but the catalog is empty.
- Confirm
play: <category> resolved=trueappears in the log when you tap a track.resolved=falsemeans a stale id resolved to nothing (re-open the category to refresh). - On Android 13+, the media notification (and some controls) require the
POST_NOTIFICATIONSruntime permission — grant it when prompted on first launch, or in system app settings.
- When a Cast session is active, Android Auto's transport and track
selection are routed to the single
PlaybackController, which has suspended the local engine for the duration of the cast session. So selecting a track or pressing play from the car updates the queue and the cast receiver plays it — the phone does not start a second, duplicate stream. - Ending the cast session returns playback to the phone paused at the receiver's last position, so nothing surprise-starts.
- Sideloaded builds need "unknown sources" (see above) — a device setting, not something Linthra can change.
- Search from Android Auto is not implemented (voice "play …" and the car's search box). It's a planned follow-up: the browse leaves already have stable, resolvable ids, so search can return them without new playback plumbing.
- No folder grouping and no "Recently added" / smart-mixes rows in the car yet (both are deliberate follow-ups to keep this change focused and the car tree simple). Songs is a flat list; Albums and Artists are grouped.
- Large libraries are not paged: the browse tree reads the whole synced
catalog into memory and groups it per request. This is local-only (it never
calls a server), but a very large catalog builds a long list. Paging via the
MediaBrowserpage options is a follow-up. - Artists open a flat track list (album by album), not a list of album sub-folders. Selecting a track queues the whole artist; album sub-folders under an artist are a possible follow-up.
- The now-playing Up Next list mirrors the app's queue and you can tap a row to jump to it, but you can't reorder or remove queue items from the car (do that in the app). The browsable Queue category is likewise read + jump.
- Previous always steps to the previous track; there's no restart-then-go-back (single vs. double press) behaviour some apps use.
- Next on the last track / Previous on the first is a no-op; Next does not wrap to the start even with repeat-all on (only auto-advance at end-of-track wraps).
- The car experience is basic browsing, not a custom/polished car UI (no tabs, content-style hints, lyrics, or now-playing artwork tuning).
- Lock-screen / now-playing artwork covers Jellyfin (its token-free image URL),
local embedded covers (extracted during the scan into a private
file:), Subsonic/Navidrome, and Plex. A credentialed cover URL (Subsonic's salt+tokengetCoverArt, or Plex'sX-Plex-Tokencover-art URL) can't be handed to the session as a URL; Linthra instead:- fetches the cover itself (Subsonic server-downscaled via its
sizeparam; Plex as the stored thumb) and caches the bytes to a private file, keyed by a hash of the credential-free reference (subsonic-cover:/plex-thumb:) — never the URL; - pre-warms the now-playing + next few covers off the playback path
(now-playing first, retrying a transient miss on the next queue change), so a
cover is cached before its track reaches the now-playing card; if it lands
after the card was published art-less, a
coverReadyevent re-publishes the now-playing item at once (gated by the change check, so it never double-pushes or loops) instead of waiting for the next playback tick; - hands the session a credential-free
content://URI for the cover via aFileProvider(MediaArtworkFileProvider, authority…linthra.mediaartwork, serving only the hashed-filename cover cache —res/xml/media_artwork_paths.xml). The session loadsMediaItem.artUriin its own process, which can read acontent://URI (andaudio_servicealso decodes it in-process) but not an app-privatefile:path. The embedded album-art bitmap is also downscaled (artDownscaleWidth/Height) so it survives delivery to the car. - grants the media consumers read access to each cover URI. The provider
is
exported="false", so Android Auto / SystemUI (lock screen) / Bluetooth would otherwise get a permission denial reading the URI from their own processes.MediaArtworkFileProvideroverridesopenFiletograntUriPermission(<consumer>, uri, FLAG_GRANT_READ_URI_PERMISSION)for those well-known media hosts whenever Linthra itself opens a cover — whichaudio_servicedoes at metadata-publish time, just before the same URI is delivered to the consumers, so the read grant is already in place when they read it. Read-only, per-URI, best-effort, and never write.
- Why this path: earlier attempts set
artUrito a privatefile:, then acontent://with no grant. Real head-unit tests showed thefile:/no-grant paths fixed playback smoothness (downscale + pre-warm + a synchronous handler) but the cover stayed blank, because Android Auto loads the art URI in its own process and can't read an app-privatefile:(nor an ungrantedcontent://) — Jellyfin'shttpcover works precisely because Android Auto can fetch it. The grantedcontent://gives Android Auto a readable cover without ever exposing a credential. Still pending real-car confirmation. - Privacy: the
content://path is…/media_artwork/<sha256>.img— no username, password, token, salt, server URL, or auth query, anywhere in the URI, the filename, the metadata, logs, or the DB. A failed/slow fetch leaves the card art-less and never blocks playback. - The car browse-tree thumbnail for a private-
file:cover (a local embedded cover) may not be readable by the car's own process, so a Jellyfin server cover remains the most reliable browse thumbnail; this is a known limitation, not a regression.
- fetches the cover itself (Subsonic server-downscaled via its
- No MPRIS (Linux desktop media keys) yet.