diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs
new file mode 100644
index 00000000..20914d0f
--- /dev/null
+++ b/Runtime/Scripts/Audio/AndroidRouteController.cs
@@ -0,0 +1,582 @@
+#if UNITY_ANDROID && !UNITY_EDITOR
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using LiveKit.Internal;
+using UnityEngine;
+
+namespace LiveKit
+{
+ ///
+ /// Android routing backend for , built on the
+ /// communication-device APIs introduced in Android 12 (API 31):
+ /// AudioManager.getAvailableCommunicationDevices /
+ /// setCommunicationDevice / clearCommunicationDevice.
+ ///
+ /// The controller owns the voice-communication audio session for its whole lifetime
+ /// (construction to ): it enters MODE_IN_COMMUNICATION
+ /// (saving and restoring the prior mode) and keeps the output route pinned to the
+ /// best device — the sticky override while its device is
+ /// still available, otherwise the highest-ranked available kind per the current
+ /// . Owning the mode is what makes the
+ /// pin authoritative: without it the platform periodically reasserts its own default
+ /// route (observed on Pixel 8a: Telecom flipped playout back to the earpiece every
+ /// ~6 s after a Bluetooth session ended). Note that since Android 13 the mode request
+ /// is only honored while the app has active voice-communication capture, so
+ /// re-asserts the policy when capture
+ /// (re)starts.
+ ///
+ /// Route changes are detected two ways, both required (device-verified in the
+ /// sample hotfix this backend is hardened from, PR #364):
+ /// - OnCommunicationDeviceChangedListener — fires when the OS changes or
+ /// clears the pin (e.g. the pinned device disconnected).
+ /// - A poll thread (every 1.5 s) — covers transitions that fire no event: a device
+ /// added while a pin is active, and the trace-verified teardown where a powered-off
+ /// Bluetooth headset stays in the available list up to ~10 s after the route
+ /// already fell back to the earpiece, then leaves the list without another
+ /// communication-device change.
+ ///
+ /// Threading: re-evaluation runs on whichever thread triggered it (Unity main,
+ /// the Android main executor, or the poll thread — all JVM-attached) behind one
+ /// lock. may therefore be raised from any of them;
+ /// marshals it to the Unity main thread.
+ ///
+ internal sealed class AndroidRouteController : IRouteController
+ {
+ // android.media.AudioManager / AudioAttributes constants.
+ private const int ModeInCommunication = 3; // AudioManager.MODE_IN_COMMUNICATION
+ private const int AudioFocusGain = 1; // AudioManager.AUDIOFOCUS_GAIN
+ private const int AudioFocusRequestGranted = 1; // AudioManager.AUDIOFOCUS_REQUEST_GRANTED
+ private const int UsageVoiceCommunication = 2; // AudioAttributes.USAGE_VOICE_COMMUNICATION
+ private const int ContentTypeSpeech = 1; // AudioAttributes.CONTENT_TYPE_SPEECH
+
+ private const int MinSupportedApiLevel = 31;
+ private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1.5);
+
+ private readonly PlatformAudio _owner;
+ private readonly object _gate = new object();
+ private readonly ManualResetEventSlim _stopPoll = new ManualResetEventSlim(false);
+ private readonly List _recordingSnapshot;
+ private readonly Thread _pollThread;
+
+ private List _ranked;
+ private int _stickyDeviceId = -1;
+ private int _pinnedDeviceId = -1;
+ private int _savedAudioMode;
+ private CommunicationDeviceListener _listener;
+ private AndroidJavaObject _audioFocusRequest;
+ private bool _audioFocusEnabled;
+ private List<(int Id, AudioOutputKind Kind, bool IsSelected)> _lastSignature;
+ private bool _disposed;
+
+ public event Action, IReadOnlyList> DevicesChanged;
+
+ ///
+ /// Creates the Android backend, or an on
+ /// Android versions below 12 (API 31), which lack the communication-device APIs
+ /// this backend is built on. On those versions the routing verbs are documented
+ /// no-ops/throws, matching the gate the sample hotfix carried.
+ ///
+ internal static IRouteController Create(PlatformAudio owner, IReadOnlyList initialPreference)
+ {
+ int sdkInt;
+ try
+ {
+ sdkInt = AndroidSdkInt();
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: failed to read Build.VERSION.SDK_INT, routing disabled: {e.Message}");
+ return new UnsupportedRouteController(owner, "this Android device");
+ }
+
+ if (sdkInt < MinSupportedApiLevel)
+ return new UnsupportedRouteController(owner, $"Android API {sdkInt} (routing requires API {MinSupportedApiLevel})");
+
+ return new AndroidRouteController(owner, initialPreference);
+ }
+
+ private AndroidRouteController(PlatformAudio owner, IReadOnlyList initialPreference)
+ {
+ _owner = owner;
+ _ranked = new List(initialPreference);
+
+ // The FFI exposes a single placeholder entry for the OS default input on
+ // Android; input routing follows the communication device, so this list is
+ // static and can back every DevicesChanged payload. Fetched before any
+ // session state is touched so a failure here has no side effects.
+ _recordingSnapshot = owner.GetDevicesViaFfi().Recording;
+
+ EnterCommunicationMode();
+ RegisterListener();
+ Reevaluate();
+
+ _pollThread = new Thread(PollLoop)
+ {
+ IsBackground = true,
+ Name = "LiveKitAndroidRoutePoll",
+ };
+ _pollThread.Start();
+ }
+
+ public (List Recording, List Playout) GetDevices()
+ {
+ var recording = _owner.GetDevicesViaFfi().Recording;
+ var playout = new List();
+ try
+ {
+ using var audioManager = GetAudioManager();
+ using var current = audioManager.Call("getCommunicationDevice");
+ var currentId = current != null ? current.Call("getId") : -1;
+
+ using var available = audioManager.Call("getAvailableCommunicationDevices");
+ var count = available.Call("size");
+ for (var i = 0; i < count; i++)
+ {
+ using var device = available.Call("get", i);
+ playout.Add(ToAudioDevice(device, (uint)i, currentId));
+ }
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: device enumeration failed: {e.Message}");
+ }
+ return (recording, playout);
+ }
+
+ public void ApplyOutputPreference(IReadOnlyList ranked)
+ {
+ lock (_gate)
+ {
+ _ranked = new List(ranked);
+ }
+ Reevaluate();
+ }
+
+ public void SelectOutput(AudioDevice device)
+ {
+ if (string.IsNullOrEmpty(device.Guid)
+ || !int.TryParse(device.Guid, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
+ throw new ArgumentException(
+ $"Device '{device.Name}' does not carry an Android device id; " +
+ "pass an entry from GetDevices().Playout", nameof(device));
+
+ lock (_gate)
+ {
+ _stickyDeviceId = id;
+ }
+ Reevaluate();
+ }
+
+ public void ClearOutputOverride()
+ {
+ lock (_gate)
+ {
+ if (_stickyDeviceId == -1)
+ return;
+ _stickyDeviceId = -1;
+ }
+ Reevaluate();
+ }
+
+ ///
+ /// Optional audio-focus request (AUDIOFOCUS_GAIN with voice-communication
+ /// attributes) held while enabled. Off by default. Not exposed on the public
+ /// API surface (PAR-019 defines it once); flip it here when embedding scenarios
+ /// need focus, until a supported knob exists.
+ ///
+ internal bool AudioFocusEnabled
+ {
+ get
+ {
+ lock (_gate) return _audioFocusEnabled;
+ }
+ set
+ {
+ lock (_gate)
+ {
+ if (_disposed || _audioFocusEnabled == value)
+ return;
+ _audioFocusEnabled = value;
+ if (value)
+ RequestAudioFocus();
+ else
+ AbandonAudioFocus();
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ }
+
+ // Stop the poll first so no re-evaluation runs concurrently with teardown.
+ _stopPoll.Set();
+ if (_pollThread.Join(TimeSpan.FromSeconds(3)))
+ _stopPoll.Dispose();
+ else
+ Utils.Warning("AndroidRouteController: poll thread did not stop in time");
+
+ // Unregister BEFORE clearing the pin: clearCommunicationDevice fires the
+ // change event, and a still-registered listener would immediately re-pin.
+ UnregisterListener();
+
+ lock (_gate)
+ {
+ AbandonAudioFocus();
+ }
+
+ try
+ {
+ using var audioManager = GetAudioManager();
+ audioManager.Call("clearCommunicationDevice");
+ audioManager.Call("setMode", _savedAudioMode);
+ Utils.Debug($"AndroidRouteController: route cleared, audio mode restored ({_savedAudioMode})");
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: failed to restore audio session: {e.Message}");
+ }
+ }
+
+ ///
+ /// Single policy pass: picks the target device (sticky override while its device
+ /// is still available — dropped for good once it disappears — else the best
+ /// available kind by rank), pins it when it differs from the active route, and
+ /// raises when the observable list (ids, kinds,
+ /// selection) changed since the last pass. Re-pinning is skipped when the target
+ /// is already active: our own setCommunicationDevice fires the change listener,
+ /// and that no-op check is what stops the feedback loop. When nothing sticky or
+ /// ranked is available, an existing pin is released so the OS default applies;
+ /// kinds missing from the ranking are never auto-selected.
+ ///
+ private void Reevaluate()
+ {
+ List playout = null;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ try
+ {
+ using var audioManager = GetAudioManager();
+ using var current = audioManager.Call("getCommunicationDevice");
+ var currentId = current != null ? current.Call("getId") : -1;
+
+ using var available = audioManager.Call("getAvailableCommunicationDevices");
+ var count = available.Call("size");
+ var devices = new List<(AndroidJavaObject Device, int Id, AudioOutputKind Kind)>(count);
+ try
+ {
+ for (var i = 0; i < count; i++)
+ {
+ var device = available.Call("get", i);
+ devices.Add((device, device.Call("getId"), KindFromDeviceType(device.Call("getType"))));
+ }
+
+ var targetIndex = -1;
+ if (_stickyDeviceId != -1)
+ {
+ targetIndex = devices.FindIndex(d => d.Id == _stickyDeviceId);
+ if (targetIndex < 0)
+ {
+ Utils.Debug("AndroidRouteController: sticky output device disappeared; reverting to automatic policy");
+ _stickyDeviceId = -1;
+ }
+ }
+
+ if (targetIndex < 0)
+ {
+ var bestRank = int.MaxValue;
+ for (var i = 0; i < devices.Count; i++)
+ {
+ var rank = _ranked.IndexOf(devices[i].Kind);
+ if (rank >= 0 && rank < bestRank)
+ {
+ bestRank = rank;
+ targetIndex = i;
+ }
+ }
+ }
+
+ int selectedId;
+ if (targetIndex >= 0)
+ {
+ var target = devices[targetIndex];
+ if (target.Id != currentId)
+ {
+ var ok = audioManager.Call("setCommunicationDevice", target.Device);
+ Utils.Debug($"AndroidRouteController: setCommunicationDevice(kind={target.Kind}) -> {ok}");
+ if (ok)
+ _pinnedDeviceId = target.Id;
+ selectedId = ok ? target.Id : currentId;
+ }
+ else
+ {
+ selectedId = currentId;
+ }
+ }
+ else
+ {
+ if (_pinnedDeviceId != -1)
+ {
+ audioManager.Call("clearCommunicationDevice");
+ _pinnedDeviceId = -1;
+ Utils.Debug("AndroidRouteController: no ranked device available; cleared pin, OS default applies");
+ using var fallback = audioManager.Call("getCommunicationDevice");
+ selectedId = fallback != null ? fallback.Call("getId") : -1;
+ }
+ else
+ {
+ selectedId = currentId;
+ }
+ }
+
+ var signature = new List<(int Id, AudioOutputKind Kind, bool IsSelected)>(devices.Count);
+ foreach (var d in devices)
+ signature.Add((d.Id, d.Kind, d.Id == selectedId));
+
+ if (SignatureChanged(signature))
+ {
+ _lastSignature = signature;
+ playout = new List(devices.Count);
+ for (var i = 0; i < devices.Count; i++)
+ playout.Add(ToAudioDevice(devices[i].Device, (uint)i, selectedId));
+ }
+ }
+ finally
+ {
+ foreach (var d in devices)
+ d.Device.Dispose();
+ }
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: route evaluation failed: {e.Message}");
+ }
+ }
+
+ // Raised outside the lock; PlatformAudio marshals to the Unity main thread.
+ if (playout != null)
+ DevicesChanged?.Invoke(playout, new List(_recordingSnapshot));
+ }
+
+ private bool SignatureChanged(List<(int Id, AudioOutputKind Kind, bool IsSelected)> signature)
+ {
+ if (_lastSignature == null || _lastSignature.Count != signature.Count)
+ return true;
+ for (var i = 0; i < signature.Count; i++)
+ {
+ if (!_lastSignature[i].Equals(signature[i]))
+ return true;
+ }
+ return false;
+ }
+
+ private void PollLoop()
+ {
+ if (AndroidJNI.AttachCurrentThread() != 0)
+ {
+ Utils.Warning("AndroidRouteController: failed to attach poll thread to the JVM; poll disabled, only OS events will re-route");
+ return;
+ }
+ try
+ {
+ while (!_stopPoll.Wait(PollInterval))
+ Reevaluate();
+ }
+ finally
+ {
+ AndroidJNI.DetachCurrentThread();
+ }
+ }
+
+ private void EnterCommunicationMode()
+ {
+ try
+ {
+ using var audioManager = GetAudioManager();
+ _savedAudioMode = audioManager.Call("getMode");
+ audioManager.Call("setMode", ModeInCommunication);
+ Utils.Debug($"AndroidRouteController: audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})");
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: failed to enter communication mode: {e.Message}");
+ }
+ }
+
+ private void RegisterListener()
+ {
+ try
+ {
+ using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+ using var activity = unityPlayer.GetStatic("currentActivity");
+ using var audioManager = activity.Call("getSystemService", "audio");
+ using var executor = activity.Call("getMainExecutor");
+
+ _listener = new CommunicationDeviceListener(this);
+ audioManager.Call("addOnCommunicationDeviceChangedListener", executor, _listener);
+ }
+ catch (Exception e)
+ {
+ _listener = null;
+ Utils.Warning($"AndroidRouteController: failed to register device listener, falling back to polling only: {e.Message}");
+ }
+ }
+
+ private void UnregisterListener()
+ {
+ if (_listener == null)
+ return;
+ try
+ {
+ using var audioManager = GetAudioManager();
+ audioManager.Call("removeOnCommunicationDeviceChangedListener", _listener);
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: failed to unregister device listener: {e.Message}");
+ }
+ _listener = null;
+ }
+
+ // Both focus methods are called under _gate.
+ private void RequestAudioFocus()
+ {
+ try
+ {
+ using var attributesBuilder = new AndroidJavaObject("android.media.AudioAttributes$Builder");
+ using var withUsage = attributesBuilder.Call("setUsage", UsageVoiceCommunication);
+ using var withContentType = withUsage.Call("setContentType", ContentTypeSpeech);
+ using var attributes = withContentType.Call("build");
+ using var focusBuilder = new AndroidJavaObject("android.media.AudioFocusRequest$Builder", AudioFocusGain);
+ using var withAttributes = focusBuilder.Call("setAudioAttributes", attributes);
+ _audioFocusRequest = withAttributes.Call("build");
+
+ using var audioManager = GetAudioManager();
+ var result = audioManager.Call("requestAudioFocus", _audioFocusRequest);
+ Utils.Debug($"AndroidRouteController: requestAudioFocus -> {(result == AudioFocusRequestGranted ? "granted" : result.ToString())}");
+ }
+ catch (Exception e)
+ {
+ _audioFocusRequest?.Dispose();
+ _audioFocusRequest = null;
+ Utils.Warning($"AndroidRouteController: audio focus request failed: {e.Message}");
+ }
+ }
+
+ private void AbandonAudioFocus()
+ {
+ if (_audioFocusRequest == null)
+ return;
+ try
+ {
+ using var audioManager = GetAudioManager();
+ audioManager.Call("abandonAudioFocusRequest", _audioFocusRequest);
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: failed to abandon audio focus: {e.Message}");
+ }
+ _audioFocusRequest.Dispose();
+ _audioFocusRequest = null;
+ }
+
+ private void OnCommunicationDeviceChangedFromJava()
+ {
+ try
+ {
+ Reevaluate();
+ }
+ catch (Exception e)
+ {
+ Utils.Warning($"AndroidRouteController: listener re-evaluation failed: {e.Message}");
+ }
+ }
+
+ private static AudioDevice ToAudioDevice(AndroidJavaObject device, uint index, int selectedId)
+ {
+ var id = device.Call("getId");
+ using var productName = device.Call("getProductName");
+ return new AudioDevice
+ {
+ Index = index,
+ Name = productName?.Call("toString") ?? string.Empty,
+ Guid = id.ToString(CultureInfo.InvariantCulture),
+ Kind = KindFromDeviceType(device.Call("getType")),
+ IsSelected = id == selectedId,
+ };
+ }
+
+ // AudioDeviceInfo.TYPE_* to AudioOutputKind, mirroring the planned FFI mapping.
+ private static AudioOutputKind KindFromDeviceType(int deviceType)
+ {
+ switch (deviceType)
+ {
+ case 1: // TYPE_BUILTIN_EARPIECE
+ return AudioOutputKind.Earpiece;
+ case 2: // TYPE_BUILTIN_SPEAKER
+ return AudioOutputKind.Speaker;
+ case 3: // TYPE_WIRED_HEADSET
+ case 4: // TYPE_WIRED_HEADPHONES
+ return AudioOutputKind.WiredHeadset;
+ case 7: // TYPE_BLUETOOTH_SCO
+ case 26: // TYPE_BLE_HEADSET
+ case 27: // TYPE_BLE_SPEAKER
+ return AudioOutputKind.Bluetooth;
+ case 22: // TYPE_USB_HEADSET
+ return AudioOutputKind.Usb;
+ case 23: // TYPE_HEARING_AID
+ return AudioOutputKind.HearingAid;
+ default:
+ return AudioOutputKind.Unknown;
+ }
+ }
+
+ private static int AndroidSdkInt()
+ {
+ using var version = new AndroidJavaClass("android.os.Build$VERSION");
+ return version.GetStatic("SDK_INT");
+ }
+
+ // Caller owns the returned object (wrap it in `using var`).
+ private static AndroidJavaObject GetAudioManager()
+ {
+ using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+ using var activity = unityPlayer.GetStatic("currentActivity");
+ return activity.Call("getSystemService", "audio");
+ }
+
+ // C#-side implementation of the Java callback interface. AndroidJavaProxy can
+ // only implement interfaces, which is why this listens for communication-device
+ // changes rather than subclassing android.media.AudioDeviceCallback (an abstract
+ // class); list add/remove transitions that fire no communication-device event
+ // are covered by the poll thread instead.
+ private sealed class CommunicationDeviceListener : AndroidJavaProxy
+ {
+ private readonly AndroidRouteController _controller;
+
+ public CommunicationDeviceListener(AndroidRouteController controller)
+ : base("android.media.AudioManager$OnCommunicationDeviceChangedListener")
+ {
+ _controller = controller;
+ }
+
+ // Invoked by Android on the activity's main executor — a JVM-attached
+ // thread, but not the Unity main thread.
+ public void onCommunicationDeviceChanged(AndroidJavaObject device)
+ {
+ device?.Dispose();
+ _controller.OnCommunicationDeviceChangedFromJava();
+ }
+ }
+ }
+}
+#endif
diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs.meta b/Runtime/Scripts/Audio/AndroidRouteController.cs.meta
new file mode 100644
index 00000000..1f1eb67b
--- /dev/null
+++ b/Runtime/Scripts/Audio/AndroidRouteController.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f96269970c2ac4b4ea77f794848cafae
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs
index 414ef160..db77860b 100644
--- a/Runtime/Scripts/Audio/PlatformAudio.cs
+++ b/Runtime/Scripts/Audio/PlatformAudio.cs
@@ -76,14 +76,16 @@ public struct AudioDevice
///
public string Guid;
///
- /// The kind of output this device represents.
- /// where the platform does not report a type — currently all devices: no routing
- /// backend classifies devices yet.
+ /// The kind of output this device represents. Classified by the Android routing
+ /// backend (Android 12/API 31 and newer);
+ /// where the platform does not report a type or no backend classifies devices
+ /// yet (desktop, iOS, older Android).
///
public AudioOutputKind Kind;
///
- /// Whether this device is the active output route. Only meaningful once a platform
- /// routing backend reports selection state — currently always false.
+ /// Whether this device is the active output route. Reported by the Android
+ /// routing backend (Android 12/API 31 and newer); always false where no backend
+ /// reports selection state yet (desktop, iOS, older Android).
///
public bool IsSelected;
}
@@ -176,7 +178,7 @@ public PlatformAudio()
private IRouteController CreateRouteController()
{
#if UNITY_ANDROID && !UNITY_EDITOR
- return new UnsupportedRouteController(this, "Android");
+ return AndroidRouteController.Create(this, _outputPreference);
#elif UNITY_IOS && !UNITY_EDITOR
return new UnsupportedRouteController(this, "iOS");
#else
@@ -191,9 +193,15 @@ private IRouteController CreateRouteController()
/// - Desktop (Windows/macOS/Linux): returns the full list of microphones and
/// speakers reported by the OS. Devices can be selected with
/// / .
- /// - iOS and Android: returns a single placeholder entry at index 0 for each
- /// list, representing the system's currently selected default input/output.
- /// The OS owns audio routing on these platforms (AVAudioSession on iOS,
+ /// - Android 12 (API 31) and newer: the playout list contains the available
+ /// communication devices with and
+ /// set; entries can be routed to with
+ /// . The recording list stays a single placeholder
+ /// entry for the OS default input — input routing follows the selected
+ /// communication device.
+ /// - iOS and older Android: returns a single placeholder entry at index 0 for
+ /// each list, representing the system's currently selected default
+ /// input/output. The OS owns audio routing there (AVAudioSession on iOS,
/// AudioManager on Android), so individual devices are not enumerated and
/// selecting one is a no-op (see /
/// ).
@@ -202,8 +210,8 @@ private IRouteController CreateRouteController()
/// A tuple containing:
/// - Recording: List of available microphones (on iOS/Android, a single
/// placeholder for the OS default input)
- /// - Playout: List of available speakers/headphones (on iOS/Android, a single
- /// placeholder for the OS default output)
+ /// - Playout: List of available speakers/headphones (on iOS and pre-API-31
+ /// Android, a single placeholder for the OS default output)
///
///
/// Thrown if device enumeration failed.
@@ -268,11 +276,14 @@ private IRouteController CreateRouteController()
/// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority
/// over the built-in outputs, so the Speaker/Earpiece relative order — i.e.
/// — is the only part of the ranking with an
- /// effect. On Android the full ranking applies. On desktop, output is selected
+ /// effect. On Android the full ranking applies: the backend routes to the
+ /// highest-ranked available kind on Android 12 (API 31) and newer, and kinds
+ /// missing from the list are never auto-selected (when nothing ranked is
+ /// available the OS default route applies). On desktop, output is selected
/// per device ( / )
- /// and the ranking has no routing effect. The mobile routing backends are not
- /// implemented yet in this version: on Android and iOS the value is currently
- /// stored and round-trips, but has no routing effect either.
+ /// and the ranking has no routing effect. On older Android versions and on iOS
+ /// (routing backend not implemented yet in this version) the value is stored and
+ /// round-trips, but has no routing effect either.
///
/// Thrown if set to null.
///
@@ -318,11 +329,13 @@ public IReadOnlyList OutputPreference
///
/// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority
/// over the built-in outputs, so this bool is the only part of the ranking with an
- /// effect. On Android the full ranking applies. On desktop, output is selected
- /// per device ( / )
- /// and the ranking has no routing effect. The mobile routing backends are not
- /// implemented yet in this version: on Android and iOS the value is currently
- /// stored and round-trips, but has no routing effect either.
+ /// effect. On Android the full ranking applies: the backend routes to the
+ /// highest-ranked available kind on Android 12 (API 31) and newer. On desktop,
+ /// output is selected per device ( /
+ /// ) and the ranking has no routing effect.
+ /// On older Android versions and on iOS (routing backend not implemented yet in
+ /// this version) the value is stored and round-trips, but has no routing effect
+ /// either.
///
public bool IsSpeakerOutputPreferred
{
@@ -373,16 +386,18 @@ public bool IsSpeakerOutputPreferred
/// when set, otherwise by index and name.
///
/// Platform notes: on desktop this selects the device like
- /// . On Android and iOS the routing backends
- /// are not implemented yet in this version and this method throws
- /// .
+ /// . On Android 12 (API 31) and newer the
+ /// device is pinned as the communication device; the override is dropped once the
+ /// device disappears from the playout list (automatic policy resumes). On older
+ /// Android versions and on iOS (routing backend not implemented yet in this
+ /// version) this method throws .
///
/// A playout device from .
///
/// Thrown if the device does not match any current playout device.
///
///
- /// Thrown on Android and iOS, where no routing backend exists yet.
+ /// Thrown on iOS (no routing backend yet) and on Android below API 31.
///
public void SelectOutput(AudioDevice device)
{
@@ -408,9 +423,10 @@ public void SelectOutput(AudioDevice device)
/// policy applies again.
///
/// Platform notes: on desktop there is no automatic policy to fall back to yet, so
- /// clearing keeps the currently selected device (no-op). On Android and iOS no
- /// override can exist yet ( throws), so this is a no-op
- /// there as well.
+ /// clearing keeps the currently selected device (no-op). On Android 12 (API 31)
+ /// and newer the automatic policy re-routes immediately. On older Android
+ /// versions and on iOS no override can exist ( throws),
+ /// so this is a no-op there.
///
public void ClearOutputOverride()
{
@@ -421,9 +437,12 @@ public void ClearOutputOverride()
/// Raised when the set of available audio devices changes, with the current playout
/// and recording device lists. Raised on the Unity main thread.
///
- /// No implementation raises this event yet in this version: desktop hot-plug events
- /// and the mobile routing backends that produce it are not implemented. Subscribing
- /// and unsubscribing is safe at any time, including after .
+ /// Raised by the Android routing backend (Android 12/API 31 and newer) when the
+ /// available communication devices or the active route change; changes that fire
+ /// no OS event are detected by a poll with roughly 1.5 s of latency. Desktop
+ /// hot-plug events and the iOS backend are not implemented yet in this version, so
+ /// the event is never raised there. Subscribing and unsubscribing is safe at any
+ /// time, including after .
///
public event Action, IReadOnlyList> DevicesChanged;
@@ -590,6 +609,13 @@ public IEnumerator StartRecording()
Utils.Debug("PlatformAudio: started recording");
+ // Re-assert the routing policy now that capture is active. Since Android 13
+ // the app's MODE_IN_COMMUNICATION request — and with it the
+ // communication-device pin — is only honored while the app has active
+ // voice-communication capture, so the platform may have moved the route
+ // while it was un-owned. No-op on the other backends.
+ _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly());
+
// Ensures this method is always a valid iterator even when the PLATFORM_ANDROID
// branch is compiled out (no `yield return` would otherwise be reachable on
// non-Android builds, which is a compile error for IEnumerator-returning methods).
diff --git a/Runtime/Scripts/Audio/RouteController.cs b/Runtime/Scripts/Audio/RouteController.cs
index 31d7861b..e54b65b5 100644
--- a/Runtime/Scripts/Audio/RouteController.cs
+++ b/Runtime/Scripts/Audio/RouteController.cs
@@ -87,10 +87,11 @@ public void Dispose()
}
///
- /// Placeholder backend for platforms whose routing implementation has not landed yet
- /// (Android, iOS). Device snapshots still work through the FFI (a single placeholder
- /// entry for the OS default input/output); the routing verbs throw or no-op as
- /// documented on the public API.
+ /// Placeholder backend for platforms without a routing implementation: iOS (not
+ /// landed yet) and Android below API 31 (which lacks the communication-device APIs
+ /// the Android backend is built on). Device snapshots still work through the FFI (a
+ /// single placeholder entry for the OS default input/output); the routing verbs
+ /// throw or no-op as documented on the public API.
///
internal sealed class UnsupportedRouteController : IRouteController
{
@@ -116,7 +117,7 @@ public void ApplyOutputPreference(IReadOnlyList ranked)
public void SelectOutput(AudioDevice device)
{
throw new NotSupportedException(
- $"SelectOutput is not implemented on {_platform} yet");
+ $"SelectOutput is not supported on {_platform}");
}
public void ClearOutputOverride()