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()