software-mansion / software-mansion/react-native-audio-api

Android: artwork thread writes stale MediaSession metadata, so notification/lock-screen title lags one track behind

Open
#1,280 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

android bug
Dominant language
C++
Stars
839
Forks
92
Avg merge
4d 15h
Merged PRs (30d)
27

Description

Summary

On Android with react-native-audio-api@0.13.3, the media notification and lock-screen controls display the previous track's title after switching tracks, even though JS passes the correct metadata to PlaybackNotificationManager.show() synchronously and immediately.

The cause is in PlaybackNotification.updateInternal(): the async artwork thread reads back metadata via MediaControllerCompat.getMetadata() and writes it to the session again. MediaControllerCompat is updated asynchronously over IPC, so shortly after setMetadata() it still returns the previous metadata. The artwork thread then republishes that stale metadata, overwriting the correct one.

Since Android 13+ renders both the notification shade media widget and the lock-screen controls from the MediaSession metadata, the previous track's title becomes visible again.

This is a separate defect from #1273 (which is about MEDIA_BUTTON intents not being handled), but it lives in the same code block — see "Relationship to #1273" below.

Environment

  • react-native-audio-api: 0.13.3
  • React Native: 0.81.5
  • Expo SDK: 54
  • Android: reproduced on Android 15, real device
  • New Architecture enabled

Reproduction observed in app

  1. Show playback notification metadata and enable play, pause, nextTrack, previousTrack, seekTo.
  2. Start playback of a queue with several tracks, using a remote artwork URL (loading must take non-trivial time).
  3. Press next (or previous), either in-app or from the notification.
  4. Observe the title in the notification shade widget and on the lock screen.

Expected behavior

The notification and lock-screen title update to the newly loaded track.

Actual behavior

The title briefly shows the correct track, then reverts to the previous track's title and stays there until the next track change. The effect is consistently "one track behind".

We verified from the JS side that the correct value is sent every time. Logging the exact payload handed to PlaybackNotificationManager.show() at each track change:

playNext        -> { nextIndex: 1, arrangementId: "1189d1b0-…" }
show() payload  -> { title: "Can't stop the feeling", state: "paused" }
metadata loaded -> { id: "1189d1b0-…", title: "Can't stop the feeling" }
show() payload  -> { title: "Can't stop the feeling", state: "playing" }

The title sent to native is correct and synchronous, while the native UI still renders the previous track. This rules out the JS layer.

Code-level analysis

In PlaybackNotification.kt, updateInternal() starts a background thread for artwork and, on completion, republishes metadata:

val currentMetadata = mediaSession?.controller?.metadata
val newBuilder = MediaMetadataCompat.Builder(currentMetadata ?: MediaMetadataCompat.Builder().build())
mediaSession?.setMetadata(newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build())

Two problems:

  1. MediaControllerCompat.getMetadata() is not a reliable read-back of setMetadata(). The controller receives metadata through the session's IPC callback, so immediately after setMetadata() it can still return the previous value. Using it as the base means the previous track's METADATA_KEY_TITLE / ARTIST / DURATION get republished.

  2. The interrupt of the previous artwork thread is not effective. updateInternal() calls artworkThread.interrupt(), but the thread is typically blocked in URL.openConnection() / BitmapFactory.decodeStream(). A blocking socket read is not interruptible, so the old thread keeps running and completes after the new track has already published its metadata.

Together these mean the artwork callback of track N can overwrite the metadata of track N+1.

This is timing-dependent, which likely explains why it does not appear with fast/local artwork or in a minimal repro — it needs artwork loading to overlap a track change.

Suggested fix

Keep an authoritative copy of the last published metadata instead of reading it back from the controller, and ignore artwork results belonging to a superseded request.

   private var artworkThread: Thread? = null
 
+  // Authoritative copy of the last metadata we published; MediaControllerCompat.getMetadata()
+  // lags behind setMetadata() over IPC and would resurrect the previous track.
+  private var lastMetadata: MediaMetadataCompat? = null
+  private var artworkRequestId: Long = 0L
+
   private fun initializeIfNeeded() {
@@
       if (artworkUri != null) {
+        val requestId = ++artworkRequestId
         artworkThread =
           Thread {
             try {
               val bitmap = loadArtwork(artworkUri, localArtwork)
               if (bitmap != null) {
-                artwork = bitmap
                 val context = reactContext.get()
                 context?.runOnUiQueueThread {
+                  // A newer track started while this artwork was loading; discard the result.
+                  if (requestId != artworkRequestId) return@runOnUiQueueThread
+
+                  artwork = bitmap
                   notificationBuilder?.setLargeIcon(bitmap)
 
-                  val currentMetadata = mediaSession?.controller?.metadata
-                  val newBuilder = MediaMetadataCompat.Builder(currentMetadata ?: MediaMetadataCompat.Builder().build())
-                  mediaSession?.setMetadata(newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build())
+                  val base = lastMetadata
+                  val newBuilder =
+                    if (base != null) MediaMetadataCompat.Builder(base) else MediaMetadataCompat.Builder()
+                  val updated = newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build()
+                  lastMetadata = updated
+                  mediaSession?.setMetadata(updated)
 
                   // Trigger update
                   val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
                   notificationManager.notify(notificationId, buildNotification())
@@
     if (artwork != null) {
       md.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, artwork)
     }
-    mediaSession?.setMetadata(md.build())
+    val builtMetadata = md.build()
+    lastMetadata = builtMetadata
+    mediaSession?.setMetadata(builtMetadata)

lastMetadata is also cleared in hide() alongside artwork.

Applied as a local patch, this fully resolves the lagging title on our device. It requires a native rebuild to take effect.

Relationship to #1273

This is a distinct bug from the missing MEDIA_BUTTON handling in #1273, but it shares a root location with the ConcurrentModificationException reported there. That stack trace points into the same artwork callback:

androidx.core.app.NotificationCompat$Builder.build(NotificationCompat.java:2528)
com.swmansion.audioapi.system.notification.PlaybackNotification.buildNotification(PlaybackNotification.kt:515)
com.swmansion.audioapi.system.notification.PlaybackNotification.updateInternal$lambda$1$lambda$0(PlaybackNotification.kt:249)

updateInternal$lambda$1$lambda$0 is the runOnUiQueueThread block inside the artwork Thread. So the artwork callback mutates shared state (notificationBuilder, mediaSession, artwork) from outside the normal update flow, which produces both the crash and the stale metadata. Guarding stale results and not reading back from the controller addresses the metadata half; adding synchronization around show() / hide() / buildNotification() addresses the crash half.

Note on a minimal repro

To set expectations honestly: I do not have bandwidth to build a public MRE in the near term, so please do not wait on one from me.

This report is based on a real app reproduction. I am filing it anyway because the cause does not depend on my observation being reproduced — it is a documented property of the Android API: MediaControllerCompat.getMetadata() is populated through the session's IPC callback and is not a valid read-back of a preceding setMetadata() call. The current code uses it as the base for the metadata it republishes, which is unsafe regardless of whether the resulting lag is visible on a given device or timing profile.

Anyone wanting to reproduce it deliberately would need artwork loading slow enough to overlap a track change, for example a deliberately throttled remote artwork URL combined with rapid next/previous presses.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in PlaybackNotification.kt, especially updateInternal(), the artwork callback, and hide(); inspect how metadata and artworkThread are updated. Reproduce with slow remote artwork and rapid track changes, then verify that stale artwork cannot overwrite the latest metadata and that the notification and lock-screen title remain current.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin, react-native
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.