diff --git a/CHANGELOG.md b/CHANGELOG.md index a8761e0..bbaea36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,91 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.3.0] - 2026-07-06 + +### Added + +- **Audio output routing.** New `setAudioRoute()`, `getAudioRoute()`, and + `addAudioRouteChangeListener()` exports for steering playback between the + loudspeaker, earpiece, and Bluetooth headsets, plus observing route changes + made outside the app (Control Center, plugging in headphones, …). Requestable + routes are `'speaker' | 'earpiece' | 'bluetooth' | 'auto'`; the active route + additionally reports `'wired'`. Routing is best-effort: the OS keeps the + final say (a wired headset always wins), `bluetooth` engages once a headset + is actually connected (the request is remembered and applied when one + appears), and external switches are adopted — surfaced via the + `DeepgramRouteChange` event — rather than fought. During echo-cancelled + (`enableVoiceProcessing`) sessions Bluetooth is opt-in, since HFP's loopback + defeats hardware AEC. Backed by a new `AudioRouteManager` on Android and an + extended audio-session layer on iOS. Exposes `DeepgramAudioRoute` / + `DeepgramActiveAudioRoute` types and an `AudioRouteSubscription` handle. +- **Example app.** The Voice Agent screen now includes an audio-route picker + demonstrating the new routing API. +- **iOS privacy manifest.** The pod now ships a `PrivacyInfo.xcprivacy` + declaring audio-data collection (App Functionality, not linked to identity, + no tracking) and its required-reason API usage + (`NSProcessInfo.systemUptime`, reason `35F9.1`), bundled via the podspec's + `resource_bundles` so Xcode's privacy-report aggregation picks it up + automatically. +- **Customizable foreground-service notification (Android).** The keep-alive + notification can now be branded via the Expo plugin's new + `androidNotification` option (`title`, `text`, `channelName`, `icon`) or + `com.deepgram.notification.*` `` manifest entries on bare React + Native. Defaults are now sane without configuration: the title falls back to + the app's label, the small icon to the app's launcher icon, and tapping the + notification opens the app. +- **Audio interruption events.** New `addInterruptionListener()` export (plus + an `onInterruption` callback on the speech-to-text, voice-agent, and + text-to-speech hooks) surfaces system audio interruptions to JS via the new + shared `DeepgramInterruption` native event: `began` (phone call / audio-focus + loss), `ended` (with the system's `shouldResume` hint), and `stopped` + (Android permanent focus loss tore the session down). Exposes the + `DeepgramInterruptionEvent` type. +- **Interruption-proof live sessions.** While a phone call, Siri, or another + app holds the audio hardware, the STT and Voice Agent hooks now keep the + Deepgram socket warm with `KeepAlive` frames (previously the idle socket was + closed server-side after ~10 s with `NET-0001` and the session died with a + reconnect error), resume streaming when the interruption ends, and end + gracefully — `onEnd`/`onClose` instead of an error — when Android tears the + session down on permanent focus loss. On iOS, capture/playback now also + recovers when the system never posts an interruption-ended notification + (common with Siri): the module re-runs its retrying resume when the app + becomes active again and ignores stale “was suspended” interruption + notifications. + +### Changed + +- **Android foreground-service permissions are now opt-in.** The library + manifest no longer force-merges `FOREGROUND_SERVICE`, + `FOREGROUND_SERVICE_MICROPHONE`, or `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, and + the bundled `DeepgramAudioService` no longer hardcodes + `android:foregroundServiceType`. Google Play requires every app that declares + a foreground-service type to justify it (with a demo video) in the Play + Console — apps that don't use background audio couldn't. Now only apps that + opt in carry the declarations: + - **Expo** — the config plugin adds the permissions **and** the service's + `foregroundServiceType="microphone|mediaPlayback"` when `backgroundAudio` + is enabled (still the default). + - **Bare React Native** — apps that keep audio alive in the background must + add the permissions and a `` override to their own + `AndroidManifest.xml`; see the README's "Background audio" section. + **Action required** if you relied on the previously auto-merged entries. + - At runtime the module checks which permissions the host app actually + declared: without them the keep-alive service is skipped gracefully + (foreground audio is unaffected), and on Android 14+ the microphone + service type is only advertised when `FOREGROUND_SERVICE_MICROPHONE` is + declared, so playback-only apps degrade cleanly instead of crashing. +- The library manifest now ships `MODIFY_AUDIO_SETTINGS` (and legacy + `BLUETOOTH`, API ≤ 30) — normal, non-reviewed permissions needed by the new + audio-routing API. + +### Fixed + +- **Expo plugin `backgroundAudio: false` now actually works on Android.** + Previously the option skipped adding the foreground-service permissions, but + manifest merging pulled them in from the library manifest anyway, so every + app still shipped them. + ## [2.2.0] - 2026-06-25 ### Added diff --git a/Deepgram.podspec b/Deepgram.podspec index cd17651..0d754ee 100644 --- a/Deepgram.podspec +++ b/Deepgram.podspec @@ -21,6 +21,9 @@ Pod::Spec.new do |s| s.private_header_files = "ios/**/*.h" s.frameworks = ["AVFoundation", "AVFAudio"] + # App Store privacy manifest (data collection + required-reason APIs). + s.resource_bundles = { "Deepgram_Privacy" => ["ios/PrivacyInfo.xcprivacy"] } + install_modules_dependencies(s) end diff --git a/README.md b/README.md index 5f78d95..2c51d24 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ module.exports = { { microphonePermission: 'Allow $(PRODUCT_NAME) to access your microphone.', + backgroundAudio: true, // default — keeps audio alive in the background; set false to skip the Android foreground service + iOS background mode }, ], ], @@ -184,6 +185,8 @@ cd ios && pod install The package is autolinked through `react-native.config.js` — no manual `MainApplication` edits required. +Add `android.permission.RECORD_AUDIO` to your `AndroidManifest.xml` and `NSMicrophoneUsageDescription` to your `Info.plist` if you use any microphone feature. If you also want audio to keep running in the background, see [Background audio](#background-audio) for the opt-in foreground-service entries. + ### Expo (managed or bare) Add the plugin to your config: @@ -358,10 +361,47 @@ For pure speech-to-text usage you generally **don't** want this — leave the op ### Background audio +Background audio is **opt-in**: the library itself ships with no foreground-service permissions, so apps that only use foreground audio (or the pure-REST Text Intelligence / Management APIs) never have to justify foreground-service types in the Google Play Console. + Leave `backgroundAudio: true` on the Expo plugin (it's the default) to keep playback and capture alive when the user leaves the app: - **iOS** — `UIBackgroundModes: ["audio"]` is added to your `Info.plist`. -- **Android** — a foreground service (`DeepgramAudioService`) is bundled with `foregroundServiceType=microphone|mediaPlayback` and the matching `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MICROPHONE`, `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions are merged into your manifest. The service is started/stopped automatically whenever recording or playback is active. +- **Android** — the `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MICROPHONE`, and `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions are added to your manifest, and the bundled `DeepgramAudioService` is given `foregroundServiceType="microphone|mediaPlayback"`. The service is started/stopped automatically whenever recording or playback is active. + +On **bare React Native**, add the equivalent entries to your `AndroidManifest.xml` yourself: + +```xml + + + + + + + + +``` + +(If your app is playback-only — TTS but no mic in the background — you can omit `FOREGROUND_SERVICE_MICROPHONE` and use `android:foregroundServiceType="mediaPlayback"`; the service degrades gracefully.) + +Things to know: + +- **Play Console declaration.** Apps targeting Android 14+ that declare foreground-service types must describe each type's use (and link a demo video) under **App content → Foreground service permissions** in the Play Console. For this package: `TYPE_MICROPHONE` → “Background Audio Access” (live transcription / voice agent continues while backgrounded), `TYPE_MEDIA_PLAYBACK` → “Media Playback” (TTS/agent audio continues while backgrounded). If you can't justify them, set `backgroundAudio: false` (or omit the manifest entries) — then no declaration is needed. +- **App Store privacy.** The pod ships a `PrivacyInfo.xcprivacy` privacy manifest declaring audio-data collection (App Functionality, not linked to identity, no tracking) and its required-reason API usage — Xcode aggregates it into your app's privacy report automatically. Remember to also declare your *own* use of the captured audio (e.g. Deepgram processing/retention) in App Store Connect. +- **Customizing the notification.** By default the Android keep-alive notification uses your app's name and launcher icon, and tapping it opens the app. Brand it via the Expo plugin's [`androidNotification` option](#expo-config-plugin-reference), or on bare React Native with `` entries in your `` element: + + ```xml + + + + + + + ``` + + Each entry is optional. Values can also reference string resources for localization (`android:resource="@string/..."` instead of `android:value`); `ICON` names a drawable/mipmap resource and safely falls back to the launcher icon if missing. +- **Graceful degradation.** If the permissions aren't declared, everything still works while the app is in the foreground; the native module simply skips the keep-alive service instead of crashing. ### Built-in resilience @@ -375,6 +415,58 @@ The native modules handle the awkward edge cases for you: - **iOS 17+ permission API** — `requestMicPermission` calls `AVAudioApplication.requestRecordPermissionWithCompletionHandler` when available, falling back to `AVAudioSession` on older OS versions. - **Bounded playback queue (Android)** — if JS feeds audio faster than the device can play it, the oldest chunks are dropped at ~1.5 MB so you never OOM mid-call. +### Interruption events + +All of the pausing/resuming above happens automatically — but your UI can observe it. Subscribe to interruption events to show “paused — on a call” states or to restart a session after Android tears it down on permanent focus loss: + +```ts +import { addInterruptionListener } from 'react-native-deepgram'; + +const sub = addInterruptionListener((e) => { + if (e.type === 'began') { + // capture/playback paused by the system + // e.reason: 'phoneCall' | 'focusLoss' | 'routeChange' | 'unknown' + } else if (e.type === 'ended') { + // interruption over; e.shouldResume === true means audio already resumed + } else { + // e.type === 'stopped' — Android permanent focus loss tore the session down; + // call startListening()/connect() again if you want to continue + } +}); +// later: sub.remove(); +``` + +The same events are available as an `onInterruption` callback on `useDeepgramSpeechToText`, `useDeepgramVoiceAgent`, and `useDeepgramTextToSpeech`. While a live STT or agent session is active the hooks also react on your behalf: they keep the Deepgram socket warm with `KeepAlive` frames for as long as the interruption holds the microphone (otherwise Deepgram closes the idle socket after ~10 s with `NET-0001`), resume normal streaming when it ends, and end the session gracefully (`onEnd`/`onClose`, no error) when Android tears it down on permanent focus loss. + +### Audio output routing + +Steer playback between the loudspeaker, the earpiece and a connected Bluetooth headset, and observe route changes made outside your app (Control Center, plugging in headphones, …): + +```ts +import { + setAudioRoute, + getAudioRoute, + addAudioRouteChangeListener, +} from 'react-native-deepgram'; + +await setAudioRoute('speaker'); // 'speaker' | 'earpiece' | 'bluetooth' | 'auto' + +const active = await getAudioRoute(); // 'speaker' | 'earpiece' | 'bluetooth' | 'wired' + +const sub = addAudioRouteChangeListener((route) => { + console.log('now playing through', route); +}); +// later: sub.remove(); +``` + +Things to know: + +- **Best-effort semantics.** The OS always has the final say: a wired headset wins over everything, and `bluetooth` only engages once a headset is actually connected (the request is remembered and applied when one appears). Invalid route names reject with `invalid_data`; OS-level failures reject with `playback_error`. +- **Call any time.** You can set a route before or during a session. Set it before `connect()`/`startListening()` and it's applied when the audio session spins up. +- **Bluetooth is opt-in during full-duplex.** In echo-cancelled (`enableVoiceProcessing`) sessions the module doesn't auto-route to Bluetooth — HFP's loopback defeats hardware AEC — so the agent stays on the built-in mic/speaker until you explicitly request `'bluetooth'`. +- **External switches are adopted, not fought.** If the user reroutes audio from Control Center or the system UI, the module accepts the new route (and tells you via the listener) instead of forcing your last request back. +- **Android permissions** — `MODIFY_AUDIO_SETTINGS` and `BLUETOOTH` (API ≤ 30) are merged in from the library manifest automatically. + --- ## Voice Agent (`useDeepgramVoiceAgent`) @@ -1280,8 +1372,8 @@ The package ships with an Expo config plugin (exported from `app.plugin.js`). It | Platform | Effect | | --- | --- | | Android | Adds `android.permission.RECORD_AUDIO` if missing. | -| Android | When `backgroundAudio !== false`, also adds `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MICROPHONE`, and `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions. | -| Android | Registers the `DeepgramAudioService` (microphone + media-playback foreground service). | +| Android | When `backgroundAudio !== false`, adds the `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MICROPHONE`, and `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions **and** sets `android:foregroundServiceType="microphone|mediaPlayback"` on `DeepgramAudioService`. When `false`, none of these are added — your app carries no foreground-service footprint and needs no Play Console foreground-service declaration. | +| Android | When `androidNotification` is provided, writes `com.deepgram.notification.*` `` entries that customize the foreground-service notification (title, text, channel name, icon). | | iOS | Sets `NSMicrophoneUsageDescription` from the `microphonePermission` option (with a sensible fallback). | | iOS | When `backgroundAudio !== false`, adds `audio` to `UIBackgroundModes`. | @@ -1298,6 +1390,12 @@ module.exports = { microphonePermission: 'Allow $(PRODUCT_NAME) to capture audio for real-time transcription.', backgroundAudio: true, // default — set false to disable background audio + foreground service + androidNotification: { + title: 'Transcribing…', // default: your app's name + text: 'Live captions are running', + channelName: 'Live audio', + icon: 'ic_stat_audio', // drawable/mipmap resource name; default: launcher icon + }, }, ], ], @@ -1308,7 +1406,8 @@ module.exports = { | Option | Type | Default | Effect | | --- | --- | --- | --- | | `microphonePermission` | `string` | Generic English fallback | Sets `NSMicrophoneUsageDescription`. Always provide your own copy in production. | -| `backgroundAudio` | `boolean` | `true` | When `false`, **omits** iOS background-audio mode and Android foreground-service permissions. Choose this if your app must never run audio in the background. | +| `backgroundAudio` | `boolean` | `true` | When `false`, **omits** the iOS background-audio mode, the Android foreground-service permissions, and the service's `foregroundServiceType`. Choose this if your app must never run audio in the background — it also spares you the Play Console foreground-service declaration. | +| `androidNotification` | `object` | — | Brands the Android keep-alive notification. `title` (default: app name), `text`, `channelName` (default: title), and `icon` (drawable/mipmap resource name, default: launcher icon) are each optional. See [Background audio](#background-audio) for the bare-RN `` equivalents. | > 🧭 **Bare React Native** without Expo? You can still load the plugin in your own prebuild pipeline: `require('react-native-deepgram/app.plugin.js')`. diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 92b8e10..f822cfe 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,15 +1,29 @@ - - - + + + + + android:exported="false" /> diff --git a/android/src/main/java/com/deepgram/AudioRouteManager.kt b/android/src/main/java/com/deepgram/AudioRouteManager.kt new file mode 100644 index 0000000..650a150 --- /dev/null +++ b/android/src/main/java/com/deepgram/AudioRouteManager.kt @@ -0,0 +1,248 @@ +package com.deepgram + +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.util.Log +import java.util.concurrent.Executor + +/** + * Owns audio output-route selection for the Deepgram module (speaker / + * earpiece / bluetooth / auto) and forwards system route changes (headphone + * plug/unplug, Bluetooth connect/disconnect, communication-device switches) + * back to the module so they can be surfaced to JS as the + * `DeepgramRouteChange` event. + * + * Routing is best-effort and device-dependent. On API 31+ we use the modern + * [AudioManager.setCommunicationDevice] API; on older releases we fall back to + * the legacy `setSpeakerphoneOn` / `startBluetoothSco` switches. Both paths + * take effect most reliably while the module is in a communication use case — + * playback uses `USAGE_VOICE_COMMUNICATION` and the AEC capture path sets + * `MODE_IN_COMMUNICATION`, so the Voice Agent (the primary consumer of route + * control) always qualifies. + */ +internal class AudioRouteManager( + context: Context, + private val mainHandler: Handler, + private val onRouteChange: (String) -> Unit, +) { + private val audioManager: AudioManager? = + context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + + private var deviceCallback: AudioDeviceCallback? = null + private var communicationDeviceListener: + AudioManager.OnCommunicationDeviceChangedListener? = null + + @Volatile + private var requestedRoute: String = ROUTE_AUTO + + /** Begin observing route changes. Idempotent. */ + fun start() { + val manager = audioManager ?: return + if (deviceCallback != null) return + + val callback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array?) { + onRouteChange(currentRoute()) + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + onRouteChange(currentRoute()) + } + } + try { + manager.registerAudioDeviceCallback(callback, mainHandler) + deviceCallback = callback + } catch (e: Exception) { + Log.w(TAG, "registerAudioDeviceCallback failed", e) + } + + // Device add/remove callbacks miss route flips between already-connected + // devices (e.g. speaker -> earpiece). API 31+ surfaces those directly. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val listener = AudioManager.OnCommunicationDeviceChangedListener { + onRouteChange(currentRoute()) + } + try { + val executor = Executor { runnable -> mainHandler.post(runnable) } + manager.addOnCommunicationDeviceChangedListener(executor, listener) + communicationDeviceListener = listener + } catch (e: Exception) { + Log.w(TAG, "addOnCommunicationDeviceChangedListener failed", e) + } + } + } + + /** Stop observing route changes. Idempotent. */ + fun stop() { + val manager = audioManager ?: return + deviceCallback?.let { + try { + manager.unregisterAudioDeviceCallback(it) + } catch (e: Exception) { + Log.w(TAG, "unregisterAudioDeviceCallback failed", e) + } + } + deviceCallback = null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + communicationDeviceListener?.let { + try { + manager.removeOnCommunicationDeviceChangedListener(it) + } catch (e: Exception) { + Log.w(TAG, "removeOnCommunicationDeviceChangedListener failed", e) + } + } + } + communicationDeviceListener = null + } + + /** + * Apply a preferred route. Throws [IllegalArgumentException] for an unknown + * route keyword and [IllegalStateException] when the platform AudioManager + * is unavailable, so the caller can reject the promise with the matching + * contract code. + */ + fun setRoute(route: String) { + require( + route == ROUTE_SPEAKER || + route == ROUTE_EARPIECE || + route == ROUTE_BLUETOOTH || + route == ROUTE_AUTO + ) { + "Unknown audio route '$route'" + } + val manager = audioManager + ?: throw IllegalStateException("AudioManager is unavailable") + requestedRoute = route + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + applyRouteApi31(manager, route) + } else { + applyRouteLegacy(manager, route) + } + onRouteChange(currentRoute()) + } + + /** Resolve the route the system is currently using. */ + fun currentRoute(): String { + val manager = audioManager ?: return ROUTE_SPEAKER + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + manager.communicationDevice?.let { return mapDeviceType(it.type) } + } + + // A wired/USB output that is physically connected wins over the built-in + // speaker/earpiece regardless of our request. + val outputs = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + for (device in outputs) { + when (device.type) { + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_USB_DEVICE -> return ROUTE_WIRED + } + } + + @Suppress("DEPRECATION") + if (manager.isBluetoothScoOn) return ROUTE_BLUETOOTH + + @Suppress("DEPRECATION") + if (manager.isSpeakerphoneOn) return ROUTE_SPEAKER + + return if (requestedRoute == ROUTE_EARPIECE) ROUTE_EARPIECE else ROUTE_SPEAKER + } + + private fun applyRouteApi31(manager: AudioManager, route: String) { + when (route) { + ROUTE_SPEAKER -> + selectCommunicationDevice(manager, intArrayOf(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER)) + ROUTE_EARPIECE -> + selectCommunicationDevice(manager, intArrayOf(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE)) + ROUTE_BLUETOOTH -> + selectCommunicationDevice( + manager, + intArrayOf( + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLE_HEADSET, + ), + ) + else -> manager.clearCommunicationDevice() + } + } + + private fun selectCommunicationDevice(manager: AudioManager, types: IntArray) { + val device = manager.availableCommunicationDevices.firstOrNull { it.type in types } + if (device != null) { + if (!manager.setCommunicationDevice(device)) { + Log.w(TAG, "setCommunicationDevice(type=${device.type}) returned false") + } + } else { + // Requested device isn't connected — fall back to the system default. + manager.clearCommunicationDevice() + } + } + + @Suppress("DEPRECATION") + private fun applyRouteLegacy(manager: AudioManager, route: String) { + when (route) { + ROUTE_SPEAKER -> { + stopSco(manager) + manager.isSpeakerphoneOn = true + } + ROUTE_EARPIECE -> { + stopSco(manager) + manager.isSpeakerphoneOn = false + } + ROUTE_BLUETOOTH -> { + manager.isSpeakerphoneOn = false + try { + manager.startBluetoothSco() + manager.isBluetoothScoOn = true + } catch (e: Exception) { + Log.w(TAG, "startBluetoothSco failed", e) + } + } + else -> { + stopSco(manager) + manager.isSpeakerphoneOn = false + } + } + } + + @Suppress("DEPRECATION") + private fun stopSco(manager: AudioManager) { + try { + if (manager.isBluetoothScoOn) { + manager.isBluetoothScoOn = false + manager.stopBluetoothSco() + } + } catch (e: Exception) { + Log.w(TAG, "stopBluetoothSco failed", e) + } + } + + private fun mapDeviceType(type: Int): String = when (type) { + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> ROUTE_SPEAKER + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> ROUTE_EARPIECE + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_BLE_HEADSET -> ROUTE_BLUETOOTH + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_USB_DEVICE -> ROUTE_WIRED + else -> ROUTE_SPEAKER + } + + companion object { + private const val TAG = "DeepgramRoute" + const val ROUTE_SPEAKER = "speaker" + const val ROUTE_EARPIECE = "earpiece" + const val ROUTE_BLUETOOTH = "bluetooth" + const val ROUTE_WIRED = "wired" + const val ROUTE_AUTO = "auto" + } +} diff --git a/android/src/main/java/com/deepgram/DeepgramAudioService.kt b/android/src/main/java/com/deepgram/DeepgramAudioService.kt index ffeeec7..04c171d 100644 --- a/android/src/main/java/com/deepgram/DeepgramAudioService.kt +++ b/android/src/main/java/com/deepgram/DeepgramAudioService.kt @@ -3,9 +3,11 @@ package com.deepgram import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder @@ -15,11 +17,69 @@ class DeepgramAudioService : Service() { companion object { private const val TAG = "DeepgramAudioService" private const val CHANNEL_ID = "deepgram_audio_channel" - private const val CHANNEL_NAME = "Deepgram Audio" private const val NOTIFICATION_ID = 43123 private const val EXTRA_WITH_MICROPHONE = "with_microphone" + // Optional keys (merged manifest) for customizing the + // keep-alive notification. Set by the Expo plugin's `androidNotification` + // option, or manually by bare RN apps. + private const val META_TITLE = "com.deepgram.notification.TITLE" + private const val META_TEXT = "com.deepgram.notification.TEXT" + private const val META_CHANNEL_NAME = "com.deepgram.notification.CHANNEL_NAME" + private const val META_ICON = "com.deepgram.notification.ICON" + + private const val PERMISSION_FGS = "android.permission.FOREGROUND_SERVICE" + private const val PERMISSION_FGS_MICROPHONE = + "android.permission.FOREGROUND_SERVICE_MICROPHONE" + private const val PERMISSION_FGS_MEDIA_PLAYBACK = + "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" + + /** + * True when [permission] is declared (``) in the host + * app's merged manifest. The FGS permissions are install-time permissions, + * so a declared permission is a granted one. + */ + internal fun isPermissionDeclared(context: Context, permission: String): Boolean { + return try { + val info = context.packageManager.getPackageInfo( + context.packageName, + android.content.pm.PackageManager.GET_PERMISSIONS + ) + info.requestedPermissions?.contains(permission) == true + } catch (e: Exception) { + Log.w(TAG, "Unable to read declared permissions", e) + false + } + } + + /** + * The keep-alive service is opt-in: apps that don't need background audio + * don't declare the FOREGROUND_SERVICE* permissions (so they never have to + * justify them in the Play Console). When they're absent we skip the + * service entirely — audio keeps working while the app is foregrounded. + */ + private fun canStartForegroundService(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return true + if (!isPermissionDeclared(context, PERMISSION_FGS)) return false + if (Build.VERSION.SDK_INT >= 34 && + !isPermissionDeclared(context, PERMISSION_FGS_MEDIA_PLAYBACK) + ) { + return false + } + return true + } + fun start(context: Context, withMicrophone: Boolean) { + if (!canStartForegroundService(context)) { + Log.i( + TAG, + "Foreground-service permissions not declared; skipping keep-alive " + + "service (audio continues while the app is in the foreground). " + + "Enable background audio via the Expo plugin or manifest additions " + + "if you need it." + ) + return + } val intent = Intent(context, DeepgramAudioService::class.java) intent.putExtra(EXTRA_WITH_MICROPHONE, withMicrophone) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -50,9 +110,14 @@ class DeepgramAudioService : Service() { // Always allow media playback; only advertise the microphone type when // we are actually capturing. Declaring FOREGROUND_SERVICE_TYPE_MICROPHONE // for a playback-only session throws a SecurityException on Android 14+ - // when the RECORD_AUDIO permission has not been granted. + // when the RECORD_AUDIO permission has not been granted. On Android 14+ + // the type also requires the FOREGROUND_SERVICE_MICROPHONE permission, + // which is opt-in — degrade to playback-only when it's absent. var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - if (withMicrophone) { + if (withMicrophone && + (Build.VERSION.SDK_INT < 34 || + isPermissionDeclared(this, PERMISSION_FGS_MICROPHONE)) + ) { serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE } startForeground(NOTIFICATION_ID, notification, serviceType) @@ -76,12 +141,14 @@ class DeepgramAudioService : Service() { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return + // setName on an existing channel is an allowed rename, so a config change + // takes effect without reinstalling. val channel = NotificationChannel( CHANNEL_ID, - CHANNEL_NAME, + notificationMeta(META_CHANNEL_NAME) ?: notificationTitle(), NotificationManager.IMPORTANCE_LOW ) - channel.description = "Keeps Deepgram audio capture and playback active" + channel.description = "Keeps audio capture and playback active" manager.createNotificationChannel(channel) } @@ -92,11 +159,76 @@ class DeepgramAudioService : Service() { Notification.Builder(this) } - return builder - .setContentTitle("Deepgram Audio Active") - .setContentText("Recording or playback is running") - .setSmallIcon(android.R.drawable.stat_sys_speakerphone) + builder + .setContentTitle(notificationTitle()) + .setContentText(notificationMeta(META_TEXT) ?: "Audio is running") + .setSmallIcon(notificationIcon()) .setOngoing(true) - .build() + + contentIntent()?.let { builder.setContentIntent(it) } + + return builder.build() + } + + /** Custom title from meta-data, falling back to the host app's label. */ + private fun notificationTitle(): String { + notificationMeta(META_TITLE)?.let { return it } + return try { + packageManager.getApplicationLabel(applicationInfo).toString() + } catch (_: Exception) { + "Audio active" + } + } + + /** + * Custom small icon: a drawable/mipmap resource named by META_ICON, else the + * app's launcher icon, else the previous system speakerphone icon. Never + * throws — a bad icon name must not take down the keep-alive service. + */ + private fun notificationIcon(): Int { + notificationMeta(META_ICON)?.let { name -> + for (type in arrayOf("drawable", "mipmap")) { + @Suppress("DiscouragedApi") + val id = resources.getIdentifier(name, type, packageName) + if (id != 0) return id + } + Log.w(TAG, "Notification icon resource '$name' not found; falling back") + } + if (applicationInfo.icon != 0) return applicationInfo.icon + return android.R.drawable.stat_sys_speakerphone + } + + /** Tap-to-open: launch intent for the host app, when it has one. */ + private fun contentIntent(): PendingIntent? { + val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return null + launch.setPackage(null) + launch.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + return PendingIntent.getActivity( + this, + 0, + launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** + * Read a notification meta-data value from the merged manifest's + * `` element. Supports both literal strings (`android:value`) + * and string resources (`android:resource`) for localization. + */ + private fun notificationMeta(key: String): String? { + return try { + val info = packageManager.getApplicationInfo( + packageName, + PackageManager.GET_META_DATA + ) + val meta = info.metaData ?: return null + val resId = meta.getInt(key, 0) + if (resId != 0) return getString(resId) + meta.getString(key)?.takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w(TAG, "Unable to read notification meta-data '$key'", e) + null + } } } diff --git a/android/src/main/java/com/deepgram/DeepgramModule.kt b/android/src/main/java/com/deepgram/DeepgramModule.kt index dd3336a..86a684d 100644 --- a/android/src/main/java/com/deepgram/DeepgramModule.kt +++ b/android/src/main/java/com/deepgram/DeepgramModule.kt @@ -52,6 +52,23 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) : onForegroundServiceRelease = { stopForegroundAudioServiceIfInactive() }, ) + private val routeManager = AudioRouteManager( + context = reactContext, + mainHandler = mainHandler, + onRouteChange = { route -> sendRouteChange(route) }, + ) + + /** True between a transient focus-loss `began` and the matching `ended`. */ + @Volatile + private var interruptionActive = false + + init { + // Observe output-route changes (headset plug/unplug, Bluetooth + // connect/disconnect) for the module's lifetime so `DeepgramRouteChange` + // fires even while idle. + routeManager.start() + } + override fun getName() = NAME // ------------------------------------------------------------------- @@ -65,15 +82,34 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) : try { player.stopStreamingPlayback(throwOnError = false) } catch (_: Exception) {} try { player.stopOneShotPlayback() } catch (_: Exception) {} try { recorder.stop(throwOnError = false) } catch (_: Exception) {} + interruptionActive = false + sendInterruption { map -> + map.putString("type", "stopped") + map.putString("reason", "focusLossPermanent") + } } override fun onFocusLostTransiently() { // Transient loss (e.g. incoming call) — pause playback but keep state. player.pauseForFocusLoss() + if (!interruptionActive) { + interruptionActive = true + sendInterruption { map -> + map.putString("type", "began") + map.putString("reason", "focusLoss") + } + } } override fun onFocusGained() { player.resumeForFocusGain() + if (interruptionActive) { + interruptionActive = false + sendInterruption { map -> + map.putString("type", "ended") + map.putBoolean("shouldResume", true) + } + } } // ------------------------------------------------------------------- @@ -256,6 +292,34 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) : ) } + // ------------------------------------------------------------------- + // Audio output routing + // ------------------------------------------------------------------- + + @ReactMethod + fun setAudioRoute(route: String, promise: Promise) { + try { + routeManager.setRoute(route) + promise.resolve(null) + } catch (e: IllegalArgumentException) { + Log.e(TAG, "setAudioRoute invalid route", e) + promise.reject("invalid_data", e) + } catch (e: Exception) { + Log.e(TAG, "setAudioRoute error", e) + promise.reject("playback_error", e) + } + } + + @ReactMethod + fun getAudioRoute(promise: Promise) { + try { + promise.resolve(routeManager.currentRoute()) + } catch (e: Exception) { + Log.e(TAG, "getAudioRoute error", e) + promise.reject("playback_error", e) + } + } + override fun invalidate() { try { recorder.stop(false) @@ -269,6 +333,7 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) : Log.w(TAG, "Error stopping playback on invalidate", e) } + routeManager.stop() focusManager.abandonFocus() super.invalidate() } @@ -312,6 +377,43 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) : } } + /** + * Emit the active output route to JS as the `DeepgramRouteChange` event + * (shared event name with iOS; payload `{ route }`). + */ + private fun sendRouteChange(route: String) { + if (!reactContext.hasActiveReactInstance()) return + val map = WritableNativeMap() + map.putString("route", route) + try { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("DeepgramRouteChange", map) + } catch (e: Exception) { + // Catalyst tearing down or JS bundle not ready — drop silently. + Log.w(TAG, "sendRouteChange emit failed", e) + } + } + + /** + * Emit an audio-focus interruption to JS as the `DeepgramInterruption` + * event (shared event name with iOS). Observability only — the focus + * callbacks above already pause/resume/tear down the audio pipeline. + */ + private fun sendInterruption(populate: (WritableNativeMap) -> Unit) { + if (!reactContext.hasActiveReactInstance()) return + val map = WritableNativeMap() + populate(map) + try { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("DeepgramInterruption", map) + } catch (e: Exception) { + // Catalyst tearing down or JS bundle not ready — drop silently. + Log.w(TAG, "sendInterruption emit failed", e) + } + } + companion object { const val NAME = "Deepgram" private const val TAG = "DeepgramModule" diff --git a/example/src/VoiceAgent.tsx b/example/src/VoiceAgent.tsx index 66906dc..605b155 100644 --- a/example/src/VoiceAgent.tsx +++ b/example/src/VoiceAgent.tsx @@ -11,6 +11,11 @@ import { useDeepgramVoiceAgent, createAgentSettings, DeepgramError, + setAudioRoute, + getAudioRoute, + addAudioRouteChangeListener, + type DeepgramAudioRoute, + type DeepgramActiveAudioRoute, } from 'react-native-deepgram'; import OptionSelect from './components/OptionSelect'; import Button from './components/Button'; @@ -57,6 +62,37 @@ export default function VoiceAgent() { const [customMessage, setCustomMessage] = useState(''); const [reconnectAttempt, setReconnectAttempt] = useState(null); const [errorCode, setErrorCode] = useState(null); + const [activeRoute, setActiveRoute] = + useState(null); + const [requestedRoute, setRequestedRoute] = + useState('auto'); + + // Track the actual output route (updates on headset plug/unplug, Bluetooth + // connect/disconnect and speaker ↔ earpiece switches — including switches + // made by the user outside the app). + useEffect(() => { + let mounted = true; + getAudioRoute() + .then((route) => mounted && setActiveRoute(route)) + .catch(() => {}); + const sub = addAudioRouteChangeListener((route) => { + if (mounted) setActiveRoute(route); + }); + return () => { + mounted = false; + sub.remove(); + }; + }, []); + + const requestRoute = async (route: DeepgramAudioRoute) => { + setRequestedRoute(route); + try { + await setAudioRoute(route); + setActiveRoute(await getAudioRoute()); + } catch (err) { + console.warn('setAudioRoute failed', err); + } + }; const agentSettings = useMemo( () => @@ -392,6 +428,35 @@ export default function VoiceAgent() { ) : null} + {/* Audio output routing */} + + + {( + [ + { route: 'auto', label: 'Auto', icon: '⚙️' }, + { route: 'speaker', label: 'Speaker', icon: '🔊' }, + { route: 'earpiece', label: 'Earpiece', icon: '📞' }, + { route: 'bluetooth', label: 'Bluetooth', icon: '🎧' }, + ] as const + ).map(({ route, label, icon }) => ( +