package com.evsuite.abrp; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.media.MediaMetadata; import android.media.session.MediaController; import android.media.session.MediaSessionManager; import android.media.session.PlaybackState; import android.net.Uri; import android.os.Handler; import android.os.Looper; import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * Watches the media sessions the head unit publishes (Bluetooth's, CarPlay's, the radio's) for the * Overview's "now playing", the way the Logs tab's music-sources check reads them: through * {@code MediaSessionManager}, which needs MEDIA_CONTENT_CONTROL (the car build has it as the * system user). It only listens: no media browser service is connected to (that stopped the music * in 2.42.0) and no command is sent. Each session's state and song are kept as the session reports * changes, so reading {@link #current()} costs no call into another app. Runs only while * MainActivity says so, on the main thread. Without the permission, or on any failure, there is * simply nothing playing. Nothing it sees is logged. */ final class NowPlayingWatcher { private static final int COVER_PX = 160; private final Context app; private final Handler main = new Handler(Looper.getMainLooper()); private final List watched = new ArrayList<>(); private final ExecutorService coverLoader = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "now-playing-cover"); t.setDaemon(true); return t; }); private MediaSessionManager sessions; private boolean running; private Bitmap sessionCover; /** The file cover asked for last and what came of it, from the loader thread. */ private volatile String coverUri; private volatile Bitmap fileCover; private final MediaSessionManager.OnActiveSessionsChangedListener changed = this::watch; NowPlayingWatcher(Context context) { app = context.getApplicationContext(); } void setRunning(boolean run) { if (run == running) return; running = run; if (!run) { try { if (sessions != null) sessions.removeOnActiveSessionsChangedListener(changed); } catch (RuntimeException ignored) { // Gone with the listener either way. } watch(null); return; } try { sessions = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE); if (sessions == null) return; sessions.addOnActiveSessionsChangedListener(changed, null, main); watch(sessions.getActiveSessions(null)); } catch (RuntimeException e) { // No permission (the stable build) or no session service: nothing plays. android.util.Log.w("NowPlaying", "Media sessions not readable: " + e.getClass().getSimpleName()); } } boolean isRunning() { return running; } /** What plays now, or null. */ NowPlaying.Track current() { List seen = new ArrayList<>(watched.size()); for (Watched w : watched) seen.add(w.seen); return NowPlaying.pick(seen); } /** The current track's cover, or null: the picture the session carries, else its file, once loaded. */ Bitmap cover(NowPlaying.Track track) { if (track == null) return null; for (Watched w : watched) { if (w.seen == track.session && w.cover != null) return w.cover; } String uri = track.session.artUri; if (uri == null) return null; if (!uri.equals(coverUri)) { coverUri = uri; fileCover = null; coverLoader.execute(() -> { Bitmap b = load(uri); if (uri.equals(coverUri)) fileCover = b; }); } return fileCover; } private void watch(List controllers) { for (Watched w : watched) w.release(); watched.clear(); if (controllers == null || !running) return; for (MediaController c : controllers) { try { watched.add(new Watched(c)); } catch (RuntimeException ignored) { // A session that went away while being read. } } } /** One session: its last state and song, kept up to date by its callback. */ private final class Watched extends MediaController.Callback { final MediaController controller; final NowPlaying.Session seen = new NowPlaying.Session(); Bitmap cover; Watched(MediaController c) { controller = c; seen.packageName = c.getPackageName(); onPlaybackStateChanged(c.getPlaybackState()); onMetadataChanged(c.getMetadata()); c.registerCallback(this, main); } void release() { try { controller.unregisterCallback(this); } catch (RuntimeException ignored) { // Already gone. } } @Override public void onPlaybackStateChanged(PlaybackState state) { seen.state = state == null ? 0 : state.getState(); seen.positionMs = state == null ? -1 : state.getPosition(); seen.positionUpdatedAt = state == null ? 0 : state.getLastPositionUpdateTime(); seen.speed = state == null ? 1f : state.getPlaybackSpeed(); } @Override public void onMetadataChanged(MediaMetadata m) { seen.title = text(m, MediaMetadata.METADATA_KEY_TITLE); if (seen.title == null) seen.title = text(m, MediaMetadata.METADATA_KEY_DISPLAY_TITLE); seen.artist = text(m, MediaMetadata.METADATA_KEY_ARTIST); if (seen.artist == null) seen.artist = text(m, MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE); seen.durationMs = m == null ? 0 : Math.max(0, m.getLong(MediaMetadata.METADATA_KEY_DURATION)); cover = picture(m); seen.artUri = text(m, MediaMetadata.METADATA_KEY_ALBUM_ART_URI); if (seen.artUri == null) seen.artUri = text(m, MediaMetadata.METADATA_KEY_ART_URI); } @Override public void onSessionDestroyed() { seen.state = 0; } } private static String text(MediaMetadata m, String key) { if (m == null) return null; try { CharSequence s = m.getText(key); return s == null ? null : s.toString(); } catch (RuntimeException e) { return null; } } private static Bitmap picture(MediaMetadata m) { if (m == null) return null; for (String key : new String[] { MediaMetadata.METADATA_KEY_ALBUM_ART, MediaMetadata.METADATA_KEY_ART, MediaMetadata.METADATA_KEY_DISPLAY_ICON }) { try { Bitmap b = m.getBitmap(key); if (b != null && b.getWidth() > 1 && b.getHeight() > 1) return b; } catch (RuntimeException ignored) { // Try the next. } } return null; } /** A cover file, made small; null when it cannot be read. Off the main thread. */ private Bitmap load(String uri) { try { Uri u = uri.startsWith("/") ? Uri.fromFile(new java.io.File(uri)) : Uri.parse(uri); String scheme = u.getScheme(); if (!"file".equals(scheme) && !"content".equals(scheme)) return null; BitmapFactory.Options size = new BitmapFactory.Options(); size.inJustDecodeBounds = true; try (InputStream in = app.getContentResolver().openInputStream(u)) { BitmapFactory.decodeStream(in, null, size); } if (size.outWidth <= 0 || size.outHeight <= 0) return null; BitmapFactory.Options small = new BitmapFactory.Options(); small.inSampleSize = 1; while (Math.max(size.outWidth, size.outHeight) / (small.inSampleSize * 2) >= COVER_PX) small.inSampleSize *= 2; try (InputStream in = app.getContentResolver().openInputStream(u)) { return BitmapFactory.decodeStream(in, null, small); } } catch (Exception | OutOfMemoryError e) { return null; } } }