mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 11:12:33 +02:00
android auto support
This commit is contained in:
@@ -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))
|
||||
}
|
||||
+134
@@ -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))
|
||||
+158
@@ -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)
|
||||
}
|
||||
}
|
||||
+135
@@ -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>
|
||||
Reference in New Issue
Block a user