Skip to content

Commit 766d1e0

Browse files
committed
Add PoC for native audio output selector
1 parent 019327e commit 766d1e0

File tree

1 file changed

+149
-18
lines changed
  • features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui

1 file changed

+149
-18
lines changed

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

Lines changed: 149 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import android.content.Context
1212
import android.media.AudioDeviceCallback
1313
import android.media.AudioDeviceInfo
1414
import android.media.AudioManager
15+
import android.media.AudioManager.OnCommunicationDeviceChangedListener
16+
import android.os.Build
1517
import android.util.Log
1618
import android.view.ViewGroup
1719
import android.webkit.ConsoleMessage
@@ -20,20 +22,26 @@ import android.webkit.WebChromeClient
2022
import android.webkit.WebView
2123
import androidx.activity.compose.BackHandler
2224
import androidx.compose.foundation.layout.Box
25+
import androidx.compose.foundation.layout.Column
2326
import androidx.compose.foundation.layout.consumeWindowInsets
2427
import androidx.compose.foundation.layout.fillMaxSize
28+
import androidx.compose.foundation.layout.fillMaxWidth
2529
import androidx.compose.foundation.layout.padding
2630
import androidx.compose.material3.ExperimentalMaterial3Api
2731
import androidx.compose.runtime.Composable
32+
import androidx.compose.runtime.DisposableEffect
2833
import androidx.compose.runtime.getValue
34+
import androidx.compose.runtime.mutableStateListOf
2935
import androidx.compose.runtime.mutableStateOf
3036
import androidx.compose.runtime.remember
3137
import androidx.compose.runtime.setValue
3238
import androidx.compose.ui.Alignment
3339
import androidx.compose.ui.Modifier
40+
import androidx.compose.ui.platform.LocalContext
3441
import androidx.compose.ui.platform.LocalInspectionMode
3542
import androidx.compose.ui.res.stringResource
3643
import androidx.compose.ui.tooling.preview.PreviewParameter
44+
import androidx.compose.ui.unit.dp
3745
import androidx.compose.ui.viewinterop.AndroidView
3846
import androidx.core.content.getSystemService
3947
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -52,11 +60,15 @@ import io.element.android.libraries.designsystem.components.button.BackButton
5260
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
5361
import io.element.android.libraries.designsystem.preview.ElementPreview
5462
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
63+
import io.element.android.libraries.designsystem.theme.components.Button
64+
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
65+
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
5566
import io.element.android.libraries.designsystem.theme.components.Scaffold
5667
import io.element.android.libraries.designsystem.theme.components.Text
5768
import io.element.android.libraries.designsystem.theme.components.TopAppBar
5869
import io.element.android.libraries.ui.strings.CommonStrings
5970
import timber.log.Timber
71+
import java.util.concurrent.Executors
6072

6173
typealias RequestPermissionCallback = (Array<String>) -> Unit
6274

@@ -158,28 +170,147 @@ private fun CallWebView(
158170
Text("WebView - can't be previewed")
159171
}
160172
} else {
161-
var audioDeviceCallback: AudioDeviceCallback? by remember { mutableStateOf(null) }
162-
AndroidView(
163-
modifier = modifier,
164-
factory = { context ->
165-
audioDeviceCallback = context.setupAudioConfiguration()
166-
WebView(context).apply {
167-
onWebViewCreate(this)
168-
setup(userAgent, onPermissionsRequest)
169-
}
170-
},
171-
update = { webView ->
172-
if (url is AsyncData.Success && webView.url != url.data) {
173-
webView.loadUrl(url.data)
173+
Column(modifier = modifier) {
174+
var audioDeviceCallback: AudioDeviceCallback? by remember { mutableStateOf(null) }
175+
176+
OutputAudioDeviceSelector()
177+
178+
AndroidView(
179+
modifier = Modifier.fillMaxWidth(),
180+
factory = { context ->
181+
audioDeviceCallback = context.setupAudioConfiguration()
182+
WebView(context).apply {
183+
onWebViewCreate(this)
184+
setup(userAgent, onPermissionsRequest)
185+
}
186+
},
187+
update = { webView ->
188+
if (url is AsyncData.Success && webView.url != url.data) {
189+
webView.loadUrl(url.data)
190+
}
191+
},
192+
onRelease = { webView ->
193+
// Reset audio mode
194+
webView.context.releaseAudioConfiguration(audioDeviceCallback)
195+
webView.destroy()
174196
}
175-
},
176-
onRelease = { webView ->
177-
// Reset audio mode
178-
webView.context.releaseAudioConfiguration(audioDeviceCallback)
179-
webView.destroy()
197+
)
198+
}
199+
}
200+
}
201+
202+
@Composable
203+
private fun OutputAudioDeviceSelector() {
204+
val context = LocalContext.current
205+
val audioManager = remember { context.getSystemService<AudioManager>() }
206+
207+
val audioDevices = remember { mutableStateListOf<AudioDeviceInfo>() }
208+
var expanded by remember { mutableStateOf(false) }
209+
var selected by remember(audioDevices) {
210+
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
211+
audioManager?.communicationDevice
212+
} else {
213+
null
214+
}
215+
mutableStateOf(device)
216+
}
217+
218+
DisposableEffect(Unit) {
219+
audioDevices.addAll(audioManager?.loadOutputAudioDevices().orEmpty())
220+
221+
val onCommunicationDeviceChangedListener = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
222+
OnCommunicationDeviceChangedListener { selected = audioManager?.communicationDevice }
223+
.also { audioManager?.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), it) }
224+
} else {
225+
null
226+
}
227+
228+
val audioDeviceCallback = object : AudioDeviceCallback() {
229+
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
230+
audioDevices.clear()
231+
audioDevices.addAll(audioManager?.loadOutputAudioDevices().orEmpty())
232+
}
233+
234+
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
235+
audioDevices.clear()
236+
audioDevices.addAll(audioManager?.loadOutputAudioDevices().orEmpty())
237+
}
238+
}
239+
audioManager?.registerAudioDeviceCallback(audioDeviceCallback, null)
240+
241+
onDispose {
242+
audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback)
243+
244+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
245+
onCommunicationDeviceChangedListener?.let { audioManager?.removeOnCommunicationDeviceChangedListener(it) }
246+
}
247+
}
248+
}
249+
250+
Box(Modifier.padding(horizontal = 16.dp)) {
251+
Button(
252+
modifier = Modifier.fillMaxWidth(),
253+
text = "Audio output: ${selected?.description() ?: "-"}",
254+
onClick = {
255+
expanded = !expanded
180256
}
181257
)
258+
259+
DropdownMenu(expanded, onDismissRequest = { expanded = false }) {
260+
for (device in audioDevices) {
261+
DropdownMenuItem(text = {
262+
Text(device.description())
263+
}, onClick = {
264+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
265+
audioManager?.setCommunicationDevice(device)
266+
selected = device
267+
expanded = false
268+
}
269+
})
270+
}
271+
}
272+
}
273+
}
274+
275+
private fun AudioManager.loadOutputAudioDevices(): List<AudioDeviceInfo> {
276+
val wantedDeviceTypes = listOf(
277+
// Paired bluetooth device with microphone
278+
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
279+
// USB devices which can play or record audio
280+
AudioDeviceInfo.TYPE_USB_HEADSET,
281+
AudioDeviceInfo.TYPE_USB_DEVICE,
282+
AudioDeviceInfo.TYPE_USB_ACCESSORY,
283+
// Wired audio devices
284+
AudioDeviceInfo.TYPE_WIRED_HEADSET,
285+
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
286+
// The built-in speaker of the device
287+
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
288+
// The built-in earpiece of the device
289+
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
290+
)
291+
val devices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
292+
availableCommunicationDevices
293+
} else {
294+
getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
295+
}
296+
return devices.filter { device ->
297+
wantedDeviceTypes.contains(device.type)
298+
}
299+
}
300+
301+
fun AudioDeviceInfo.description(): String {
302+
val type = when (type) {
303+
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth SCO"
304+
AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB accessory"
305+
AudioDeviceInfo.TYPE_USB_DEVICE -> "USB device"
306+
AudioDeviceInfo.TYPE_USB_HEADSET -> "USB headset"
307+
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired headset"
308+
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired headphones"
309+
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in speaker"
310+
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Built-in earpiece"
311+
else -> "Unknown device type: $type"
182312
}
313+
return "$productName - $type"
183314
}
184315

185316
private fun Context.setupAudioConfiguration(): AudioDeviceCallback? {

0 commit comments

Comments
 (0)