From a57b4504de368ec2ca901269c3653bc90bb78ca6 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:40:55 +0700 Subject: [PATCH] perf(audio): reuse media session worker and bound metadata retention --- .../audio_service/android/build.gradle.kts | 1 + .../ryanheise/audioservice/AudioService.java | 43 +++++--- .../audioservice/AudioServicePlugin.java | 104 ++++++++++++++---- .../audioservice/PinnedMetadataCache.java | 52 +++++++++ .../audioservice/PinnedMetadataCacheTest.java | 68 ++++++++++++ 5 files changed, 234 insertions(+), 34 deletions(-) create mode 100644 third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/PinnedMetadataCache.java create mode 100644 third_party/audio_service/android/src/test/java/com/ryanheise/audioservice/PinnedMetadataCacheTest.java diff --git a/third_party/audio_service/android/build.gradle.kts b/third_party/audio_service/android/build.gradle.kts index aea12495..9feb38cf 100644 --- a/third_party/audio_service/android/build.gradle.kts +++ b/third_party/audio_service/android/build.gradle.kts @@ -73,4 +73,5 @@ android { dependencies { implementation("androidx.media:media:1.8.0") implementation("androidx.core:core:1.19.1") + testImplementation("junit:junit:4.13.2") } diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java index e9b1670a..fa1a785a 100644 --- a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java @@ -42,7 +42,6 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -100,11 +99,12 @@ public class AudioService extends MediaBrowserServiceCompat { | PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE | PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED; - static AudioService instance; + static volatile AudioService instance; + private volatile boolean destroyed; private static PendingIntent contentIntent; private static ServiceListener listener; private static List queue = new ArrayList<>(); - private static final Map mediaMetadataCache = new HashMap<>(); + private static final PinnedMetadataCache mediaMetadataCache = new PinnedMetadataCache<>(256); public static void init(ServiceListener listener) { AudioService.listener = listener; @@ -164,7 +164,10 @@ public class AudioService extends MediaBrowserServiceCompat { } } MediaMetadataCompat mediaMetadata = builder.build(); - mediaMetadataCache.put(mediaId, mediaMetadata); + synchronized (mediaMetadataCache) { + if (destroyed) throw new IllegalStateException("Audio service is no longer attached"); + mediaMetadataCache.put(mediaId, mediaMetadata); + } return mediaMetadata; } @@ -359,6 +362,10 @@ public class AudioService extends MediaBrowserServiceCompat { @Override public void onDestroy() { + synchronized (mediaMetadataCache) { + destroyed = true; + mediaMetadataCache.clear(); + } super.onDestroy(); if (listener != null) { listener.onDestroy(); @@ -367,7 +374,6 @@ public class AudioService extends MediaBrowserServiceCompat { mediaMetadata = null; artBitmap = null; queue.clear(); - mediaMetadataCache.clear(); controls.clear(); artBitmapCache.evictAll(); compactActionIndices = null; @@ -800,7 +806,11 @@ public class AudioService extends MediaBrowserServiceCompat { * Updates queue. * Gets called from background thread. */ - synchronized void setQueue(List queue) { + synchronized void setQueue(List queue, Map metadata) { + synchronized (mediaMetadataCache) { + if (destroyed) throw new IllegalStateException("Audio service is no longer attached"); + mediaMetadataCache.setQueue(metadata); + } AudioService.queue = queue; mediaSession.setQueue(queue); } @@ -822,6 +832,10 @@ public class AudioService extends MediaBrowserServiceCompat { * - https://9to5google.com/2020/08/02/android-11-lockscreen-art/ */ synchronized void setMetadata(MediaMetadataCompat mediaMetadata) { + synchronized (mediaMetadataCache) { + if (destroyed) throw new IllegalStateException("Audio service is no longer attached"); + mediaMetadataCache.setCurrent(mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID), mediaMetadata); + } String artCacheFilePath = mediaMetadata.getString("artCacheFile"); if (artCacheFilePath != null) { // Load local files and network images, cached in files @@ -838,6 +852,7 @@ public class AudioService extends MediaBrowserServiceCompat { artBitmap = null; } } + if (destroyed) throw new IllegalStateException("Audio service is no longer attached"); this.mediaMetadata = mediaMetadata; mediaSession.setMetadata(mediaMetadata); handler.removeCallbacksAndMessages(null); @@ -907,19 +922,19 @@ public class AudioService extends MediaBrowserServiceCompat { @Override public void onAddQueueItem(MediaDescriptionCompat description) { if (listener == null) return; - listener.onAddQueueItem(getMediaMetadata(description.getMediaId())); + listener.onAddQueueItem(description); } @Override public void onAddQueueItem(MediaDescriptionCompat description, int index) { if (listener == null) return; - listener.onAddQueueItemAt(getMediaMetadata(description.getMediaId()), index); + listener.onAddQueueItemAt(description, index); } @Override public void onRemoveQueueItem(MediaDescriptionCompat description) { if (listener == null) return; - listener.onRemoveQueueItem(getMediaMetadata(description.getMediaId())); + listener.onRemoveQueueItem(description); } @Override @@ -1141,7 +1156,7 @@ public class AudioService extends MediaBrowserServiceCompat { public void onPlayMediaItem(final MediaDescriptionCompat description) { if (listener == null) return; - listener.onPlayMediaItem(getMediaMetadata(description.getMediaId())); + listener.onPlayMediaItem(description); } } @@ -1172,9 +1187,9 @@ public class AudioService extends MediaBrowserServiceCompat { void onSetRepeatMode(int repeatMode); void onSetShuffleMode(int shuffleMode); void onCustomAction(String action, Bundle extras); - void onAddQueueItem(MediaMetadataCompat metadata); - void onAddQueueItemAt(MediaMetadataCompat metadata, int index); - void onRemoveQueueItem(MediaMetadataCompat metadata); + void onAddQueueItem(MediaDescriptionCompat description); + void onAddQueueItemAt(MediaDescriptionCompat description, int index); + void onRemoveQueueItem(MediaDescriptionCompat description); void onRemoveQueueItemAt(int index); void onSetPlaybackSpeed(float speed); void onSetCaptioningEnabled(boolean enabled); @@ -1185,7 +1200,7 @@ public class AudioService extends MediaBrowserServiceCompat { // NON-STANDARD METHODS // - void onPlayMediaItem(MediaMetadataCompat metadata); + void onPlayMediaItem(MediaDescriptionCompat description); void onTaskRemoved(); void onClose(); void onDestroy(); diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java index b776a840..961028ec 100644 --- a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java @@ -34,6 +34,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import io.flutter.embedding.android.FlutterActivity; @@ -131,6 +132,7 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { private static final String CHANNEL_CLIENT = "com.ryanheise.audio_service.client.methods"; private static final String CHANNEL_HANDLER = "com.ryanheise.audio_service.handler.methods"; + private static final String DESCRIPTION_METADATA = "com.ryanheise.audio_service.metadata"; private static final Set clientInterfaces = new HashSet<>(); private static ClientInterface mainClientInterface; @@ -520,6 +522,8 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { public MethodChannel channel; private AudioTrack silenceAudioTrack; private final Handler handler = new Handler(Looper.getMainLooper()); + private final ExecutorService mediaWorker = Executors.newSingleThreadExecutor(); + private volatile boolean destroyed; private List methodInvocationQueue = new LinkedList(); public AudioHandlerInterface(BinaryMessenger messenger) { @@ -702,8 +706,8 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { } @Override - public void onPlayMediaItem(MediaMetadataCompat metadata) { - invokeMethod("playMediaItem", mapOf("mediaItem", mediaMetadata2raw(metadata))); + public void onPlayMediaItem(MediaDescriptionCompat description) { + invokeMediaItemMethod("playMediaItem", description, null); } @Override @@ -712,20 +716,48 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { } @Override - public void onAddQueueItem(MediaMetadataCompat metadata) { - invokeMethod("addQueueItem", mapOf("mediaItem", mediaMetadata2raw(metadata))); + public void onAddQueueItem(MediaDescriptionCompat description) { + invokeMediaItemMethod("addQueueItem", description, null); } @Override - public void onAddQueueItemAt(MediaMetadataCompat metadata, int index) { - invokeMethod("insertQueueItem", mapOf( - "mediaItem", mediaMetadata2raw(metadata), - "index", index)); + public void onAddQueueItemAt(MediaDescriptionCompat description, int index) { + invokeMediaItemMethod("insertQueueItem", description, index); } @Override - public void onRemoveQueueItem(MediaMetadataCompat metadata) { - invokeMethod("removeQueueItem", mapOf("mediaItem", mediaMetadata2raw(metadata))); + public void onRemoveQueueItem(MediaDescriptionCompat description) { + invokeMediaItemMethod("removeQueueItem", description, null); + } + + private void invokeMediaItemMethod(String method, MediaDescriptionCompat description, Integer index) { + MediaMetadataCompat metadata = AudioService.getMediaMetadata(description.getMediaId()); + if (metadata == null && description.getExtras() != null) { + metadata = description.getExtras().getParcelable(DESCRIPTION_METADATA); + } + if (metadata != null) { + sendMediaItemMethod(method, mediaMetadata2raw(metadata), index); + return; + } + // A browser can retain a description after its metadata leaves the + // recent cache. Resolve it through the same Dart API as onLoadItem. + invokeMethod("getMediaItem", mapOf("mediaId", description.getMediaId()), new Result() { + @Override + public void success(Object value) { + if (destroyed || !(value instanceof Map)) return; + Object item = ((Map)value).get("mediaItem"); + if (item instanceof Map) sendMediaItemMethod(method, (Map)item, index); + } + @Override + public void error(String code, String message, Object details) { } + @Override + public void notImplemented() { } + }); + } + + private void sendMediaItemMethod(String method, Map item, Integer index) { + invokeMethod(method, index == null ? mapOf("mediaItem", item) + : mapOf("mediaItem", item, "index", index)); } @Override @@ -835,11 +867,15 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { Map args = (Map)call.arguments; switch (call.method) { case "setMediaItem": { - Executors.newSingleThreadExecutor().execute(() -> { + final AudioService service = AudioService.instance; + mediaWorker.execute(() -> { try { + if (destroyed || service == null || AudioService.instance != service) { + throw new IllegalStateException("Audio service is no longer attached"); + } Map rawMediaItem = (Map)args.get("mediaItem"); - MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); - AudioService.instance.setMetadata(mediaMetadata); + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem, service); + service.setMetadata(mediaMetadata); handler.post(() -> result.success(null)); } catch (Exception e) { handler.post(() -> { @@ -850,11 +886,16 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { break; } case "setQueue": { - Executors.newSingleThreadExecutor().execute(() -> { + final AudioService service = AudioService.instance; + mediaWorker.execute(() -> { try { + if (destroyed || service == null || AudioService.instance != service) { + throw new IllegalStateException("Audio service is no longer attached"); + } @SuppressWarnings("unchecked") List> rawQueue = (List>) args.get("queue"); - List queue = raw2queue(rawQueue); - AudioService.instance.setQueue(queue); + Map metadata = new HashMap<>(); + List queue = raw2queue(rawQueue, metadata, service); + service.setQueue(queue, metadata); handler.post(() -> result.success(null)); } catch (Exception e) { handler.post(() -> { @@ -1016,6 +1057,9 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { } private void destroy() { + destroyed = true; + channel.setMethodCallHandler(null); + mediaWorker.shutdown(); if (silenceAudioTrack != null) silenceAudioTrack.release(); } @@ -1129,8 +1173,12 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { } private static MediaMetadataCompat createMediaMetadata(Map rawMediaItem) { + return createMediaMetadata(rawMediaItem, AudioService.instance); + } + + private static MediaMetadataCompat createMediaMetadata(Map rawMediaItem, AudioService service) { //noinspection unchecked - return AudioService.instance.createMediaMetadata( + return service.createMediaMetadata( (String)rawMediaItem.get("id"), (String)rawMediaItem.get("title"), (String)rawMediaItem.get("album"), @@ -1178,16 +1226,32 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware { private static MediaBrowserCompat.MediaItem rawToMediaItem(Map rawMediaItem) { MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); - final MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map)rawMediaItem.get("extras")); + MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map)rawMediaItem.get("extras")); + // Browser descriptions can outlive the bounded server cache. Keep their + // metadata with the returned item instead of retaining every browsed ID. + Bundle extras = new Bundle(); + if (description.getExtras() != null) extras.putAll(description.getExtras()); + extras.putParcelable(DESCRIPTION_METADATA, mediaMetadata); + description = new MediaDescriptionCompat.Builder() + .setMediaId(description.getMediaId()) + .setTitle(description.getTitle()) + .setSubtitle(description.getSubtitle()) + .setDescription(description.getDescription()) + .setIconBitmap(description.getIconBitmap()) + .setIconUri(description.getIconUri()) + .setMediaUri(description.getMediaUri()) + .setExtras(extras) + .build(); final Boolean playable = (Boolean)rawMediaItem.get("playable"); return new MediaBrowserCompat.MediaItem(description, playable ? MediaBrowserCompat.MediaItem.FLAG_PLAYABLE : MediaBrowserCompat.MediaItem.FLAG_BROWSABLE); } - private static List raw2queue(List> rawQueue) { + private static List raw2queue(List> rawQueue, Map metadata, AudioService service) { List queue = new ArrayList<>(); int i = 0; for (Map rawMediaItem : rawQueue) { - MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem, service); + metadata.put(mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID), mediaMetadata); MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map)rawMediaItem.get("extras")); queue.add(new MediaSessionCompat.QueueItem(description, i)); i++; diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/PinnedMetadataCache.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/PinnedMetadataCache.java new file mode 100644 index 00000000..1d942b44 --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/PinnedMetadataCache.java @@ -0,0 +1,52 @@ +package com.ryanheise.audioservice; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Keeps current/queue items while bounding metadata retained from older browsers. */ +final class PinnedMetadataCache { + private final int recentLimit; + private final Map recent = new LinkedHashMap<>(16, 0.75f, true); + private Map queue = new HashMap<>(); + private String currentId; + private T current; + + PinnedMetadataCache(int recentLimit) { + if (recentLimit < 1) throw new IllegalArgumentException("recentLimit must be positive"); + this.recentLimit = recentLimit; + } + + synchronized void put(String id, T metadata) { + recent.put(id, metadata); + while (recent.size() > recentLimit) { + recent.remove(recent.keySet().iterator().next()); + } + } + + synchronized T get(String id) { + if (id != null && id.equals(currentId)) return current; + T metadata = queue.get(id); + return metadata != null ? metadata : recent.get(id); + } + + synchronized void setCurrent(String id, T metadata) { + currentId = id; + current = metadata; + } + + synchronized void setQueue(Map metadata) { + queue = new HashMap<>(metadata); + } + + synchronized void clear() { + recent.clear(); + queue.clear(); + currentId = null; + current = null; + } + + synchronized int recentSize() { + return recent.size(); + } +} diff --git a/third_party/audio_service/android/src/test/java/com/ryanheise/audioservice/PinnedMetadataCacheTest.java b/third_party/audio_service/android/src/test/java/com/ryanheise/audioservice/PinnedMetadataCacheTest.java new file mode 100644 index 00000000..050f5e22 --- /dev/null +++ b/third_party/audio_service/android/src/test/java/com/ryanheise/audioservice/PinnedMetadataCacheTest.java @@ -0,0 +1,68 @@ +package com.ryanheise.audioservice; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class PinnedMetadataCacheTest { + @Test + public void queueLargerThanRecentLimitAndCurrentStayAvailableAfterChurn() { + PinnedMetadataCache cache = new PinnedMetadataCache<>(2); + Map queue = new HashMap<>(); + for (int i = 0; i < 10; i++) queue.put("queue-" + i, "metadata-" + i); + cache.setQueue(queue); + queue.clear(); + cache.setCurrent("playing", "current"); + for (int i = 0; i < 1000; i++) cache.put("browser-" + i, "old"); + for (int i = 0; i < 10; i++) assertEquals("metadata-" + i, cache.get("queue-" + i)); + assertEquals("current", cache.get("playing")); + assertEquals(2, cache.recentSize()); + cache.setQueue(new HashMap<>()); + assertNull(cache.get("queue-0")); + cache.clear(); + assertNull(cache.get("playing")); + assertEquals(0, cache.recentSize()); + } + + @Test + public void recentBrowserReadsRefreshEvictionOrder() { + PinnedMetadataCache cache = new PinnedMetadataCache<>(2); + cache.put("first", "a"); + cache.put("second", "b"); + assertEquals("a", cache.get("first")); + cache.put("third", "c"); + assertNull(cache.get("second")); + assertEquals("a", cache.get("first")); + } + + @Test + public void concurrentBrowserAndWorkerWritesRemainBounded() throws Exception { + PinnedMetadataCache cache = new PinnedMetadataCache<>(16); + cache.setCurrent("playing", "current"); + ExecutorService workers = Executors.newFixedThreadPool(4); + List> results = new ArrayList<>(); + try { + for (int i = 0; i < 1000; i++) { + final String id = "item-" + i; + results.add(workers.submit(() -> { + cache.put(id, id); + cache.get(id); + })); + } + } finally { + workers.shutdown(); + } + org.junit.Assert.assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + for (Future result : results) result.get(); + assertEquals(16, cache.recentSize()); + assertEquals("current", cache.get("playing")); + } +}