Skip to content

Commit ab7a555

Browse files
committed
Better handling of audio devices, added proximity sensor *only* when on earpiece mode
1 parent 2db4187 commit ab7a555

File tree

2 files changed

+116
-92
lines changed

2 files changed

+116
-92
lines changed

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ package io.element.android.features.call.impl.ui
1010
import android.Manifest
1111
import android.app.PictureInPictureParams
1212
import android.content.Intent
13+
import android.media.AudioDeviceInfo
14+
import android.media.AudioManager
15+
import android.media.AudioManager.OnCommunicationDeviceChangedListener
1316
import android.os.Build
1417
import android.os.Bundle
1518
import android.os.PowerManager
@@ -32,6 +35,7 @@ import androidx.core.content.IntentCompat
3235
import androidx.core.content.getSystemService
3336
import androidx.core.util.Consumer
3437
import androidx.lifecycle.Lifecycle
38+
import androidx.lifecycle.lifecycleScope
3539
import io.element.android.features.call.api.CallType
3640
import io.element.android.features.call.api.CallType.ExternalUrl
3741
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
@@ -51,7 +55,12 @@ import io.element.android.libraries.core.log.logger.LoggerTag
5155
import io.element.android.libraries.core.meta.BuildMeta
5256
import io.element.android.libraries.designsystem.theme.ElementThemeApp
5357
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
58+
import kotlinx.coroutines.flow.MutableStateFlow
59+
import kotlinx.coroutines.flow.launchIn
60+
import kotlinx.coroutines.flow.onCompletion
61+
import kotlinx.coroutines.flow.onEach
5462
import timber.log.Timber
63+
import java.util.concurrent.Executors
5564
import javax.inject.Inject
5665

5766
private val loggerTag = LoggerTag("ElementCallActivity")
@@ -78,12 +87,6 @@ class ElementCallActivity :
7887

7988
private var eventSink: ((CallScreenEvents) -> Unit)? = null
8089

81-
private val proximitySensorWakeLock: PowerManager.WakeLock? by lazy {
82-
getSystemService<PowerManager>()
83-
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
84-
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "$packageName:ProximitySensorCallWakeLock")
85-
}
86-
8790
override fun onCreate(savedInstanceState: Bundle?) {
8891
super.onCreate(savedInstanceState)
8992

@@ -140,26 +143,6 @@ class ElementCallActivity :
140143
}
141144
}
142145

143-
override fun onStart() {
144-
super.onStart()
145-
146-
if (proximitySensorWakeLock?.isHeld == false) {
147-
val proximitySensorEnabled = proximitySensorWakeLock?.acquire()
148-
Timber.d("Proximity sensor wake lock acquired: $proximitySensorEnabled")
149-
} else {
150-
Timber.d("Proximity sensor wakelock does not exist or is already held")
151-
}
152-
}
153-
154-
override fun onStop() {
155-
super.onStop()
156-
157-
if (proximitySensorWakeLock?.isHeld == true) {
158-
proximitySensorWakeLock?.release()
159-
Timber.d("Proximity sensor wake lock released")
160-
}
161-
}
162-
163146
private fun setCallIsActive() {
164147
audioFocus.requestAudioFocus(
165148
requester = AudioFocusRequester.ElementCall,

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt

Lines changed: 107 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,18 @@ import android.media.AudioDeviceCallback
1212
import android.media.AudioDeviceInfo
1313
import android.media.AudioManager
1414
import android.os.Build
15+
import android.os.PowerManager
1516
import android.webkit.JavascriptInterface
1617
import android.webkit.WebView
18+
import androidx.core.content.getSystemService
1719
import kotlinx.coroutines.MainScope
1820
import kotlinx.coroutines.launch
21+
import kotlinx.serialization.Serializable
22+
import kotlinx.serialization.Transient
23+
import kotlinx.serialization.json.Json
1924
import timber.log.Timber
2025
import java.util.concurrent.Executors
2126
import java.util.concurrent.atomic.AtomicBoolean
22-
import kotlin.math.exp
2327

2428
class WebViewAudioManager(
2529
private val webView: WebView,
@@ -43,6 +47,12 @@ class WebViewAudioManager(
4347

4448
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
4549

50+
private val proximitySensorWakeLock by lazy {
51+
webView.context.getSystemService<PowerManager>()
52+
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
53+
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${webView.context.packageName}:ProximitySensorCallWakeLock")
54+
}
55+
4656
private val commsDeviceChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device ->
4757
if (device?.id == expectedNewCommunicationDeviceId) {
4858
if (device != null) {
@@ -65,19 +75,38 @@ class WebViewAudioManager(
6575

6676
private val audioDeviceCallback = object : AudioDeviceCallback() {
6777
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
68-
setAvailableAudioDevices()
69-
// TODO: maybe only change the selected device to a new external one
70-
selectDefaultAudioDevice()
78+
val validNewDevices = addedDevices.orEmpty().filter { it.type in wantedDeviceTypes && it.isSink }
79+
if (validNewDevices.isEmpty()) return
80+
81+
val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }
82+
setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo))
83+
// This should automatically switch to a new device if it has a higher priority than the current one
84+
selectDefaultAudioDevice(audioDevices)
7185
}
7286

7387
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
88+
// Update the available devices
7489
setAvailableAudioDevices()
75-
// TODO: maybe only change the selected device if it was one of the added devices
76-
selectDefaultAudioDevice()
90+
91+
// Unless the removed device is the current one, we don't need to do anything else
92+
val removedCurrentDevice = removedDevices.orEmpty().any { it.id == currentDeviceId }
93+
if (!removedCurrentDevice) return
94+
95+
val previousDevice = previousSelectedDevice
96+
if (previousDevice != null) {
97+
previousSelectedDevice = null
98+
// If we have a previous device, we should select it again
99+
audioManager.selectAudioDevice(previousDevice.id.toString())
100+
} else {
101+
// If we don't have a previous device, we should select the default one
102+
selectDefaultAudioDevice()
103+
}
77104
}
78105
}
79106

107+
private var currentDeviceId: Int? = null
80108
private var expectedNewCommunicationDeviceId: Int? = null
109+
private var previousSelectedDevice: AudioDeviceInfo? = null
81110

82111
val isInCallMode = AtomicBoolean(false)
83112

@@ -123,6 +152,10 @@ class WebViewAudioManager(
123152
audioManager.removeOnCommunicationDeviceChangedListener(commsDeviceChangedListener)
124153
}
125154

155+
if (proximitySensorWakeLock?.isHeld == true) {
156+
proximitySensorWakeLock?.release()
157+
}
158+
126159
audioManager.mode = AudioManager.MODE_NORMAL
127160
}
128161

@@ -131,49 +164,45 @@ class WebViewAudioManager(
131164
webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { onAudioDeviceSelectedCallback.setOutputDevice(id); };", null)
132165
}
133166

134-
private fun setAvailableAudioDevices() {
135-
val devices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
136-
audioManager.availableCommunicationDevices.map(CompatAudioDevice::fromAudioDeviceInfo)
167+
private fun listAudioDevices(): List<AudioDeviceInfo> {
168+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
169+
audioManager.availableCommunicationDevices
137170
} else {
138171
val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL)
139-
rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink }.map { CompatAudioDevice.fromAudioDeviceInfo(it) }
172+
rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink }
140173
}
174+
}
175+
176+
private fun setAvailableAudioDevices(
177+
devices: List<SerializableAudioDevice> = listAudioDevices().map(SerializableAudioDevice::fromAudioDeviceInfo),
178+
) {
141179
Timber.d("Updating available audio devices")
142-
val deviceList = devices.joinToString(",") { "{ 'id': '${it.id}', 'name': '${deviceName(it.type, it.name)}' }" }
143-
webView.evaluateJavascript("controls.setAvailableOutputDevices([$deviceList]);", {
180+
val jsonSerializer = Json {
181+
encodeDefaults = true
182+
explicitNulls = false
183+
}
184+
val deviceList = jsonSerializer.encodeToString(devices)
185+
webView.evaluateJavascript("controls.setAvailableOutputDevices($deviceList);", {
144186
Timber.d("Audio: setAvailableOutputDevices result: $it")
145187
})
146188
}
147189

148190
private fun registerWebViewDeviceSelectedCallback() {
149-
val webViewAudioDeviceSelectedCallback = WebViewAudioOutputCallback {
150-
Timber.d("Audio device selected in webview, id: $it")
151-
audioManager.selectAudioDevice(it)
191+
val webViewAudioDeviceSelectedCallback = WebViewAudioOutputCallback { selectedDeviceId ->
192+
Timber.d("Audio device selected in webview, id: $selectedDeviceId")
193+
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
194+
audioManager.selectAudioDevice(selectedDeviceId)
152195
}
153196
Timber.d("Setting onAudioDeviceSelectedCallback javascript interface in webview")
154197
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "onAudioDeviceSelectedCallback")
155198
}
156199

157-
@Suppress("DEPRECATION")
158-
private fun selectDefaultAudioDevice() {
159-
val selectedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
160-
val devices = audioManager.availableCommunicationDevices
161-
devices.minByOrNull {
162-
wantedDeviceTypes.indexOf(it.type).let { index ->
163-
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
164-
if (index == -1) Int.MAX_VALUE else index
165-
}
200+
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
201+
val selectedDevice = availableDevices.minByOrNull {
202+
wantedDeviceTypes.indexOf(it.type).let { index ->
203+
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
204+
if (index == -1) Int.MAX_VALUE else index
166205
}
167-
} else {
168-
// If we don't have access to the new APIs, use the deprecated ones
169-
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL)
170-
devices.filter { it.isSink }
171-
.minByOrNull {
172-
wantedDeviceTypes.indexOf(it.type).let { index ->
173-
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
174-
if (index == -1) Int.MAX_VALUE else index
175-
}
176-
}
177206
}
178207

179208
expectedNewCommunicationDeviceId = selectedDevice?.id
@@ -189,32 +218,45 @@ class WebViewAudioManager(
189218
private fun selectAudioDeviceInWebView(deviceId: String) {
190219
MainScope().launch { webView.evaluateJavascript("controls.setOutputDevice('$deviceId');", null) }
191220
}
192-
}
193221

194-
private fun AudioManager.selectAudioDevice(device: String) {
195-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
196-
val audioDevice = availableCommunicationDevices.find { it.id.toString() == device }
197-
selectAudioDevice(audioDevice)
198-
} else {
199-
val rawAudioDevices = getDevices(AudioManager.GET_DEVICES_ALL)
200-
val audioDevice = rawAudioDevices.find { it.id.toString() == device }
201-
selectAudioDevice(audioDevice)
222+
private fun AudioManager.selectAudioDevice(device: String) {
223+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
224+
val audioDevice = availableCommunicationDevices.find { it.id.toString() == device }
225+
selectAudioDevice(audioDevice)
226+
} else {
227+
val rawAudioDevices = getDevices(AudioManager.GET_DEVICES_ALL)
228+
val audioDevice = rawAudioDevices.find { it.id.toString() == device }
229+
selectAudioDevice(audioDevice)
230+
}
202231
}
203-
}
204232

205-
private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) {
206-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
207-
if (device != null) {
208-
setCommunicationDevice(device)
233+
private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) {
234+
currentDeviceId = device?.id
235+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
236+
if (device != null) {
237+
if (device != communicationDevice) {
238+
setCommunicationDevice(device)
239+
}
240+
} else {
241+
audioManager.clearCommunicationDevice()
242+
}
209243
} else {
210-
Timber.w("Audio: unable to select audio device with id: ${device?.id}")
244+
if (device != null) {
245+
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
246+
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
247+
} else {
248+
isSpeakerphoneOn = false
249+
isBluetoothScoOn = false
250+
}
211251
}
212-
} else {
213-
if (device != null) {
214-
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
215-
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
216-
} else {
217-
Timber.w("Audio: unable to select audio device with id: ${device?.id}")
252+
253+
@Suppress("WakeLock", "WakeLockTimeout")
254+
if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
255+
// If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
256+
proximitySensorWakeLock?.acquire()
257+
} else if (proximitySensorWakeLock?.isHeld == true) {
258+
// If the device is no longer the earpiece, we need to release the wake lock
259+
proximitySensorWakeLock?.release()
218260
}
219261
}
220262
}
@@ -245,12 +287,7 @@ private fun deviceName(type: Int, name: String): String {
245287
return if (isBuiltIn(type)) {
246288
typePart
247289
} else {
248-
val namePart = if (name.length > 10) {
249-
name.substring(0, 10) + ""
250-
} else {
251-
name
252-
}
253-
"$namePart - $typePart"
290+
"$typePart - $name"
254291
}
255292
}
256293

@@ -262,15 +299,19 @@ private fun isBuiltIn(type: Int): Boolean = when (type) {
262299
else -> false
263300
}
264301

265-
266-
data class CompatAudioDevice(
302+
@Suppress("unused")
303+
@Serializable
304+
class SerializableAudioDevice(
267305
val id: String,
268306
val name: String,
269-
val type: Int,
307+
@Transient val type: Int = 0,
308+
val isEarpiece: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
309+
val isSpeaker: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
310+
val isExternalHeadset: Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
270311
) {
271312
companion object {
272-
fun fromAudioDeviceInfo(audioDeviceInfo: AudioDeviceInfo): CompatAudioDevice {
273-
return CompatAudioDevice(
313+
fun fromAudioDeviceInfo(audioDeviceInfo: AudioDeviceInfo): SerializableAudioDevice {
314+
return SerializableAudioDevice(
274315
id = audioDeviceInfo.id.toString(),
275316
name = deviceName(type = audioDeviceInfo.type, name = audioDeviceInfo.productName.toString()),
276317
type = audioDeviceInfo.type,

0 commit comments

Comments
 (0)