diff --git a/.github/workflows/react-native-workflow.yml b/.github/workflows/react-native-workflow.yml index ce69988b02..dbf1456585 100644 --- a/.github/workflows/react-native-workflow.yml +++ b/.github/workflows/react-native-workflow.yml @@ -72,7 +72,7 @@ jobs: - uses: actions/checkout@v4 - name: Select XCode version - run: sudo xcode-select --switch /Applications/Xcode_16.4.app + run: sudo xcode-select --switch /Applications/Xcode_26.0.1.app - uses: ./.github/actions/rn-bootstrap timeout-minutes: 20 @@ -103,7 +103,7 @@ jobs: - uses: actions/checkout@v4 - name: Select XCode version - run: sudo xcode-select --switch /Applications/Xcode_16.4.app + run: sudo xcode-select --switch /Applications/Xcode_26.0.1.app - uses: ./.github/actions/rn-bootstrap timeout-minutes: 15 diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/ProximityManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/ProximityManager.kt new file mode 100644 index 0000000000..52e9262b29 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/ProximityManager.kt @@ -0,0 +1,183 @@ +package com.streamvideo.reactnative.callmanager + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.PowerManager +import android.util.Log + +/** + * Encapsulates Android proximity sensor handling for in-call UX. + * + * Responsibilities: + * - Initialize proximity sensor + PowerManager wake lock lazily + * - Register/unregister sensor listener + * - Acquire/release PROXIMITY_SCREEN_OFF_WAKE_LOCK when near/away + * - Provide a simple API: start(), stop(), update() + */ +class ProximityManager( + private val context: Context, +) { + + companion object { + const val TAG = "ProximityManager" + } + + private var sensorManager: SensorManager? = null + private var proximitySensor: Sensor? = null + private var proximityListener: SensorEventListener? = null + + private var powerManager: PowerManager? = null + private var proximityWakeLock: PowerManager.WakeLock? = null + + private var proximityRegistered = false + private var initialized = false + + fun start() { + this.update() + } + + fun stop() { + // Unregister listener and release wakelock + disableProximity() + } + + fun onDestroy() { + stop() + } + + /** + * Toggle monitoring state based on higher-level decision. + */ + fun update() { + if (!initialized) init() + if (isOnEarpiece()) enableProximity() else disableProximity() + } + + private fun init() { + if (initialized) return + try { + sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + proximitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY) + } catch (t: Throwable) { + Log.w(TAG, "Proximity sensor init failed", t) + } + try { + powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + // Obtain PROXIMITY_SCREEN_OFF_WAKE_LOCK via reflection to avoid compile-time dependency + val field = PowerManager::class.java.getField("PROXIMITY_SCREEN_OFF_WAKE_LOCK") + val level = field.getInt(null) + proximityWakeLock = powerManager?.newWakeLock(level, "$TAG:Proximity") + } catch (t: Throwable) { + Log.w(TAG, "Proximity wakelock init failed (may be unsupported on this device)", t) + proximityWakeLock = null + } + initialized = true + } + + private fun enableProximity() { + val sensor = proximitySensor + if (sensor == null) { + Log.d(TAG, "No proximity sensor available; skipping enable") + return + } + if (proximityRegistered) return + if (proximityListener == null) { + proximityListener = object : SensorEventListener { + override fun onSensorChanged(event: android.hardware.SensorEvent) { + val max = sensor.maximumRange + val value = event.values.firstOrNull() ?: max + val near = value < max + onProximityChanged(near) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + } + } + try { + sensorManager?.registerListener( + proximityListener, + sensor, + SensorManager.SENSOR_DELAY_NORMAL + ) + proximityRegistered = true + Log.d(TAG, "Proximity monitoring ENABLED") + } catch (t: Throwable) { + Log.w(TAG, "Failed to register proximity listener", t) + } + } + + private fun disableProximity() { + if (proximityRegistered && proximityListener != null) { + try { + sensorManager?.unregisterListener(proximityListener) + } catch (t: Throwable) { + Log.w(TAG, "Failed to unregister proximity listener", t) + } + } + proximityRegistered = false + releaseProximityWakeLock() + Log.d(TAG, "Proximity monitoring DISABLED") + } + + private fun onProximityChanged(near: Boolean) { + if (near) { + acquireProximityWakeLock() + } else { + releaseProximityWakeLock() + } + } + + private fun acquireProximityWakeLock() { + try { + val wl = proximityWakeLock + if (wl != null && !wl.isHeld) { + wl.acquire() + Log.d(TAG, "Proximity wakelock ACQUIRED (screen off near ear)") + } + } catch (t: Throwable) { + Log.w(TAG, "Failed to acquire proximity wakelock", t) + } + } + + private fun releaseProximityWakeLock() { + try { + val wl = proximityWakeLock + if (wl != null && wl.isHeld) { + wl.release() + Log.d(TAG, "Proximity wakelock RELEASED (screen on)") + } + } catch (t: Throwable) { + Log.w(TAG, "Failed to release proximity wakelock", t) + } + } + + private fun isOnEarpiece(): Boolean { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + // If speakerphone is on, not earpiece + if (audioManager.isSpeakerphoneOn) return false + + // Check if Bluetooth SCO/A2DP or wired headset is connected + var hasBt = false + var hasWired = false + val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + outputs.forEach { dev -> + val type = dev.type + if (type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + ) { + hasBt = true + } else if (type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES + || type == AudioDeviceInfo.TYPE_WIRED_HEADSET + || type == AudioDeviceInfo.TYPE_USB_HEADSET + ) { + hasWired = true + } + } + + return !hasBt && !hasWired + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt index 1be164d32d..cec8eeb7bd 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt @@ -20,7 +20,7 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : private var audioManagerActivated = false private val mAudioDeviceManager = AudioDeviceManager(reactContext) - + private val proximityManager = ProximityManager(reactContext) override fun getName(): String { return TAG @@ -40,6 +40,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : } override fun invalidate() { + // Ensure we cleanup proximity and screen flags too + stop() mAudioDeviceManager.close() super.invalidate() } @@ -87,6 +89,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : mAudioDeviceManager.start(it) setKeepScreenOn(true) audioManagerActivated = true + // Initialize and evaluate proximity monitoring via controller + proximityManager.start() } } } @@ -99,6 +103,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : Log.d(TAG, "stop() mAudioDeviceManager") mAudioDeviceManager.stop() setMicrophoneMute(false) + // Disable proximity monitoring via controller and clear keep-screen-on + proximityManager.stop() setKeepScreenOn(false) audioManagerActivated = false } @@ -127,6 +133,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : return } mAudioDeviceManager.setSpeakerphoneOn(enable) + // Re-evaluate proximity monitoring when route may change + this.proximityManager.update() } @ReactMethod @@ -152,6 +160,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : mAudioDeviceManager.switchDeviceFromDeviceName( endpointDeviceName ) + // Re-evaluate proximity monitoring when endpoint changes + this.proximityManager.update() } @ReactMethod @@ -164,6 +174,7 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : mAudioDeviceManager.unmuteAudioOutput() } + override fun onHostResume() { } diff --git a/packages/react-native-sdk/ios/StreamInCallManager.swift b/packages/react-native-sdk/ios/StreamInCallManager.swift index dff568cec4..de00733031 100644 --- a/packages/react-native-sdk/ios/StreamInCallManager.swift +++ b/packages/react-native-sdk/ios/StreamInCallManager.swift @@ -18,32 +18,32 @@ enum DefaultAudioDevice { @objc(StreamInCallManager) class StreamInCallManager: RCTEventEmitter { - + private let audioSessionQueue = DispatchQueue(label: "io.getstream.rn.audioSessionQueue") - + private var audioManagerActivated = false private var callAudioRole: CallAudioRole = .communicator private var defaultAudioDevice: DefaultAudioDevice = .speaker private var previousVolume: Float = 0.75 - + private struct AudioSessionState { let category: AVAudioSession.Category let mode: AVAudioSession.Mode let options: AVAudioSession.CategoryOptions } - + private var previousAudioSessionState: AudioSessionState? - + private var hasRegisteredRouteObserver = false + override func invalidate() { stop() super.invalidate() } - + override static func requiresMainQueueSetup() -> Bool { return false } - - + @objc(setAudioRole:) func setAudioRole(audioRole: String) { audioSessionQueue.async { [self] in @@ -54,7 +54,7 @@ class StreamInCallManager: RCTEventEmitter { self.callAudioRole = audioRole.lowercased() == "listener" ? .listener : .communicator } } - + @objc(setDefaultAudioDeviceEndpointType:) func setDefaultAudioDeviceEndpointType(endpointType: String) { audioSessionQueue.async { [self] in @@ -65,7 +65,7 @@ class StreamInCallManager: RCTEventEmitter { self.defaultAudioDevice = endpointType.lowercased() == "earpiece" ? .earpiece : .speaker } } - + @objc func start() { audioSessionQueue.async { [self] in @@ -79,10 +79,17 @@ class StreamInCallManager: RCTEventEmitter { options: session.categoryOptions ) configureAudioSession() + // Enable wake lock to prevent the screen from dimming/locking during a call + DispatchQueue.main.async { + UIApplication.shared.isIdleTimerDisabled = true + self.registerAudioRouteObserver() + self.updateProximityMonitoring() + self.log("Wake lock enabled (idle timer disabled)") + } audioManagerActivated = true } } - + @objc func stop() { audioSessionQueue.async { [self] in @@ -100,13 +107,20 @@ class StreamInCallManager: RCTEventEmitter { } audioManagerActivated = false } + // Disable wake lock and proximity when call manager stops so the device can sleep again + DispatchQueue.main.async { + self.setProximityMonitoringEnabled(false) + self.unregisterAudioRouteObserver() + UIApplication.shared.isIdleTimerDisabled = false + self.log("Wake lock disabled (idle timer enabled)") + } } - + private func configureAudioSession() { let intendedCategory: AVAudioSession.Category! let intendedMode: AVAudioSession.Mode! let intendedOptions: AVAudioSession.CategoryOptions! - + if (callAudioRole == .listener) { // enables high quality audio playback but disables microphone intendedCategory = .playback @@ -115,16 +129,16 @@ class StreamInCallManager: RCTEventEmitter { } else { intendedCategory = .playAndRecord intendedMode = .voiceChat - + if (defaultAudioDevice == .speaker) { // defaultToSpeaker will route to speaker if nothing else is connected - intendedOptions = [.allowBluetooth, .defaultToSpeaker] + intendedOptions = [.allowBluetoothHFP, .defaultToSpeaker] } else { // having no defaultToSpeaker makes sure audio goes to earpiece if nothing is connected - intendedOptions = [.allowBluetooth] + intendedOptions = [.allowBluetoothHFP] } } - + // START: set the config that webrtc must use when it takes control let rtcConfig = RTCAudioSessionConfiguration.webRTC() rtcConfig.category = intendedCategory.rawValue @@ -132,14 +146,14 @@ class StreamInCallManager: RCTEventEmitter { rtcConfig.categoryOptions = intendedOptions RTCAudioSessionConfiguration.setWebRTC(rtcConfig) // END - + // START: compare current audio session with intended, and update if different let session = RTCAudioSession.sharedInstance() let currentCategory = session.category let currentMode = session.mode let currentOptions = session.categoryOptions let currentIsActive = session.isActive - + if currentCategory != intendedCategory.rawValue || currentMode != intendedMode.rawValue || currentOptions != intendedOptions || !currentIsActive { session.lockForConfiguration() do { @@ -162,7 +176,7 @@ class StreamInCallManager: RCTEventEmitter { } // END } - + @objc(showAudioRoutePicker) public func showAudioRoutePicker() { guard #available(iOS 11.0, tvOS 11.0, macOS 10.15, *) else { @@ -177,7 +191,7 @@ class StreamInCallManager: RCTEventEmitter { .sendActions(for: .touchUpInside) } } - + @objc(setForceSpeakerphoneOn:) func setForceSpeakerphoneOn(enable: Bool) { let session = AVAudioSession.sharedInstance() @@ -188,12 +202,12 @@ class StreamInCallManager: RCTEventEmitter { log("Error setting speakerphone: \(error)") } } - + @objc(setMicrophoneMute:) func setMicrophoneMute(enable: Bool) { log("iOS does not support setMicrophoneMute()") } - + @objc func logAudioState() { let session = AVAudioSession.sharedInstance() @@ -209,17 +223,17 @@ class StreamInCallManager: RCTEventEmitter { """ log(logString) } - + @objc(muteAudioOutput) func muteAudioOutput() { DispatchQueue.main.async { [self] in let volumeView = MPVolumeView() - + // Add to a temporary view hierarchy to make it functional if let window = getCurrentWindow() { volumeView.frame = CGRect(x: -1000, y: -1000, width: 1, height: 1) window.addSubview(volumeView) - + // Give it a moment to initialize DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { @@ -230,24 +244,24 @@ class StreamInCallManager: RCTEventEmitter { } else { self.log("Could not find volume slider") } - + // Remove from view hierarchy after use volumeView.removeFromSuperview() } } } } - + @objc(unmuteAudioOutput) func unmuteAudioOutput() { DispatchQueue.main.async { [self] in let volumeView = MPVolumeView() - + // Add to a temporary view hierarchy to make it functional if let window = getCurrentWindow() { volumeView.frame = CGRect(x: -1000, y: -1000, width: 1, height: 1) window.addSubview(volumeView) - + // Give it a moment to initialize DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { @@ -258,31 +272,84 @@ class StreamInCallManager: RCTEventEmitter { } else { self.log("Could not find volume slider") } - + // Remove from view hierarchy after use volumeView.removeFromSuperview() } } } } - + + // MARK: - Proximity Handling + private func registerAudioRouteObserver() { + if hasRegisteredRouteObserver { return } + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + hasRegisteredRouteObserver = true + log("Registered AVAudioSession.routeChangeNotification observer") + } + + private func unregisterAudioRouteObserver() { + if !hasRegisteredRouteObserver { return } + NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil) + hasRegisteredRouteObserver = false + log("Unregistered AVAudioSession.routeChangeNotification observer") + } + + @objc private func handleAudioRouteChange(_ notification: Notification) { + // Route changes can arrive on arbitrary queues; ensure UI-safe work on main + DispatchQueue.main.async { [weak self] in + self?.updateProximityMonitoring() + } + } + + private func updateProximityMonitoring() { + // Proximity is only meaningful while a call is active + guard audioManagerActivated else { + setProximityMonitoringEnabled(false) + return + } + let session = AVAudioSession.sharedInstance() + let port = session.currentRoute.outputs.first?.portType + let isEarpiece = (port == .builtInReceiver) + setProximityMonitoringEnabled(isEarpiece) + } + + private func setProximityMonitoringEnabled(_ enabled: Bool) { + // Always toggle on the main thread + if Thread.isMainThread { + if UIDevice.current.isProximityMonitoringEnabled != enabled { + UIDevice.current.isProximityMonitoringEnabled = enabled + log("Proximity monitoring \(enabled ? "ENABLED" : "DISABLED")") + } + } else { + DispatchQueue.main.async { [weak self] in + self?.setProximityMonitoringEnabled(enabled) + } + } + } + // MARK: - RCTEventEmitter - + override func supportedEvents() -> [String]! { // TODO: list events that can be sent to JS return [] } - + @objc override func addListener(_ eventName: String!) { super.addListener(eventName) } - + @objc override func removeListeners(_ count: Double) { super.removeListeners(count) } - + // MARK: - Helper Methods private func getCurrentWindow() -> UIWindow? { if #available(iOS 13.0, *) { @@ -294,10 +361,10 @@ class StreamInCallManager: RCTEventEmitter { return UIApplication.shared.keyWindow } } - + // MARK: - Logging Helper private func log(_ message: String) { NSLog("InCallManager: %@", message) } - + } diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock index ccb94872ad..b88ca8450a 100644 --- a/sample-apps/react-native/dogfood/ios/Podfile.lock +++ b/sample-apps/react-native/dogfood/ios/Podfile.lock @@ -2937,7 +2937,7 @@ PODS: - SocketRocket - Yoga - SocketRocket (0.7.1) - - stream-chat-react-native (8.5.2): + - stream-chat-react-native (8.6.0): - boost - DoubleConversion - fast_float @@ -2966,7 +2966,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - stream-io-noise-cancellation-react-native (0.3.0): + - stream-io-noise-cancellation-react-native (0.4.0): - boost - DoubleConversion - fast_float @@ -2996,7 +2996,7 @@ PODS: - stream-react-native-webrtc - StreamVideoNoiseCancellation - Yoga - - stream-io-video-filters-react-native (0.7.0): + - stream-io-video-filters-react-native (0.8.0): - boost - DoubleConversion - fast_float @@ -3028,7 +3028,7 @@ PODS: - stream-react-native-webrtc (125.4.4): - React-Core - StreamWebRTC (~> 125.6422.070) - - stream-video-react-native (1.21.2): + - stream-video-react-native (1.22.3): - boost - DoubleConversion - fast_float @@ -3461,11 +3461,11 @@ SPEC CHECKSUMS: RNVoipPushNotification: 4998fe6724d421da616dca765da7dc421ff54c4e RNWorklets: ad0606bee2a8103c14adb412149789c60b72bfb2 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: d30002093f5d1be72c6a7d89bc98df03f43502d0 - stream-io-noise-cancellation-react-native: c200e3f29c32bd274e3fcf5a29fd4b9e7c63f9ed - stream-io-video-filters-react-native: 3ec369fd17ee59c0628aabe5e0b2bebb28d93b3b + stream-chat-react-native: c88c3a1087393358e660885479e21be1f2c286a3 + stream-io-noise-cancellation-react-native: ed874466f2e7967ada45a9e4dfad147dabe8f9dd + stream-io-video-filters-react-native: 1336c7f604d99d452817b90828389f47771f9417 stream-react-native-webrtc: 460795039c3aa0c83c882fe2cc59f5ebae3f6a18 - stream-video-react-native: cf65d64de93d6d464c7b28672824188a00ce2a1f + stream-video-react-native: 11f2d6596a7058c418e82c1d975b41589bd87715 StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490 StreamWebRTC: a50ebd8beba4def8f4e378b4895824c3520f9889 VisionCamera: 891edb31806dd3a239c8a9d6090d6ec78e11ee80 diff --git a/sample-apps/react-native/dogfood/src/navigators/Call.tsx b/sample-apps/react-native/dogfood/src/navigators/Call.tsx index ed6c55df77..ef1de37389 100644 --- a/sample-apps/react-native/dogfood/src/navigators/Call.tsx +++ b/sample-apps/react-native/dogfood/src/navigators/Call.tsx @@ -7,6 +7,7 @@ import { RingingCallContent, StreamCall, useCalls, + callManager, } from '@stream-io/video-react-native-sdk'; import { StyleSheet } from 'react-native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -43,7 +44,12 @@ const Calls = () => { const CallLeaveOnUnmount = ({ call }: { call: StreamCallType }) => { useEffect(() => { + callManager.start({ + audioRole: 'communicator', + deviceEndpointType: 'earpiece', + }); return () => { + callManager.stop(); if (call && call.state.callingState !== CallingState.LEFT) { call.leave(); }