android auto support

This commit is contained in:
Boof2015
2026-06-29 14:07:58 -04:00
parent 3558ea30e4
commit de7ad39d62
31 changed files with 2259 additions and 39 deletions
+23
View File
@@ -0,0 +1,23 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.astracar'
version = '0.1.0'
android {
namespace "expo.modules.astracar"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "androidx.media:media:1.6.0"
implementation "com.facebook.react:react-android"
}
@@ -0,0 +1,30 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<service
android:name="expo.modules.astracar.AstraCarMediaService"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<service
android:name="expo.modules.astracar.AstraCarCommandService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<provider
android:name="expo.modules.astracar.AstraCarArtworkProvider"
android:authorities="${applicationId}.astracar.artwork"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>
@@ -0,0 +1,52 @@
package expo.modules.astracar
import android.content.Context
import android.net.Uri
/**
* content:// URI scheme served by [AstraCarArtworkProvider]. Android Auto only loads
* artwork from `content://` (and `android.resource://`) URIs — never `file://` or
* `http(s)://` — so every browse-list icon and now-playing art URI routes through here.
*/
object AstraCarArtwork {
// Must match android:authorities="${applicationId}.astracar.artwork" in the manifest.
// context.packageName resolves to the applicationId at runtime.
private const val AUTHORITY_SUFFIX = ".astracar.artwork"
const val ART_ID_PLACEHOLDER = "__ASTRA_ART_ID__"
const val QUERY_FULL = "full"
// Bump to change every art URI string. The Auto host (gearhead) caches image-load
// FAILURES in Glide keyed by URI+size; URIs from the earlier builds (where the grant
// was missing) are otherwise byte-identical and keep serving the cached SecurityException
// instead of retrying. A version param makes them fresh URIs so they re-fetch.
private const val CACHE_VERSION = "1"
fun authority(context: Context): String = context.packageName + AUTHORITY_SUFFIX
/**
* Local cached artwork, resolved by the scanner's md5 file name (hash). `full=true`
* requests the full-res `artwork/<hash>` (for now-playing); the default prefers the
* 128px `artwork-thumbs/` file (fine for small browse-list icons).
*/
fun localUri(context: Context, hash: String, full: Boolean = false): Uri =
base(context)
.appendPath("local")
.appendPath(hash)
.appendQueryParameter("v", CACHE_VERSION)
.apply { if (full) appendQueryParameter(QUERY_FULL, "1") }
.build()
/** Remote (Subsonic/Jellyfin) cover art, downloaded + cached on demand. */
fun remoteUri(context: Context, sourceId: Long, artworkSourceId: String): Uri =
base(context)
.appendPath("remote")
.appendPath(sourceId.toString())
.appendPath(artworkSourceId)
.appendQueryParameter("v", CACHE_VERSION)
.build()
private fun base(context: Context): Uri.Builder =
Uri.Builder().scheme("content").authority(authority(context))
}
@@ -0,0 +1,134 @@
package expo.modules.astracar
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
private const val TAG = "AstraCarArt"
private const val DOWNLOAD_TIMEOUT_MS = 8_000
/**
* Serves artwork to Android Auto as `content://` URIs (the only scheme Auto accepts).
* `openFile` runs on a binder pool thread (never the main thread), so the bounded remote
* download here can't ANR the browse UI.
*
* content://<authority>/local/<hash> -> cached scanner file
* content://<authority>/remote/<sourceId>/<artworkSourceId> -> server cover (download + cache)
*/
class AstraCarArtworkProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
val ctx = context ?: throw FileNotFoundException("No context")
val segments = uri.pathSegments
val full = uri.getQueryParameter(AstraCarArtwork.QUERY_FULL) != null
val file = when (segments.firstOrNull()) {
"local" -> localFile(ctx, segments.getOrNull(1), full)
"remote" -> remoteFile(ctx, segments.getOrNull(1)?.toLongOrNull(), segments.getOrNull(2))
else -> null
}
if (file == null) {
Log.w(TAG, "openFile miss for $uri")
throw FileNotFoundException("No artwork for $uri")
}
Log.d(TAG, "openFile hit for $uri -> ${file.absolutePath}")
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}
private fun localFile(ctx: Context, hash: String?, full: Boolean): File? {
val clean = hash?.trim().orEmpty()
if (clean.isEmpty()) return null
val dot = clean.lastIndexOf('.')
val stem = if (dot > 0) clean.substring(0, dot) else clean
val thumb = File(File(ctx.filesDir, "artwork-thumbs"), "$stem.jpg")
val original = File(File(ctx.filesDir, "artwork"), clean)
// Now-playing wants full-res (128px thumbs look blurry on the now-playing card);
// browse icons prefer the small thumb. Each falls back to the other if missing.
val ordered = if (full) listOf(original, thumb) else listOf(thumb, original)
return ordered.firstOrNull { it.exists() }
}
private fun remoteFile(ctx: Context, sourceId: Long?, artworkSourceId: String?): File? {
if (sourceId == null || artworkSourceId.isNullOrBlank()) return null
val cacheDir = File(ctx.cacheDir, "astra-car-art").apply { mkdirs() }
val cacheFile = File(cacheDir, sha1("$sourceId:$artworkSourceId") + ".img")
if (cacheFile.exists() && cacheFile.length() > 0) return cacheFile
// art_auth is a full cover-art URL template with an id placeholder, built by JS from
// the (well-tested) Subsonic/Jellyfin URL builders so the provider stays auth-agnostic.
val template = artAuthTemplate(ctx, sourceId) ?: return null
val url = template.replace(AstraCarArtwork.ART_ID_PLACEHOLDER, Uri.encode(artworkSourceId))
return runCatching {
download(url, cacheFile)
cacheFile.takeIf { it.length() > 0 }
}.onFailure { Log.w(TAG, "remote art download failed for $sourceId/$artworkSourceId", it) }
.getOrNull()
}
private fun artAuthTemplate(ctx: Context, sourceId: Long): String? =
AstraCarDb.openReadable(ctx)?.use { db ->
db.rawQuery(
"SELECT art_auth FROM remote_sources WHERE id = ? LIMIT 1",
arrayOf(sourceId.toString()),
).use { cursor ->
if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null
}
}?.takeIf { it.isNotBlank() }
private fun download(url: String, dest: File) {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = DOWNLOAD_TIMEOUT_MS
readTimeout = DOWNLOAD_TIMEOUT_MS
instanceFollowRedirects = true
}
try {
if (connection.responseCode !in 200..299) {
throw FileNotFoundException("HTTP ${connection.responseCode}")
}
val tmp = File(dest.absolutePath + ".tmp")
connection.inputStream.use { input -> FileOutputStream(tmp).use(input::copyTo) }
if (!tmp.renameTo(dest)) {
tmp.copyTo(dest, overwrite = true)
tmp.delete()
}
} finally {
connection.disconnect()
}
}
private fun sha1(value: String): String =
MessageDigest.getInstance("SHA-1")
.digest(value.toByteArray())
.joinToString("") { "%02x".format(it) }
override fun getType(uri: Uri): String = "image/*"
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
@@ -0,0 +1,604 @@
package expo.modules.astracar
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import java.io.File
import java.util.Locale
private const val TAG = "AstraCarCatalog"
// Cap a node's children when the client doesn't paginate, so a large list (e.g. all albums)
// can't exceed the ~1MB Binder transaction limit — which silently drops the result and hangs
// the browser on a spinner. Auto pages large lists itself; this only guards the unpaged path.
private const val MAX_UNPAGED_CHILDREN = 500
private const val TRACK_ORDER =
"COALESCE(disc_number, 9999), COALESCE(track_number, 9999), title COLLATE NOCASE"
// Fully alias-qualified (every column `t.`): these columns are SELECTed from joins where
// the other table (favorites / playlist_tracks) also has an `added_at`, so an unqualified
// `added_at` is ambiguous and SQLite throws. Single-table queries alias `tracks t` too.
private const val TRACK_COLUMNS =
"t.id, t.path, t.title, t.artist, t.album, t.album_artist, t.album_identity_key, t.duration, " +
"t.track_number, t.disc_number, t.year, t.artwork_hash, t.source_type, t.source_id, " +
"t.source_track_id, t.artwork_source_id, t.added_at, t.modified_at"
class AstraCarCatalog(private val context: Context) {
fun loadChildren(parentId: String, options: Bundle? = null): List<MediaItem> {
val media = AstraCarMediaIds.decode(parentId) ?: AstraCarMediaId(kind = "root")
val items = openReadableDb()?.use { db -> childrenFor(db, media) }
?: if (media.kind == "root") rootItems() else emptyList()
return paginate(items, options)
}
fun loadItem(mediaId: String): MediaItem? {
val media = AstraCarMediaIds.decode(mediaId) ?: return null
return openReadableDb()?.use { db ->
when (media.kind) {
"section" -> sectionItem(media.section ?: return@use null)
"album" -> getAlbums(db).firstOrNull { it.identityKey == media.key }?.let(::albumItem)
"artist" -> getArtists(db).firstOrNull { it.artist == media.key }?.let(::artistItem)
"playlist" -> media.id?.let { id -> getPlaylists(db).firstOrNull { it.id == id } }?.let(::playlistItem)
"track" -> media.path?.let { path -> getTrackByPath(db, path) }?.let {
trackItem(it, contextFromTrackMedia(media))
}
"root" -> rootItem()
else -> null
}
}
}
private fun openReadableDb(): SQLiteDatabase? = AstraCarDb.openReadable(context)
private fun childrenFor(db: SQLiteDatabase, media: AstraCarMediaId): List<MediaItem> =
when (media.kind) {
"root" -> rootItems()
"section" -> when (media.section) {
"recent" -> getRecentlyPlayed(db).map { trackItem(it, AstraCarMediaId(kind = "section", section = "recent")) }
"favorites" -> getFavorites(db).map { trackItem(it, AstraCarMediaId(kind = "section", section = "favorites")) }
"playlists" -> getPlaylists(db).map(::playlistItem)
"albums" -> getAlbums(db).map(::albumItem)
"artists" -> getArtists(db).map(::artistItem)
else -> emptyList()
}
"playlist" -> media.id?.let { id ->
getPlaylistTracks(db, id).map { trackItem(it, AstraCarMediaId(kind = "playlist", id = id)) }
} ?: emptyList()
"album" -> media.key?.let { key ->
getAlbumTracks(db, key).map { trackItem(it, AstraCarMediaId(kind = "album", key = key)) }
} ?: emptyList()
"artist" -> media.key?.let { name ->
getArtistTracks(db, name).map { trackItem(it, AstraCarMediaId(kind = "artist", key = name)) }
} ?: emptyList()
else -> emptyList()
}
private fun rootItem(): MediaItem =
MediaItem(
MediaDescriptionCompat.Builder()
.setMediaId(AstraCarMediaIds.root)
.setTitle("Astra")
.build(),
MediaItem.FLAG_BROWSABLE,
)
private fun rootItems(): List<MediaItem> =
listOf(
sectionItem("recent"),
sectionItem("favorites"),
sectionItem("playlists"),
sectionItem("albums"),
sectionItem("artists"),
)
private fun sectionItem(section: String): MediaItem {
val title = when (section) {
"recent" -> "Recently Played"
"favorites" -> "Favorites"
"playlists" -> "Playlists"
"albums" -> "Albums"
"artists" -> "Artists"
else -> section.replaceFirstChar { it.titlecase(Locale.ROOT) }
}
return browsable(AstraCarMediaIds.section(section), title, null)
}
private fun albumItem(album: AlbumRow): MediaItem =
browsable(
AstraCarMediaIds.album(album.identityKey),
album.album,
album.artist,
artworkIconUri(album.artworkHash, album.sourceId, album.artworkSourceId),
"${album.trackCount} ${if (album.trackCount == 1L) "track" else "tracks"}",
)
private fun artistItem(artist: ArtistRow): MediaItem =
browsable(
AstraCarMediaIds.artist(artist.artist),
artist.artist,
"${artist.trackCount} ${if (artist.trackCount == 1L) "track" else "tracks"}",
artworkIconUri(artist.artworkHash, artist.sourceId, artist.artworkSourceId),
)
private fun playlistItem(playlist: PlaylistRow): MediaItem =
browsable(
AstraCarMediaIds.playlist(playlist.id),
playlist.name,
"${playlist.trackCount} ${if (playlist.trackCount == 1L) "track" else "tracks"}",
artworkIconUri(playlist.artworkHash, playlist.sourceId, playlist.artworkSourceId),
)
private fun trackItem(track: TrackRow, contextMedia: AstraCarMediaId): MediaItem {
val extras = Bundle().apply {
putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (track.duration * 1000).toLong())
}
val description = MediaDescriptionCompat.Builder()
.setMediaId(AstraCarMediaIds.track(track.path, contextMedia))
.setTitle(track.title)
.setSubtitle(track.artist)
.setDescription(track.album)
.setIconUri(artworkIconUri(track.artworkHash, track.sourceId, track.artworkSourceId))
.setExtras(extras)
.build()
return MediaItem(description, MediaItem.FLAG_PLAYABLE)
}
private fun browsable(
mediaId: String,
title: String,
subtitle: String?,
iconUri: Uri? = null,
description: String? = null,
): MediaItem {
val mediaDescription = MediaDescriptionCompat.Builder()
.setMediaId(mediaId)
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setIconUri(iconUri)
.build()
return MediaItem(mediaDescription, MediaItem.FLAG_BROWSABLE)
}
/**
* Browse-list icon URI: a local content:// URI when the scanner has cached art for
* `hash`, else a remote content:// URI for a remote track with server art, else null.
* Android Auto loads art only from content:// (never file:// / http), so everything
* routes through [AstraCarArtworkProvider].
*/
private fun artworkIconUri(hash: String?, sourceId: Long?, artworkSourceId: String?): Uri? {
localArtworkUri(hash)?.let { return it }
if (sourceId != null && !artworkSourceId.isNullOrBlank()) {
return AstraCarArtwork.remoteUri(context, sourceId, artworkSourceId)
}
return null
}
private fun localArtworkUri(hash: String?): Uri? {
val clean = hash?.trim().orEmpty()
if (clean.isEmpty()) return null
val dot = clean.lastIndexOf('.')
val stem = if (dot > 0) clean.substring(0, dot) else clean
val thumb = File(File(context.filesDir, "artwork-thumbs"), "$stem.jpg")
val full = File(File(context.filesDir, "artwork"), clean)
return if (thumb.exists() || full.exists()) AstraCarArtwork.localUri(context, clean) else null
}
private fun paginate(items: List<MediaItem>, options: Bundle?): List<MediaItem> {
val page = options?.getInt(MediaBrowserCompat.EXTRA_PAGE, -1) ?: -1
val pageSize = options?.getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1) ?: -1
if (page < 0 || pageSize <= 0) {
if (items.size > MAX_UNPAGED_CHILDREN) {
Log.w(TAG, "capping ${items.size} children to $MAX_UNPAGED_CHILDREN (client did not paginate)")
return items.take(MAX_UNPAGED_CHILDREN)
}
return items
}
val from = page * pageSize
if (from >= items.size) return emptyList()
val to = minOf(from + pageSize, items.size)
return items.subList(from, to)
}
private fun contextFromTrackMedia(media: AstraCarMediaId): AstraCarMediaId =
AstraCarMediaId(
kind = media.contextKind ?: "track",
section = media.contextSection,
key = media.contextKey,
id = media.contextId,
)
private fun queryTracks(
db: SQLiteDatabase,
sql: String,
args: Array<String> = emptyArray(),
): List<TrackRow> =
db.rawQuery(sql, args).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.toTrackRow())
}
}
private fun getRecentlyPlayed(db: SQLiteDatabase): List<TrackRow> =
queryTracks(
db,
"SELECT $TRACK_COLUMNS FROM playback_history h JOIN tracks t ON t.path = h.track_path " +
"ORDER BY h.last_played_at DESC LIMIT 24",
)
private fun getFavorites(db: SQLiteDatabase): List<TrackRow> =
queryTracks(
db,
"SELECT $TRACK_COLUMNS FROM favorites f JOIN tracks t ON t.path = f.track_path " +
"ORDER BY f.added_at DESC",
)
private fun getPlaylistTracks(db: SQLiteDatabase, playlistId: Long): List<TrackRow> =
queryTracks(
db,
"SELECT $TRACK_COLUMNS FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path " +
"WHERE pt.playlist_id = ? ORDER BY pt.position, pt.id",
arrayOf(playlistId.toString()),
)
private fun getAlbumTracks(db: SQLiteDatabase, identityKey: String): List<TrackRow> =
queryTracks(
db,
"SELECT $TRACK_COLUMNS FROM tracks t WHERE t.album_identity_key = ? ORDER BY $TRACK_ORDER",
arrayOf(identityKey),
)
private fun getTrackByPath(db: SQLiteDatabase, path: String): TrackRow? =
queryTracks(db, "SELECT $TRACK_COLUMNS FROM tracks t WHERE t.path = ? LIMIT 1", arrayOf(path)).firstOrNull()
private fun getAllTracks(db: SQLiteDatabase): List<TrackRow> =
queryTracks(
db,
"SELECT $TRACK_COLUMNS FROM tracks t ORDER BY t.artist COLLATE NOCASE, t.album COLLATE NOCASE, $TRACK_ORDER",
)
private fun getArtistTracks(db: SQLiteDatabase, artist: String): List<TrackRow> {
val mode = getArtistGroupingMode(db)
return getAllTracks(db).filter { trackMatchesBrowseArtist(it, normalizeKey(artist), mode) }
}
private fun getAlbums(db: SQLiteDatabase): List<AlbumRow> =
db.rawQuery(
"""
SELECT album_identity_key AS identity_key,
MAX(album) AS album,
MAX(COALESCE(album_artist, artist)) AS artist,
MAX(year) AS year,
MAX(artwork_hash) AS artwork_hash,
MAX(source_id) AS source_id,
MAX(artwork_source_id) AS artwork_source_id,
COUNT(*) AS track_count
FROM tracks
GROUP BY album_identity_key
ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE
""".trimIndent(),
emptyArray(),
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
AlbumRow(
identityKey = cursor.string("identity_key"),
album = cursor.string("album"),
artist = cursor.string("artist"),
artworkHash = cursor.nullableString("artwork_hash"),
sourceId = cursor.nullableLong("source_id"),
artworkSourceId = cursor.nullableString("artwork_source_id"),
trackCount = cursor.long("track_count"),
),
)
}
}
}
private fun getPlaylists(db: SQLiteDatabase): List<PlaylistRow> =
db.rawQuery(
"""
SELECT p.id, p.name,
(SELECT t.artwork_hash
FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL
ORDER BY pt.position, pt.id LIMIT 1) AS artwork_hash,
(SELECT t.source_id
FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id AND t.artwork_source_id IS NOT NULL
ORDER BY pt.position, pt.id LIMIT 1) AS source_id,
(SELECT t.artwork_source_id
FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id AND t.artwork_source_id IS NOT NULL
ORDER BY pt.position, pt.id LIMIT 1) AS artwork_source_id,
(SELECT COUNT(*)
FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id) AS track_count
FROM playlists p
ORDER BY (p.last_played_at IS NULL), p.last_played_at DESC, p.updated_at DESC
""".trimIndent(),
emptyArray(),
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
PlaylistRow(
id = cursor.long("id"),
name = cursor.string("name"),
artworkHash = cursor.nullableString("artwork_hash"),
sourceId = cursor.nullableLong("source_id"),
artworkSourceId = cursor.nullableString("artwork_source_id"),
trackCount = cursor.long("track_count"),
),
)
}
}
}
private fun getArtistGroupingMode(db: SQLiteDatabase): String =
db.rawQuery(
"SELECT value FROM settings WHERE key = ? LIMIT 1",
arrayOf("artist_grouping_mode"),
).use { cursor ->
if (cursor.moveToFirst() && cursor.string(0) == "fileTags") "fileTags" else "astra"
}
private fun getArtists(db: SQLiteDatabase): List<ArtistRow> =
buildArtistList(getAllTracks(db), getArtistGroupingMode(db))
private fun buildArtistList(tracks: List<TrackRow>, mode: String): List<ArtistRow> {
val byKey = linkedMapOf<String, ArtistAggregate>()
for (track in tracks) {
val names =
if (mode == "fileTags") listOf(resolveStrictBrowseArtist(track))
else getCanonicalArtistIndexNames(track)
val seen = mutableSetOf<String>()
for (name in names) {
val key = normalizeKey(name)
if (key.isEmpty() || !seen.add(key)) continue
val aggregate = byKey.getOrPut(key) {
ArtistAggregate(
artist = name,
trackCount = 0,
artworkHash = null,
artworkYear = -1,
artworkAddedAt = -1,
artworkModifiedAt = -1,
)
}
aggregate.trackCount += 1
// Remote tracks carry no local artwork_hash; keep the first server cover ref as a
// fallback so remote-only artists still get a tile (local hash wins when present).
if (aggregate.remoteArtworkSourceId == null &&
track.sourceId != null && !track.artworkSourceId.isNullOrBlank()
) {
aggregate.remoteSourceId = track.sourceId
aggregate.remoteArtworkSourceId = track.artworkSourceId
}
val artworkHash = track.artworkHash ?: continue
val candidateYear = track.year ?: -1
val better =
aggregate.artworkHash == null ||
candidateYear > aggregate.artworkYear ||
(candidateYear == aggregate.artworkYear &&
(track.addedAt > aggregate.artworkAddedAt ||
(track.addedAt == aggregate.artworkAddedAt && track.modifiedAt > aggregate.artworkModifiedAt)))
if (!better) continue
aggregate.artworkHash = artworkHash
aggregate.artworkYear = candidateYear
aggregate.artworkAddedAt = track.addedAt
aggregate.artworkModifiedAt = track.modifiedAt
}
}
return byKey.values
.map {
ArtistRow(
it.artist,
it.trackCount.toLong(),
it.artworkHash,
it.remoteSourceId,
it.remoteArtworkSourceId,
)
}
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.artist })
}
private fun normalizeDisplay(value: String?): String =
value.orEmpty().replace(Regex("\\s+"), " ").trim()
private fun normalizeKey(value: String?): String =
normalizeDisplay(value).lowercase(Locale.ROOT)
private fun splitCollaborators(rawArtist: String?): List<String> {
val normalized = normalizeDisplay(rawArtist)
if (normalized.isEmpty()) return emptyList()
val unified = normalized
.replace(Regex("\\s*;\\s*"), ",")
.replace(Regex("\\s+&\\s+"), ",")
.replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",")
.replace(Regex("\\s+(feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",")
return dedupeByKey(unified.split(","))
}
private fun splitAlbumArtistCollaborators(rawAlbumArtist: String?): List<String> {
val normalized = normalizeDisplay(rawAlbumArtist)
if (normalized.isEmpty()) return emptyList()
val unified = normalized
.replace(Regex("\\s*;\\s*"), ",")
.replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",")
.replace(Regex("\\s+(feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",")
return dedupeByKey(unified.split(","))
}
private fun dedupeByKey(parts: List<String>): List<String> {
val unique = linkedMapOf<String, String>()
for (part in parts) {
val display = normalizeDisplay(part)
val key = normalizeKey(display)
if (key.isEmpty() || unique.containsKey(key)) continue
unique[key] = display
}
return unique.values.toList()
}
private fun resolveStrictBrowseArtist(track: TrackRow): String {
val albumArtist = normalizeDisplay(track.albumArtist)
return albumArtist.ifEmpty { normalizeDisplay(track.artist).ifEmpty { "Unknown Artist" } }
}
private fun resolveCanonicalBrowseArtist(track: TrackRow): String {
val albumArtist = normalizeDisplay(track.albumArtist)
if (albumArtist.isNotEmpty()) {
return splitAlbumArtistCollaborators(albumArtist).firstOrNull() ?: albumArtist
}
return splitCollaborators(track.artist).firstOrNull() ?: "Unknown Artist"
}
private fun getCanonicalArtistIndexNames(track: TrackRow): List<String> {
val unique = linkedMapOf<String, String>()
fun add(name: String?) {
val display = normalizeDisplay(name)
val key = normalizeKey(display)
if (key.isEmpty() || unique.containsKey(key)) return
unique[key] = display
}
add(resolveCanonicalBrowseArtist(track))
val trackArtists = splitCollaborators(track.artist)
for (name in trackArtists) add(name)
if (trackArtists.isEmpty()) {
for (name in splitAlbumArtistCollaborators(track.albumArtist)) add(name)
}
return unique.values.toList()
}
private fun trackMatchesBrowseArtist(track: TrackRow, targetArtistKey: String, mode: String): Boolean {
val browseKey = normalizeKey(
if (mode == "fileTags") resolveStrictBrowseArtist(track) else resolveCanonicalBrowseArtist(track),
)
if (browseKey == targetArtistKey) return true
if (mode == "fileTags") return false
val albumArtistKey = normalizeKey(track.albumArtist)
if (albumArtistKey.isNotEmpty() && albumArtistKey == targetArtistKey) return true
val trackArtistKey = normalizeKey(track.artist)
if (trackArtistKey.isNotEmpty() && trackArtistKey == targetArtistKey) return true
if (splitAlbumArtistCollaborators(track.albumArtist).any { normalizeKey(it) == targetArtistKey }) return true
return splitCollaborators(track.artist).any { normalizeKey(it) == targetArtistKey }
}
}
private data class TrackRow(
val id: Long,
val path: String,
val title: String,
val artist: String,
val album: String,
val albumArtist: String?,
val albumIdentityKey: String,
val duration: Double,
val trackNumber: Long?,
val discNumber: Long?,
val year: Long?,
val artworkHash: String?,
val sourceType: String,
val sourceId: Long?,
val sourceTrackId: String?,
val artworkSourceId: String?,
val addedAt: Long,
val modifiedAt: Long,
)
private data class AlbumRow(
val identityKey: String,
val album: String,
val artist: String,
val artworkHash: String?,
val sourceId: Long?,
val artworkSourceId: String?,
val trackCount: Long,
)
private data class PlaylistRow(
val id: Long,
val name: String,
val artworkHash: String?,
val sourceId: Long?,
val artworkSourceId: String?,
val trackCount: Long,
)
private data class ArtistRow(
val artist: String,
val trackCount: Long,
val artworkHash: String?,
val sourceId: Long?,
val artworkSourceId: String?,
)
private data class ArtistAggregate(
val artist: String,
var trackCount: Int,
var artworkHash: String?,
var artworkYear: Long,
var artworkAddedAt: Long,
var artworkModifiedAt: Long,
var remoteSourceId: Long? = null,
var remoteArtworkSourceId: String? = null,
)
private fun Cursor.toTrackRow(): TrackRow =
TrackRow(
id = long("id"),
path = string("path"),
title = string("title"),
artist = string("artist"),
album = string("album"),
albumArtist = nullableString("album_artist"),
albumIdentityKey = string("album_identity_key"),
duration = double("duration"),
trackNumber = nullableLong("track_number"),
discNumber = nullableLong("disc_number"),
year = nullableLong("year"),
artworkHash = nullableString("artwork_hash"),
sourceType = string("source_type"),
sourceId = nullableLong("source_id"),
sourceTrackId = nullableString("source_track_id"),
artworkSourceId = nullableString("artwork_source_id"),
addedAt = long("added_at"),
modifiedAt = long("modified_at"),
)
private fun Cursor.string(name: String): String = string(getColumnIndexOrThrow(name))
private fun Cursor.string(index: Int): String = getString(index) ?: ""
private fun Cursor.nullableString(name: String): String? {
val index = getColumnIndexOrThrow(name)
return if (isNull(index)) null else getString(index)
}
private fun Cursor.long(name: String): Long = getLong(getColumnIndexOrThrow(name))
private fun Cursor.nullableLong(name: String): Long? {
val index = getColumnIndexOrThrow(name)
return if (isNull(index)) null else getLong(index)
}
private fun Cursor.double(name: String): Double = getDouble(getColumnIndexOrThrow(name))
@@ -0,0 +1,158 @@
package expo.modules.astracar
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
class AstraCarCommandService : HeadlessJsTaskService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// We're started via startForegroundService (so transport from the car works even when
// the app is backgrounded — the media-session callback grants the FGS-start allowlist).
// Promote immediately to satisfy the "call startForeground within ~5s" requirement.
if (intent?.getStringExtra(EXTRA_COMMAND) != null) {
promoteToForeground()
}
return super.onStartCommand(intent, flags, startId)
}
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val command = intent?.getStringExtra(EXTRA_COMMAND) ?: return null
val data = Arguments.createMap().apply {
putString("command", command)
intent.getBundleExtra(EXTRA_MEDIA)?.let { putMap("media", Arguments.fromBundle(it)) }
if (intent.hasExtra(EXTRA_QUERY)) putString("query", intent.getStringExtra(EXTRA_QUERY))
if (intent.hasExtra(EXTRA_FOCUS)) putString("focus", intent.getStringExtra(EXTRA_FOCUS))
if (intent.hasExtra(EXTRA_TITLE)) putString("title", intent.getStringExtra(EXTRA_TITLE))
if (intent.hasExtra(EXTRA_ARTIST)) putString("artist", intent.getStringExtra(EXTRA_ARTIST))
if (intent.hasExtra(EXTRA_ALBUM)) putString("album", intent.getStringExtra(EXTRA_ALBUM))
if (intent.hasExtra(EXTRA_PLAYLIST)) putString("playlist", intent.getStringExtra(EXTRA_PLAYLIST))
if (intent.hasExtra(EXTRA_POSITION)) putDouble("position", intent.getDoubleExtra(EXTRA_POSITION, 0.0))
}
return HeadlessJsTaskConfig("AstraCarCommand", data, 30_000, true)
}
override fun onHeadlessJsTaskFinish(taskId: Int) {
super.onHeadlessJsTaskFinish(taskId)
// The base impl stops the service when the last task finishes; drop the FGS notification.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
private fun promoteToForeground() {
val promoted = runCatching {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}.isSuccess
// If we couldn't promote (e.g. FGS-start not allowed), stop now rather than let the
// system kill the whole process with "did not call startForeground in time".
if (!promoted) stopSelf()
}
private fun buildNotification(): Notification {
ensureChannel()
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Astra")
.setContentText("Handling car controls")
.setSmallIcon(android.R.drawable.ic_media_play)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Car controls", NotificationManager.IMPORTANCE_LOW).apply {
setShowBadge(false)
},
)
}
companion object {
private const val EXTRA_COMMAND = "command"
private const val EXTRA_MEDIA = "media"
private const val EXTRA_QUERY = "query"
private const val EXTRA_FOCUS = "focus"
private const val EXTRA_TITLE = "title"
private const val EXTRA_ARTIST = "artist"
private const val EXTRA_ALBUM = "album"
private const val EXTRA_PLAYLIST = "playlist"
private const val EXTRA_POSITION = "position"
private const val CHANNEL_ID = "astra_car_commands"
private const val NOTIFICATION_ID = 0xACAB
fun startTransport(context: Context, command: String) {
start(context, Intent(context, AstraCarCommandService::class.java).putExtra(EXTRA_COMMAND, command))
}
fun startSeek(context: Context, positionMs: Long) {
start(
context,
Intent(context, AstraCarCommandService::class.java)
.putExtra(EXTRA_COMMAND, "seek")
.putExtra(EXTRA_POSITION, positionMs / 1000.0),
)
}
fun startPlayFromMediaId(context: Context, mediaId: String?) {
val media = AstraCarMediaIds.decode(mediaId) ?: return
start(
context,
Intent(context, AstraCarCommandService::class.java)
.putExtra(EXTRA_COMMAND, "playMediaId")
.putExtra(EXTRA_MEDIA, AstraCarMediaIds.toBundle(media)),
)
}
fun startPlayFromSearch(context: Context, query: String?, extras: Bundle?) {
val focus = extras?.getString(MediaStore.EXTRA_MEDIA_FOCUS)?.let(::normalizeFocus)
start(
context,
Intent(context, AstraCarCommandService::class.java)
.putExtra(EXTRA_COMMAND, "playSearch")
.putExtra(EXTRA_QUERY, query)
.putExtra(EXTRA_FOCUS, focus)
.putExtra(EXTRA_TITLE, extras?.getString(MediaStore.EXTRA_MEDIA_TITLE))
.putExtra(EXTRA_ARTIST, extras?.getString(MediaStore.EXTRA_MEDIA_ARTIST))
.putExtra(EXTRA_ALBUM, extras?.getString(MediaStore.EXTRA_MEDIA_ALBUM))
.putExtra(EXTRA_PLAYLIST, extras?.getString(MediaStore.EXTRA_MEDIA_PLAYLIST)),
)
}
private fun start(context: Context, intent: Intent) {
HeadlessJsTaskService.acquireWakeLockNow(context)
runCatching { ContextCompat.startForegroundService(context.applicationContext, intent) }
}
private fun normalizeFocus(value: String): String =
when {
value.contains("artist", ignoreCase = true) -> "artist"
value.contains("album", ignoreCase = true) -> "album"
value.contains("playlist", ignoreCase = true) -> "playlist"
value.contains("genre", ignoreCase = true) -> "genre"
else -> value
}
}
}
@@ -0,0 +1,30 @@
package expo.modules.astracar
import android.content.Context
import android.database.sqlite.SQLiteDatabase
/**
* Shared read access to the op-sqlite library database (`astra-library.db`), used by
* both the browse catalog and the artwork ContentProvider. op-sqlite stores the file at
* [Context.getDatabasePath] (the same dir we read here).
*/
object AstraCarDb {
const val DB_NAME = "astra-library.db"
/**
* Opens the library DB for reading. The JS side runs it in WAL mode
* (`PRAGMA journal_mode = WAL`), and a strict `OPEN_READONLY` open of a live WAL
* database can throw ("could not open in read/write mode" — read-only WAL needs a
* writable `-shm`/`-wal`). Try read-only first, then fall back to read/write (we only
* ever run `SELECT`s). Returns null if the DB is missing or can't be opened.
*/
fun openReadable(context: Context): SQLiteDatabase? {
val file = context.getDatabasePath(DB_NAME)
if (!file.exists()) return null
return runCatching {
SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
}.recoverCatching {
SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
}.getOrNull()
}
}
@@ -0,0 +1,125 @@
package expo.modules.astracar
import android.os.Bundle
import android.util.Base64
import org.json.JSONObject
data class AstraCarMediaId(
val kind: String,
val section: String? = null,
val key: String? = null,
val id: Long? = null,
val path: String? = null,
val contextKind: String? = null,
val contextSection: String? = null,
val contextKey: String? = null,
val contextId: Long? = null,
)
object AstraCarMediaIds {
private const val PREFIX = "astra:"
val root: String = encode(AstraCarMediaId(kind = "root"))
fun section(section: String): String =
encode(AstraCarMediaId(kind = "section", section = section))
fun album(identityKey: String): String =
encode(AstraCarMediaId(kind = "album", key = identityKey))
fun artist(name: String): String =
encode(AstraCarMediaId(kind = "artist", key = name))
fun playlist(id: Long): String =
encode(AstraCarMediaId(kind = "playlist", id = id))
fun track(path: String, context: AstraCarMediaId): String =
encode(
AstraCarMediaId(
kind = "track",
path = path,
contextKind = context.kind,
contextSection = context.section,
contextKey = context.key,
contextId = context.id,
),
)
fun encode(media: AstraCarMediaId): String {
val json = JSONObject()
.put("kind", media.kind)
.putIfPresent("section", media.section)
.putIfPresent("key", media.key)
.putIfPresent("id", media.id)
.putIfPresent("path", media.path)
.putIfPresent("contextKind", media.contextKind)
.putIfPresent("contextSection", media.contextSection)
.putIfPresent("contextKey", media.contextKey)
.putIfPresent("contextId", media.contextId)
.toString()
val encoded = Base64.encodeToString(
json.toByteArray(Charsets.UTF_8),
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)
return "$PREFIX$encoded"
}
fun decode(mediaId: String?): AstraCarMediaId? {
if (mediaId.isNullOrBlank() || !mediaId.startsWith(PREFIX)) return null
return runCatching {
val jsonText = String(
Base64.decode(mediaId.removePrefix(PREFIX), Base64.URL_SAFE),
Charsets.UTF_8,
)
val json = JSONObject(jsonText)
AstraCarMediaId(
kind = json.optString("kind"),
section = json.optStringOrNull("section"),
key = json.optStringOrNull("key"),
id = json.optLongOrNull("id"),
path = json.optStringOrNull("path"),
contextKind = json.optStringOrNull("contextKind"),
contextSection = json.optStringOrNull("contextSection"),
contextKey = json.optStringOrNull("contextKey"),
contextId = json.optLongOrNull("contextId"),
)
}.getOrNull()
}
fun toBundle(media: AstraCarMediaId): Bundle =
Bundle().apply {
putString("kind", media.kind)
putNullableString("section", media.section)
putNullableString("key", media.key)
media.id?.let { putDouble("id", it.toDouble()) }
putNullableString("path", media.path)
putNullableString("contextKind", media.contextKind)
putNullableString("contextSection", media.contextSection)
putNullableString("contextKey", media.contextKey)
media.contextId?.let { putDouble("contextId", it.toDouble()) }
}
private fun JSONObject.putIfPresent(key: String, value: String?): JSONObject {
if (!value.isNullOrBlank()) put(key, value)
return this
}
private fun JSONObject.putIfPresent(key: String, value: Long?): JSONObject {
if (value != null) put(key, value)
return this
}
private fun JSONObject.optStringOrNull(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key).trim().takeIf { it.isNotEmpty() }
}
private fun JSONObject.optLongOrNull(key: String): Long? {
if (!has(key) || isNull(key)) return null
return optLong(key)
}
private fun Bundle.putNullableString(key: String, value: String?) {
if (value != null) putString(key, value)
}
}
@@ -0,0 +1,135 @@
package expo.modules.astracar
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.util.Log
import androidx.media.MediaBrowserServiceCompat
private const val TAG = "AstraCarMedia"
class AstraCarMediaService : MediaBrowserServiceCompat() {
private lateinit var mediaSession: MediaSessionCompat
private lateinit var catalog: AstraCarCatalog
override fun onCreate() {
super.onCreate()
catalog = AstraCarCatalog(this)
mediaSession = MediaSessionCompat(this, "AstraCar").apply {
setCallback(
object : MediaSessionCompat.Callback() {
override fun onPlay() {
AstraCarCommandService.startTransport(this@AstraCarMediaService, "play")
}
override fun onPause() {
AstraCarCommandService.startTransport(this@AstraCarMediaService, "pause")
}
override fun onStop() {
AstraCarCommandService.startTransport(this@AstraCarMediaService, "pause")
}
override fun onSkipToNext() {
AstraCarCommandService.startTransport(this@AstraCarMediaService, "next")
}
override fun onSkipToPrevious() {
AstraCarCommandService.startTransport(this@AstraCarMediaService, "previous")
}
override fun onSeekTo(pos: Long) {
AstraCarCommandService.startSeek(this@AstraCarMediaService, pos)
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
AstraCarCommandService.startPlayFromMediaId(this@AstraCarMediaService, mediaId)
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
AstraCarCommandService.startPlayFromSearch(this@AstraCarMediaService, query, extras)
}
},
)
applyAstraState(AstraCarNowPlayingStore.load(this@AstraCarMediaService))
}
sessionToken = mediaSession.sessionToken
AstraCarNowPlayingStore.attach(this)
}
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?,
): BrowserRoot {
// The art provider is exported=false; the framework does NOT auto-grant browse-item
// icon URIs to the Auto host on every head unit (observed SecurityException from
// gearhead). Explicitly grant the connecting client read access to our art subtrees.
grantArtworkAccess(clientPackageName)
return BrowserRoot(AstraCarMediaIds.root, null)
}
private fun grantArtworkAccess(clientPackageName: String) {
val authority = AstraCarArtwork.authority(this)
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
for (path in listOf("local", "remote")) {
runCatching {
grantUriPermission(clientPackageName, Uri.parse("content://$authority/$path"), flags)
}.onFailure { Log.w(TAG, "grantUriPermission failed for $clientPackageName/$path", it) }
}
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
loadChildren(parentId, null, result)
}
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaItem>>,
options: Bundle,
) {
loadChildren(parentId, options, result)
}
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
result.detach()
Thread {
val item = runCatching { catalog.loadItem(itemId) }
.onFailure { Log.e(TAG, "loadItem failed for $itemId", it) }
.getOrNull()
runCatching { result.sendResult(item) }
.onFailure { Log.e(TAG, "sendResult(item) failed for $itemId", it) }
}.start()
}
fun applyNowPlaying(state: AstraCarNowPlayingState) {
if (this::mediaSession.isInitialized) {
mediaSession.applyAstraState(state)
}
}
override fun onDestroy() {
AstraCarNowPlayingStore.detach(this)
if (this::mediaSession.isInitialized) {
mediaSession.release()
}
super.onDestroy()
}
private fun loadChildren(
parentId: String,
options: Bundle?,
result: Result<MutableList<MediaItem>>,
) {
result.detach()
Thread {
val items = runCatching { catalog.loadChildren(parentId, options).toMutableList() }
.onFailure { Log.e(TAG, "loadChildren failed for $parentId", it) }
.getOrDefault(mutableListOf())
runCatching { result.sendResult(items) }
.onFailure { Log.e(TAG, "sendResult failed for $parentId (${items.size} items)", it) }
}.start()
}
}
@@ -0,0 +1,78 @@
package expo.modules.astracar
import android.content.Context
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
class AstraCarNowPlayingRecord : Record {
@Field
val title: String? = null
@Field
val artist: String? = null
@Field
val album: String? = null
@Field
val artworkHash: String? = null
@Field
val artworkSourceId: String? = null
@Field
val artworkSourceKey: Double? = null
@Field
val playbackState: String = "stopped"
@Field
val hasTrack: Boolean = false
@Field
val duration: Double? = null
@Field
val position: Double? = null
}
class AstraCarModule : Module() {
override fun definition() = ModuleDefinition {
Name("AstraCar")
Function("setNowPlaying") { state: AstraCarNowPlayingRecord ->
val context = requireContext()
AstraCarNowPlayingStore.saveAndApply(
context,
AstraCarNowPlayingState(
title = state.title,
artist = state.artist,
album = state.album,
artworkUri = resolveArtworkUri(context, state),
playbackState = state.playbackState,
hasTrack = state.hasTrack,
durationSeconds = state.duration,
positionSeconds = state.position,
),
)
}
}
/** Build the content:// art URI Android Auto can load (local hash wins; else remote ref). */
private fun resolveArtworkUri(context: Context, state: AstraCarNowPlayingRecord): String? {
val hash = state.artworkHash?.trim()
if (!hash.isNullOrEmpty()) return AstraCarArtwork.localUri(context, hash, full = true).toString()
val sourceId = state.artworkSourceKey?.toLong()
val artId = state.artworkSourceId?.trim()
if (sourceId != null && !artId.isNullOrEmpty()) {
return AstraCarArtwork.remoteUri(context, sourceId, artId).toString()
}
return null
}
private fun requireContext(): Context =
appContext.reactContext ?: throw Exceptions.ReactContextLost()
}
@@ -0,0 +1,157 @@
package expo.modules.astracar
import android.content.Context
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import org.json.JSONObject
import java.lang.ref.WeakReference
data class AstraCarNowPlayingState(
val title: String?,
val artist: String?,
val album: String?,
val artworkUri: String?,
val playbackState: String,
val hasTrack: Boolean,
val durationSeconds: Double?,
val positionSeconds: Double?,
)
object AstraCarNowPlayingStore {
private const val PREFS_NAME = "astra_car_now_playing"
private const val KEY_STATE = "state"
private var serviceRef: WeakReference<AstraCarMediaService>? = null
fun attach(service: AstraCarMediaService) {
serviceRef = WeakReference(service)
}
fun detach(service: AstraCarMediaService) {
if (serviceRef?.get() === service) serviceRef = null
}
fun saveAndApply(context: Context, state: AstraCarNowPlayingState) {
context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_STATE, encode(state))
.apply()
serviceRef?.get()?.applyNowPlaying(state)
}
fun load(context: Context): AstraCarNowPlayingState =
decode(
context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_STATE, null),
)
fun buildMetadata(state: AstraCarNowPlayingState): MediaMetadataCompat =
MediaMetadataCompat.Builder().apply {
state.title?.let {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, it)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, it)
}
state.artist?.let {
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, it)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, it)
}
state.album?.let {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM, it)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, it)
}
state.artworkUri?.let {
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, it)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, it)
}
state.durationSeconds?.let {
putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (it * 1000).toLong())
}
}.build()
fun buildPlaybackState(state: AstraCarNowPlayingState): PlaybackStateCompat {
val playbackState = when (state.playbackState) {
"playing" -> PlaybackStateCompat.STATE_PLAYING
"paused" -> PlaybackStateCompat.STATE_PAUSED
"loading" -> PlaybackStateCompat.STATE_BUFFERING
else -> PlaybackStateCompat.STATE_STOPPED
}
val actions =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
return PlaybackStateCompat.Builder()
.setActions(actions)
.setState(
playbackState,
((state.positionSeconds ?: 0.0) * 1000).toLong(),
if (playbackState == PlaybackStateCompat.STATE_PLAYING) 1f else 0f,
)
.build()
}
private fun encode(state: AstraCarNowPlayingState): String =
JSONObject()
.put("title", state.title)
.put("artist", state.artist)
.put("album", state.album)
.put("artworkUri", state.artworkUri)
.put("playbackState", state.playbackState)
.put("hasTrack", state.hasTrack)
.put("durationSeconds", state.durationSeconds)
.put("positionSeconds", state.positionSeconds)
.toString()
private fun decode(value: String?): AstraCarNowPlayingState {
if (value.isNullOrBlank()) return emptyState()
return runCatching {
val json = JSONObject(value)
AstraCarNowPlayingState(
title = json.optNullableString("title"),
artist = json.optNullableString("artist"),
album = json.optNullableString("album"),
artworkUri = json.optNullableString("artworkUri"),
playbackState = json.optString("playbackState", "stopped"),
hasTrack = json.optBoolean("hasTrack", false),
durationSeconds = json.optDoubleOrNull("durationSeconds"),
positionSeconds = json.optDoubleOrNull("positionSeconds"),
)
}.getOrDefault(emptyState())
}
private fun emptyState(): AstraCarNowPlayingState =
AstraCarNowPlayingState(
title = null,
artist = null,
album = null,
artworkUri = null,
playbackState = "stopped",
hasTrack = false,
durationSeconds = null,
positionSeconds = null,
)
private fun JSONObject.optNullableString(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key).trim().takeIf { it.isNotEmpty() }
}
private fun JSONObject.optDoubleOrNull(key: String): Double? {
if (!has(key) || isNull(key)) return null
val value = optDouble(key)
return if (value.isNaN()) null else value
}
}
fun MediaSessionCompat.applyAstraState(state: AstraCarNowPlayingState) {
setMetadata(AstraCarNowPlayingStore.buildMetadata(state))
setPlaybackState(AstraCarNowPlayingStore.buildPlaybackState(state))
isActive = state.hasTrack || state.playbackState != "stopped"
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.astracar.AstraCarModule"]
}
}
+29
View File
@@ -0,0 +1,29 @@
import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
import type { PlaybackState } from '@/types/audio';
export interface AstraCarNowPlayingState {
title?: string | null;
artist?: string | null;
album?: string | null;
/** Local cached artwork file name; native builds a content:// URI from it. */
artworkHash?: string | null;
/** Remote server cover id (Subsonic/Jellyfin); used with artworkSourceKey. */
artworkSourceId?: string | null;
/** Remote source row id (remote_sources.id) that owns artworkSourceId. */
artworkSourceKey?: number | null;
playbackState: PlaybackState;
hasTrack: boolean;
duration?: number | null;
position?: number | null;
}
declare class AstraCarModuleType extends NativeModule {
setNowPlaying(state: AstraCarNowPlayingState): void;
}
const native = requireOptionalNativeModule<AstraCarModuleType>('AstraCar');
export const AstraCar = (native ?? {
setNowPlaying: () => {},
}) as AstraCarModuleType;