Skip to content

Commit f20ef70

Browse files
authored
fix(react-native): restore screen wake lock and proximity sensing (#1971)
When we migrated away from InCallManager (#1840) to our in-house `callManager`, we didn't port the Wake Lock on iOS and proximity sensing on both Android and iOS. This PR closes that gap. 🎫 Ticket: https://linear.app/stream/issue/RN-291/screen-auto-lock
1 parent 702f409 commit f20ef70

File tree

6 files changed

+316
-49
lines changed

6 files changed

+316
-49
lines changed

.github/workflows/react-native-workflow.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272
- uses: actions/checkout@v4
7373

7474
- name: Select XCode version
75-
run: sudo xcode-select --switch /Applications/Xcode_16.4.app
75+
run: sudo xcode-select --switch /Applications/Xcode_26.0.1.app
7676

7777
- uses: ./.github/actions/rn-bootstrap
7878
timeout-minutes: 20
@@ -103,7 +103,7 @@ jobs:
103103
- uses: actions/checkout@v4
104104

105105
- name: Select XCode version
106-
run: sudo xcode-select --switch /Applications/Xcode_16.4.app
106+
run: sudo xcode-select --switch /Applications/Xcode_26.0.1.app
107107

108108
- uses: ./.github/actions/rn-bootstrap
109109
timeout-minutes: 15
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package com.streamvideo.reactnative.callmanager
2+
3+
import android.content.Context
4+
import android.hardware.Sensor
5+
import android.hardware.SensorEventListener
6+
import android.hardware.SensorManager
7+
import android.media.AudioDeviceInfo
8+
import android.media.AudioManager
9+
import android.os.PowerManager
10+
import android.util.Log
11+
12+
/**
13+
* Encapsulates Android proximity sensor handling for in-call UX.
14+
*
15+
* Responsibilities:
16+
* - Initialize proximity sensor + PowerManager wake lock lazily
17+
* - Register/unregister sensor listener
18+
* - Acquire/release PROXIMITY_SCREEN_OFF_WAKE_LOCK when near/away
19+
* - Provide a simple API: start(), stop(), update()
20+
*/
21+
class ProximityManager(
22+
private val context: Context,
23+
) {
24+
25+
companion object {
26+
const val TAG = "ProximityManager"
27+
}
28+
29+
private var sensorManager: SensorManager? = null
30+
private var proximitySensor: Sensor? = null
31+
private var proximityListener: SensorEventListener? = null
32+
33+
private var powerManager: PowerManager? = null
34+
private var proximityWakeLock: PowerManager.WakeLock? = null
35+
36+
private var proximityRegistered = false
37+
private var initialized = false
38+
39+
fun start() {
40+
this.update()
41+
}
42+
43+
fun stop() {
44+
// Unregister listener and release wakelock
45+
disableProximity()
46+
}
47+
48+
fun onDestroy() {
49+
stop()
50+
}
51+
52+
/**
53+
* Toggle monitoring state based on higher-level decision.
54+
*/
55+
fun update() {
56+
if (!initialized) init()
57+
if (isOnEarpiece()) enableProximity() else disableProximity()
58+
}
59+
60+
private fun init() {
61+
if (initialized) return
62+
try {
63+
sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
64+
proximitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY)
65+
} catch (t: Throwable) {
66+
Log.w(TAG, "Proximity sensor init failed", t)
67+
}
68+
try {
69+
powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
70+
// Obtain PROXIMITY_SCREEN_OFF_WAKE_LOCK via reflection to avoid compile-time dependency
71+
val field = PowerManager::class.java.getField("PROXIMITY_SCREEN_OFF_WAKE_LOCK")
72+
val level = field.getInt(null)
73+
proximityWakeLock = powerManager?.newWakeLock(level, "$TAG:Proximity")
74+
} catch (t: Throwable) {
75+
Log.w(TAG, "Proximity wakelock init failed (may be unsupported on this device)", t)
76+
proximityWakeLock = null
77+
}
78+
initialized = true
79+
}
80+
81+
private fun enableProximity() {
82+
val sensor = proximitySensor
83+
if (sensor == null) {
84+
Log.d(TAG, "No proximity sensor available; skipping enable")
85+
return
86+
}
87+
if (proximityRegistered) return
88+
if (proximityListener == null) {
89+
proximityListener = object : SensorEventListener {
90+
override fun onSensorChanged(event: android.hardware.SensorEvent) {
91+
val max = sensor.maximumRange
92+
val value = event.values.firstOrNull() ?: max
93+
val near = value < max
94+
onProximityChanged(near)
95+
}
96+
97+
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
98+
}
99+
}
100+
try {
101+
sensorManager?.registerListener(
102+
proximityListener,
103+
sensor,
104+
SensorManager.SENSOR_DELAY_NORMAL
105+
)
106+
proximityRegistered = true
107+
Log.d(TAG, "Proximity monitoring ENABLED")
108+
} catch (t: Throwable) {
109+
Log.w(TAG, "Failed to register proximity listener", t)
110+
}
111+
}
112+
113+
private fun disableProximity() {
114+
if (proximityRegistered && proximityListener != null) {
115+
try {
116+
sensorManager?.unregisterListener(proximityListener)
117+
} catch (t: Throwable) {
118+
Log.w(TAG, "Failed to unregister proximity listener", t)
119+
}
120+
}
121+
proximityRegistered = false
122+
releaseProximityWakeLock()
123+
Log.d(TAG, "Proximity monitoring DISABLED")
124+
}
125+
126+
private fun onProximityChanged(near: Boolean) {
127+
if (near) {
128+
acquireProximityWakeLock()
129+
} else {
130+
releaseProximityWakeLock()
131+
}
132+
}
133+
134+
private fun acquireProximityWakeLock() {
135+
try {
136+
val wl = proximityWakeLock
137+
if (wl != null && !wl.isHeld) {
138+
wl.acquire()
139+
Log.d(TAG, "Proximity wakelock ACQUIRED (screen off near ear)")
140+
}
141+
} catch (t: Throwable) {
142+
Log.w(TAG, "Failed to acquire proximity wakelock", t)
143+
}
144+
}
145+
146+
private fun releaseProximityWakeLock() {
147+
try {
148+
val wl = proximityWakeLock
149+
if (wl != null && wl.isHeld) {
150+
wl.release()
151+
Log.d(TAG, "Proximity wakelock RELEASED (screen on)")
152+
}
153+
} catch (t: Throwable) {
154+
Log.w(TAG, "Failed to release proximity wakelock", t)
155+
}
156+
}
157+
158+
private fun isOnEarpiece(): Boolean {
159+
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
160+
// If speakerphone is on, not earpiece
161+
if (audioManager.isSpeakerphoneOn) return false
162+
163+
// Check if Bluetooth SCO/A2DP or wired headset is connected
164+
var hasBt = false
165+
var hasWired = false
166+
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
167+
outputs.forEach { dev ->
168+
val type = dev.type
169+
if (type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
170+
type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
171+
) {
172+
hasBt = true
173+
} else if (type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES
174+
|| type == AudioDeviceInfo.TYPE_WIRED_HEADSET
175+
|| type == AudioDeviceInfo.TYPE_USB_HEADSET
176+
) {
177+
hasWired = true
178+
}
179+
}
180+
181+
return !hasBt && !hasWired
182+
}
183+
}

packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
2020
private var audioManagerActivated = false
2121

2222
private val mAudioDeviceManager = AudioDeviceManager(reactContext)
23-
23+
private val proximityManager = ProximityManager(reactContext)
2424

2525
override fun getName(): String {
2626
return TAG
@@ -40,6 +40,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
4040
}
4141

4242
override fun invalidate() {
43+
// Ensure we cleanup proximity and screen flags too
44+
stop()
4345
mAudioDeviceManager.close()
4446
super.invalidate()
4547
}
@@ -87,6 +89,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
8789
mAudioDeviceManager.start(it)
8890
setKeepScreenOn(true)
8991
audioManagerActivated = true
92+
// Initialize and evaluate proximity monitoring via controller
93+
proximityManager.start()
9094
}
9195
}
9296
}
@@ -99,6 +103,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
99103
Log.d(TAG, "stop() mAudioDeviceManager")
100104
mAudioDeviceManager.stop()
101105
setMicrophoneMute(false)
106+
// Disable proximity monitoring via controller and clear keep-screen-on
107+
proximityManager.stop()
102108
setKeepScreenOn(false)
103109
audioManagerActivated = false
104110
}
@@ -127,6 +133,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
127133
return
128134
}
129135
mAudioDeviceManager.setSpeakerphoneOn(enable)
136+
// Re-evaluate proximity monitoring when route may change
137+
this.proximityManager.update()
130138
}
131139

132140
@ReactMethod
@@ -152,6 +160,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
152160
mAudioDeviceManager.switchDeviceFromDeviceName(
153161
endpointDeviceName
154162
)
163+
// Re-evaluate proximity monitoring when endpoint changes
164+
this.proximityManager.update()
155165
}
156166

157167
@ReactMethod
@@ -164,6 +174,7 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
164174
mAudioDeviceManager.unmuteAudioOutput()
165175
}
166176

177+
167178
override fun onHostResume() {
168179
}
169180

0 commit comments

Comments
 (0)