Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/build-compilation-dependencies/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ Downloaded artifacts land in:
- 16KB page size alignment enabled for Android 15+
- **Extension API (prefab)**: single public C++ header `<audioapi/compatibility/StableAPI.h>`; prefab publishes transitive headers needed to compile it (`prepareAudioApiHeadersForPrefabs`); `fix-prefab.gradle` ensures the `.so` is in prefab metadata. Contract: `EXTENSION_API.md`
- CMake exposes `COMMON_CPP_DIR` and `ANDROID_CPP_DIR` as **PUBLIC** include dirs so prefab consumers resolve `<audioapi/...>`
- **React Native's transitive Kotlin/Java deps are usable without declaring them.** `ReactAndroid` declares several libraries with `api(...)` rather than `implementation(...)`, so they reach us through the existing `implementation "com.facebook.react:react-native:+"`. Fresco is the notable one (`com.facebook.fresco:fresco` — 3.2.0 on RN 0.76, 3.7.0 on RN 0.87), used by `system/notification/ArtworkLoader.kt`. Check the RN version's `ReactAndroid/build.gradle.kts` before adding a dependency that RN may already expose; adding it explicitly only risks a version conflict.
- **Verify any such API at the RN floor, not just the checked-in version.** `android/build.gradle` asserts RN minor >= 76, so a Kotlin API that exists in `node_modules` today may not exist at 0.76. Fresco's API surface happens to be identical across 3.2.0-3.7.0, but the third `ResizeOptions` constructor parameter was renamed (`maxBitmapSize` -> `maxBitmapDimension`), so positional arguments are required. Sources for a given version are fetchable from `raw.githubusercontent.com/facebook/{react-native,fresco}/v<tag>/...`.

For full per-line analysis see [build-details.md](build-details.md#android-androidcmakeliststxt-root--detailed-analysis).

Expand Down
21 changes: 21 additions & 0 deletions .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,29 @@ suspend.then→suspended, event→suspended`.

---

## Android Notification Subsystem: JS-Queue Confinement

Kotlin rather than C++, but the same discipline. `AudioAPIModule.showNotification` →
`MediaSessionManager` → `NotificationRegistry.showNotification` → `BaseNotification.show()`/`hide()`
runs entirely on the **JS thread**; nothing on that path hops threads. `MediaSessionCompat` adopts
the looper of whichever thread constructed it, so a session created in `initializeIfNeeded()` is
JS-thread-affine too.

Anything arriving asynchronously (artwork loads) must therefore be delivered back with
`reactContext.runOnJSQueueThread { ... }`, **not** `runOnUiQueueThread`. `runOnJSQueueThread` always
posts and never runs inline, so a callback cannot re-enter an update already in progress — which in
turn makes plain field assignment safe and removes any need for a lock.

Async results also need a **generation counter** (`PlaybackNotification.artworkGeneration`), bumped
on every new request and in `hide()`. Cancelling cannot recall a result that has already been posted
to the queue, so the callback compares the generation it captured and drops a stale one. Without it,
a superseded load overwrites newer artwork or repaints a dismissed notification.

---

## Common Mistakes

- **Hopping to the UI thread from a notification callback** — `MediaSessionCompat` is bound to the JS queue thread that built it; use `runOnJSQueueThread`. Two `setMetadata` writers on different threads each read-modify-write the session's metadata and clobber each other. Keep one writer that builds from the notification's own fields.
- **Reading `node_->field_` in a getter** when that field is written by the audio thread → use shadow state or atomics.
- **Calling `node_->method()` directly from a setter** → always schedule via `scheduleAudioEvent`.
- **Not clearing callback IDs in the HostObject destructor** → node keeps firing into a GC'd JSI function; call `assignOnXCallbackId(0)` from each event HostObject layer on teardown
Expand Down
18 changes: 18 additions & 0 deletions apps/common-app/src/examples/AudioFile/AudioFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ const AudioFile: FC = () => {
await PlaybackNotificationManager.show({
title: 'Audio File',
artist: 'Software Mansion',
artwork: 'https://wallpaperaccess.com/full/2658793.jpg',
album: 'Audio API',
androidSmallIcon: 'logo',
duration: duration,
state: 'paused',
speed: 1.0,
Expand Down Expand Up @@ -138,6 +140,20 @@ const AudioFile: FC = () => {
}
);

const nextTrackListener = PlaybackNotificationManager.addEventListener(
'playbackNotificationNextTrack',
() => {
console.log('Next track event received from notification');
}
);

const previousTrackListener = PlaybackNotificationManager.addEventListener(
'playbackNotificationPreviousTrack',
() => {
console.log('Previous track event received from notification');
}
);

// Keep interruption handling through AudioManager
const interruptionSubscription = AudioManager.addSystemEventListener(
'interruption',
Expand Down Expand Up @@ -184,6 +200,8 @@ const AudioFile: FC = () => {
seekToListener.remove();
interruptionSubscription?.remove();
duckListener.remove();
nextTrackListener.remove();
previousTrackListener.remove();
};
}, [isPlaying, wasPlaying]);

Expand Down
4 changes: 2 additions & 2 deletions apps/fabric-example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2504,7 +2504,7 @@ SPEC CHECKSUMS:
ReactCodegen: af9d73dcac8951d8a47920882b803da2986e3da7
ReactCommon: 5f5a302748322015f2e3334869cdd9b4d0395846
ReactNativeDependencies: ec5530432d8191b38e278117d498aaff05ea9d6f
RNAudioAPI: 5fe6257beb4b9d735cb0916630067b6d2f57b22c
RNAudioAPI: f6c9a71fcae2d5505202ea369082539e1902b8f1
RNAudioWorklets: e763c48efcbd47d25f61eb5bb49a72d8205b5f4e
RNGestureHandler: f09eca0b47053142f7543f5c71d183f215ba5d81
RNReanimated: eabb9b5c492b8e1f16f5523c354ab3a8770c4102
Expand All @@ -2516,4 +2516,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: 7a9375c6de5b95bc2125d07cf736baa649d6ba8e

COCOAPODS: 1.16.2
COCOAPODS: 1.17.0
2 changes: 1 addition & 1 deletion packages/react-native-audio-api/RNAudioAPI.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Pod::Spec.new do |s|
end
end

s.ios.frameworks = 'Accelerate', 'AVFoundation', 'AudioToolbox', 'MediaPlayer'
s.ios.frameworks = 'Accelerate', 'AVFoundation', 'AudioToolbox', 'CoreGraphics', 'ImageIO', 'MediaPlayer', 'UIKit'

s.prepare_command = <<-CMD
chmod +x scripts/download-prebuilt-binaries.sh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,9 @@ class AudioAPIModule(
options: ReadableMap?,
promise: Promise?,
) {
val result = Arguments.createMap()
try {
if (type == null || key == null) {
val result = Arguments.createMap()
result.putBoolean("success", false)
result.putString("error", "Type and key are required")
promise?.resolve(result)
Expand All @@ -200,11 +200,9 @@ class AudioAPIModule(

MediaSessionManager.showNotification(type, key, options)

val result = Arguments.createMap()
result.putBoolean("success", true)
promise?.resolve(result)
} catch (e: Exception) {
val result = Arguments.createMap()
result.putBoolean("success", false)
result.putString("error", e.message ?: "Unknown error")
promise?.resolve(result)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.swmansion.audioapi.system.notification

import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.facebook.common.executors.CallerThreadExecutor
import com.facebook.common.references.CloseableReference
import com.facebook.datasource.DataSource
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.imagepipeline.common.ImageDecodeOptions
import com.facebook.imagepipeline.common.ResizeOptions
import com.facebook.imagepipeline.core.ImagePipeline
import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber
import com.facebook.imagepipeline.image.CloseableImage
import com.facebook.imagepipeline.request.ImageRequestBuilder
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.modules.fresco.FrescoModule
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicBoolean

private typealias ImageDataSource = DataSource<CloseableReference<CloseableImage>>

/**
* Loads media notification artwork through React Native's Fresco image pipeline.
*/
class ArtworkLoader(
private val reactContext: WeakReference<ReactApplicationContext>,
) {
/** Abandons a load. Idempotent, non-blocking, and safe to call from any thread. */
fun interface Handle {
fun cancel()
}

companion object {
private const val TAG = "ArtworkLoader"

/** Deadline covering connect, download and decode together. */
private const val ARTWORK_FETCH_TIMEOUT_MS = 10_000L
}

private val mainHandler = Handler(Looper.getMainLooper())

/**
* Latches once Fresco has been found to be unusable, so that a host app without it does not pay
* for a failing module lookup on every metadata update.
*/
@Volatile
private var isPipelineUnavailable = false

/**
* Fetches [uri], decoded no smaller than [targetSizePx] per side.
*/
fun load(
uri: Uri,
targetSizePx: Int,
onResult: (Bitmap?) -> Unit,
): Handle {
val imagePipeline = imagePipelineOrNull() ?: return deliverNothing(onResult)

val request =
ImageRequestBuilder
.newBuilderWithSource(uri)
.setResizeOptions(ResizeOptions(targetSizePx, targetSizePx))
// forcing first frame of animated image
.setImageDecodeOptions(
ImageDecodeOptions
.newBuilder()
.setForceStaticImage(true)
.build(),
).build()

return Fetch(imagePipeline.fetchDecodedImage(request, null), uri, onResult).also { it.start() }
}

private fun deliverNothing(onResult: (Bitmap?) -> Unit): Handle {
postToNativeModulesQueue { onResult(null) }
return Handle {}
}

private fun postToNativeModulesQueue(action: () -> Unit) {
reactContext.get()?.runOnNativeModulesQueueThread { action() }
}

/**
* Returns React Native's shared image pipeline, or null when Fresco is unavailable.
*
* `Fresco.initialize` must never be called here: [FrescoModule] owns it and supplies the pipeline
* configuration mounted image views depend on, and a second call rebuilds the pipeline and orphans
* the previous one with its caches. The module is a lazily created TurboModule, so requesting it
* from the React context runs that initialization along the same path mounting an image would.
*/
private fun imagePipelineOrNull(): ImagePipeline? {
if (isPipelineUnavailable) return null

if (!Fresco.hasBeenInitialized()) {
val context = reactContext.get() ?: return null
try {
context.getNativeModule(FrescoModule::class.java)
} catch (e: Exception) {
Log.w(TAG, "Could not obtain FrescoModule: ${e.message}")
}

if (!Fresco.hasBeenInitialized()) {
isPipelineUnavailable = true
Log.w(TAG, "Fresco is not initialized; notification artwork will not be loaded")
return null
}
}

return Fresco.getImagePipeline()
}

/**
* A single in-flight fetch. Success, failure, timeout and cancellation race each other, and
* [hasDelivered] makes the outcome exactly-once.
*/
private inner class Fetch(
private val dataSource: ImageDataSource,
private val uri: Uri,
private val onResult: (Bitmap?) -> Unit,
) : Handle {
private val hasDelivered = AtomicBoolean(false)

private val onTimeout =
Runnable {
Log.w(TAG, "Artwork fetch for $uri exceeded $ARTWORK_FETCH_TIMEOUT_MS ms, cancelling")
settle(null, notify = true)
}

fun start() {
mainHandler.postDelayed(onTimeout, ARTWORK_FETCH_TIMEOUT_MS)
dataSource.subscribe(
object : BaseBitmapDataSubscriber() {
override fun onNewResultImpl(bitmap: Bitmap?) {
settle(bitmap?.copy(Bitmap.Config.ARGB_8888, false), notify = true)
}

override fun onFailureImpl(failedDataSource: ImageDataSource) {
Log.w(TAG, "Failed to load artwork from $uri: ${failedDataSource.failureCause?.message}")
settle(null, notify = true)
}
},
CallerThreadExecutor.getInstance(),
)
}

// do not notify, so canceled request do not override current artwork
override fun cancel() = settle(null, notify = false)

private fun settle(
bitmap: Bitmap?,
notify: Boolean,
) {
if (!hasDelivered.compareAndSet(false, true)) return
mainHandler.removeCallbacks(onTimeout)
// Closing aborts the fetch; closing an already finished source is harmless.
dataSource.close()
if (notify) {
postToNativeModulesQueue { onResult(bitmap) }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.swmansion.audioapi.system.notification

import android.annotation.SuppressLint
import android.app.Notification
import android.util.Log
import androidx.annotation.RequiresPermission
Expand All @@ -8,6 +9,15 @@ import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
import com.swmansion.audioapi.system.ForegroundServiceManager
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap

/**
* Re-posts a notification whose content changed after [BaseNotification.show] returned, such as
* artwork that finished loading asynchronously.
*/
fun interface NotificationRedisplay {
fun redisplay(notification: Notification)
}

/**
* Central notification registry that manages multiple notification instances.
Expand All @@ -20,14 +30,14 @@ class NotificationRegistry(
companion object {
private const val TAG = "NotificationRegistry"

// Store last built notifications for foreground service access
private val builtNotifications = mutableMapOf<Int, Notification>()
// Written from the NativeModules queue thread, read by the foreground service on the main thread.
private val builtNotifications = ConcurrentHashMap<Int, Notification>()

fun getBuiltNotification(notificationId: Int): Notification? = builtNotifications[notificationId]
}

private val notifications = mutableMapOf<String, BaseNotification>()
private val activeNotifications = mutableMapOf<String, Boolean>()
private val notifications = HashMap<String, BaseNotification>()
private val activeNotifications = HashMap<String, Boolean>()

/**
* Show or update a notification.
Expand Down Expand Up @@ -123,7 +133,8 @@ class NotificationRegistry(
audioAPIModule,
PlaybackNotification.ID,
"audio_playback",
)
ArtworkLoader(reactContext),
) { redisplayIfActive(key, it) }
}

"recording" -> {
Expand Down Expand Up @@ -183,6 +194,16 @@ class NotificationRegistry(
Log.d(TAG, "Cleaned up all notifications")
}

@SuppressLint("MissingPermission")
private fun redisplayIfActive(
key: String,
notification: Notification,
) {
if (!isNotificationActive(key)) return
val notificationId = notifications[key]?.getNotificationId() ?: return
displayNotification(notificationId, notification)
}

@RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS)
private fun displayNotification(
id: Int,
Expand Down
Loading
Loading