diff --git a/samples/README.md b/samples/README.md index 33b287ef..49bab345 100644 --- a/samples/README.md +++ b/samples/README.md @@ -2,14 +2,14 @@ - [App Widgets](user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/AppWidgets.kt): Showcases how to pin widget within the app. Check the launcher widget menu for all the app widgets samples -- [Audio Manager](connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt): -This sample will show you how get all audio sources and set an audio device. Covers Bluetooth, LEA, Wired and internal speakers - [Call Notification Sample](connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/CallNotificationSample.kt): Sample demonstrating how to make incoming call notifications and in call notifications - [Camera Preview](camera/camera2/src/main/java/com/example/platform/camera/preview/Camera2Preview.kt): Demonstrates displaying processed pixel data directly from the camera sensor - [Color Contrast](accessibility/src/main/java/com/example/platform/accessibility/ColorContrast.kt): This sample demonstrates the importance of proper color contrast and how to +- [Communication Audio Manager Sample](connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioCommsSample.kt): +This sample shows how to use audio manager to for Communication application that self-manage the call. - [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): diff --git a/samples/connectivity/audio/README.md b/samples/connectivity/audio/README.md index 716674a4..e2e2dc71 100644 --- a/samples/connectivity/audio/README.md +++ b/samples/connectivity/audio/README.md @@ -1,8 +1,12 @@ -# Audio samples +# Connectivity Audio samples -This module includes Audio Samples around Android Platform +This module includes Audio Samples related to connectivity: -- [Audio Manager](src/main/java/com/example/platform/connectivity/audio/AudioSample.kt) +- [Communication Audio Manager Sample](src/main/java/com/example/platform/connectivity/audio/AudioCommsSample.kt): +Shows how to use the audio manager to manage the communication device. For example during a VoIP call. +- [AudioLoopSource](src/main/java/com/example/platform/connectivity/audio/AudioLoopSource.kt): +Simple utility class that showcases how to use AudioTrack and AudioRecord to loop audio from the +input source to the output source. For more check out the documentation at https://developer.android.com/guide/topics/media @@ -24,4 +28,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` - diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioCommsSample.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioCommsSample.kt new file mode 100644 index 00000000..c6048dff --- /dev/null +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioCommsSample.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.audio + +import android.Manifest +import android.media.AudioDeviceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.example.platform.base.PermissionBox +import com.google.android.catalog.framework.annotations.Sample +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +@Sample( + name = "Communication Audio Manager Sample", + description = "This sample shows how to use audio manager to for Communication application that self-manage the call.", + documentation = "https://developer.android.com/guide/topics/media-apps/media-apps-overview", + tags = ["audio"], +) +@RequiresApi(Build.VERSION_CODES.S) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) +@Composable +fun AudioCommsSample() { + // The record permission is only needed for looping the audio not for the AudioManager + PermissionBox(permission = Manifest.permission.RECORD_AUDIO) { + AudioCommsScreen() + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) +@RequiresApi(Build.VERSION_CODES.S) +@Composable +private fun AudioCommsScreen() { + val scope = rememberCoroutineScope() + + // Get the current state of the communication audio devices. Check [AudioDeviceState]. + val state = rememberAudioDeviceState() + val availableDevices = remember(state.availableDevices, state.activeDevice) { + // Remove the active device from our list + state.availableDevices.filter { it.id != state.activeDevice?.id } + } + var audioIssue by remember { + mutableStateOf("") + } + + // Only for testing purposes: open an audio loop that takes the active audio device, records the + // audio and loops it back with a small delay. + LaunchedEffect(Unit) { + try { + AudioLoopSource.openAudioLoop() + } catch (e: Exception) { + audioIssue = e.message ?: "Unknown error with audio loop" + } + } + + LazyColumn( + Modifier + .fillMaxSize() + .padding(vertical = 16.dp), + ) { + stickyHeader { + ActiveDeviceItem(state.activeDevice) { + scope.launch { + // On click switch back to default device + state.clearSelectedDevice() + } + } + } + + if (audioIssue.isNotBlank()) { + item { + Text( + text = audioIssue, + modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer), + style = TextStyle(color = MaterialTheme.colorScheme.error), + ) + } + } + + items(items = availableDevices, key = { it.id }) { + var isLoading by remember { + mutableStateOf(false) + } + AudioDeviceItem(deviceInfo = it, isLoading = isLoading) { + isLoading = true + scope.launch(Dispatchers.IO) { + // On item selected, switch and wait to the new device + audioIssue = if (state.selectDevice(it)) { + "" + } else { + "Error while selecting device" + } + isLoading = false + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.P) +@Composable +@OptIn(ExperimentalAnimationApi::class) +private fun ActiveDeviceItem(device: AudioDeviceInfo?, onClick: () -> Unit) { + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer), + ) { + Text(text = "Active device:", modifier = Modifier.padding(8.dp)) + AnimatedContent(targetState = device, label = "Active device") { + if (it == null) { + Text(text = "None") + } else { + AudioDeviceItem(it) { + onClick() + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.P) +@Composable +private fun AudioDeviceItem( + deviceInfo: AudioDeviceInfo, + isLoading: Boolean = false, + onItemSelected: (AudioDeviceInfo) -> Unit, +) { + ListItem( + modifier = Modifier.clickable { onItemSelected(deviceInfo) }, + headlineContent = { + Text(deviceInfo.productName.toString()) + }, + leadingContent = { + Text(deviceInfo.id.toString(), style = MaterialTheme.typography.headlineMedium) + }, + trailingContent = { + if (isLoading) { + CircularProgressIndicator() + } + }, + supportingContent = { + Text( + when (deviceInfo.type) { + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker" + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Phone" + AudioDeviceInfo.TYPE_BLE_HEADSET -> "BLE Headset" + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BT SCO" + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BT A2DP" + AudioDeviceInfo.TYPE_HEARING_AID -> "Hearing aid" + else -> "Type: ${deviceInfo.type}" + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioDeviceState.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioDeviceState.kt new file mode 100644 index 00000000..5e9a64bb --- /dev/null +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioDeviceState.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.audio + +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.core.content.getSystemService +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume + +/** + * Remember the current AudioDeviceState that observes changes in the active and available audio + * devices for communication and allows to switch between while the composable is active and the + * activity is visible. + * + * @see AudioDeviceState + */ +@RequiresApi(Build.VERSION_CODES.S) +@Composable +internal fun rememberAudioDeviceState(): AudioDeviceState { + val context = LocalContext.current + val audioManager = context.getSystemService()!! + val state = remember { + AudioDeviceState(audioManager) + } + + // Observe lifecycles and composable events and register or unregister audio observers + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + + // Called when a new device is available or when it becomes unavailable + // We only care about comms devices, thus we just update our list + val deviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + super.onAudioDevicesAdded(addedDevices) + state.availableDevices = audioManager.availableCommunicationDevices + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + super.onAudioDevicesRemoved(removedDevices) + state.availableDevices = audioManager.availableCommunicationDevices + } + } + + // Called when the active communication device changes + val changedListener = AudioManager.OnCommunicationDeviceChangedListener { + state.activeDevice = it + } + + // Removing the comms listeners throws an exception if it was not previously added + // Keep track of the listening state to avoid it. + var isListening = false + + // Clear all observers and the selected device + fun clearObservers() { + audioManager.unregisterAudioDeviceCallback(deviceCallback) + if (isListening) { + audioManager.removeOnCommunicationDeviceChangedListener(changedListener) + isListening = false + } + state.clearSelectedDevice() + } + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + audioManager.registerAudioDeviceCallback( + deviceCallback, + Handler(Looper.myLooper()!!), + ) + audioManager.addOnCommunicationDeviceChangedListener({ it.run() }, changedListener) + isListening = true + } else if (event == Lifecycle.Event.ON_STOP) { + clearObservers() + } + } + lifecycle.addObserver(observer) + + onDispose { + lifecycle.removeObserver(observer) + clearObservers() + } + } + + return state +} + +/** + * Keeps the state of the active and available communication devices and allows to change + * the active device. + * + * @see rememberAudioDeviceState + */ +@RequiresApi(Build.VERSION_CODES.S) +internal class AudioDeviceState(private val audioManager: AudioManager) { + var activeDevice by mutableStateOf(audioManager.communicationDevice) + + var availableDevices by mutableStateOf>( + audioManager.availableCommunicationDevices, + ) + + /** + * Select the given device and waits for it to be active. If an error occurs or the device + * does not become active in 30s or less it return false. + */ + suspend fun selectDevice(deviceInfo: AudioDeviceInfo): Boolean = + // For certain Bluetooth devices it can take up to 30s to activate them + withTimeout(TimeUnit.SECONDS.toMillis(30)) { + // Suspend the coroutine and wait till the device becomes active or it's cancelled + suspendCancellableCoroutine { continuation -> + // Listen for the device becoming active and return true + val listener = object : AudioManager.OnCommunicationDeviceChangedListener { + override fun onCommunicationDeviceChanged(it: AudioDeviceInfo?) { + if (it?.id == deviceInfo.id) { + audioManager.removeOnCommunicationDeviceChangedListener(this) + continuation.resume(true) + } + } + } + audioManager.addOnCommunicationDeviceChangedListener( + /* executor = */ { it.run() }, + /* listener = */ listener, + ) + if (!audioManager.setCommunicationDevice(deviceInfo)) { + audioManager.removeOnCommunicationDeviceChangedListener(listener) + continuation.resume(false) + } + continuation.invokeOnCancellation { + // if the coroutine is cancelled stop listening for changes + // this won't cancel the previous setter, the device might become active later + audioManager.removeOnCommunicationDeviceChangedListener(listener) + } + } + } + + /** + * Clear the selected device. This will force the system to fallback to the default device. + * + * You should call this when the app does no longer uses the audio. + */ + fun clearSelectedDevice() { + audioManager.clearCommunicationDevice() + } +} diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioLoopSource.kt similarity index 50% rename from samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt rename to samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioLoopSource.kt index 7eb548bf..594daac8 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioLoopSource.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package com.example.platform.connectivity.audio.datasource +package com.example.platform.connectivity.audio -import android.annotation.SuppressLint +import android.Manifest import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioFormat @@ -25,52 +25,42 @@ import android.media.AudioRecord import android.media.AudioTrack import android.media.MediaRecorder import android.os.Build -import kotlinx.coroutines.CoroutineScope +import androidx.annotation.RequiresPermission import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Audio Looping, uses AudioRecord and Audio track to loop audio from microphone back to output device * Used for testing microphones and speakers */ -class AudioLoopSource { +object AudioLoopSource { - //Scope used for getting buffer from Audio recorder to audio track - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private var job: Job? = null - val isRecording = MutableStateFlow(false) + private const val SAMPLE_RATE = 48000 - companion object { - private const val sampleRate = 48000 - - private val bufferSize = AudioRecord.getMinBufferSize( - sampleRate, + /** + * Opens the active mic and loops it back to the selected active audio device. + * When the scope is closed the mic and audio will be closed + * + * @throws IllegalStateException if AudioRecord couldn't be initialized + */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + suspend fun openAudioLoop(preferredDevice: Flow = emptyFlow()) { + // Init the recorder and audio + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - private val audioTrackBufferSize = AudioTrack.getMinBufferSize( - sampleRate, + val audioTrackBufferSize = AudioTrack.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - - - @SuppressLint("MissingPermission") - private val audioSampler = AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - sampleRate, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - bufferSize, - ) - - //Audio track for audio playback - private val audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() @@ -81,79 +71,65 @@ class AudioLoopSource { .setAudioFormat( AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .setSampleRate(sampleRate) + .setSampleRate(SAMPLE_RATE) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build(), ) .setBufferSizeInBytes(audioTrackBufferSize) .build() } else { + @Suppress("DEPRECATION") AudioTrack( AudioManager.STREAM_VOICE_CALL, - sampleRate, + SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, audioTrackBufferSize, AudioTrack.MODE_STREAM, ) } + val audioSampler = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize, + ) + audioTrack.playbackRate = SAMPLE_RATE + + try { + // Launch in a new context the loop + withContext(Dispatchers.IO) { + // Collect changes of preferred device to loop the audio + launch { + preferredDevice.collect { device -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + audioTrack.preferredDevice = device + audioSampler.preferredDevice = device + } else { + //Not required AudioManger will deal with routing in the PlatformAudioSource class + } + } + } - } - - /** - * Gets buffer from Audio Recorder and loops back to the audio track - */ - fun startAudioLoop(): Boolean { - if (audioSampler.recordingState == AudioRecord.RECORDSTATE_RECORDING) { - return false - } - - audioTrack.playbackRate = sampleRate - - job = coroutineScope.launch { - if (audioSampler.state == AudioRecord.STATE_INITIALIZED) { + check(audioSampler.state == AudioRecord.STATE_INITIALIZED) { + "Audio recorder was not properly initialized" + } audioSampler.startRecording() - } - val data = ByteArray(bufferSize) - audioTrack.play() + val audioData = ByteArray(bufferSize) + audioTrack.play() - isRecording.update { true } - - while (isActive) { - - val bytesRead = audioSampler.read(data, 0, bufferSize) - - if (bytesRead > 0) { - audioTrack.write(data, 0, bytesRead) + while (isActive) { + val bytesRead = audioSampler.read(audioData, 0, bufferSize) + if (bytesRead > 0) { + audioTrack.write(audioData, 0, bytesRead) + } } } - } - - return true - } - - /** - * Stops current job and releases microphone and audio devices - */ - fun stopAudioLoop() { - job?.cancel("Stop Recording", null) - isRecording.update { false } - if (audioSampler.state == AudioRecord.STATE_INITIALIZED) { + } finally { + audioTrack.stop() audioSampler.stop() } - audioTrack.stop() - } - - /** - * Set the audio device to record and playback with - */ - fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - audioTrack.preferredDevice = audioDeviceInfo - audioSampler.preferredDevice = audioDeviceInfo - } else { - //Not required AudioManger will deal with routing in the PlatformAudioSource class - } } } diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt deleted file mode 100644 index 9aae69d9..00000000 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.audio - -import android.Manifest -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.os.Build -import android.widget.Toast -import androidx.annotation.RequiresApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.content.getSystemService -import com.example.platform.base.PermissionBox -import com.example.platform.connectivity.audio.datasource.PlatformAudioSource -import com.example.platform.connectivity.audio.viewmodel.AudioDeviceUI -import com.example.platform.connectivity.audio.viewmodel.AudioDeviceViewModel -import com.example.platform.connectivity.audio.viewmodel.getDeviceName -import com.example.platform.connectivity.audio.viewmodel.getStatusColor -import com.google.android.catalog.framework.annotations.Sample - -@Sample( - name = "Audio Manager", - description = "This sample will show you how get all audio sources and set an audio device. Covers Bluetooth, LEA, Wired and internal speakers", - documentation = "https://developer.android.com/guide/topics/media-apps/media-apps-overview", -) -@RequiresApi(Build.VERSION_CODES.S) -@Composable -fun AudioSample() { - val context = LocalContext.current - val audioManager = context.getSystemService()!! - val viewModel = AudioDeviceViewModel(PlatformAudioSource(audioManager)) - PermissionBox(permission = Manifest.permission.RECORD_AUDIO) { - AudioSampleScreen(viewModel) - } -} - -@RequiresApi(Build.VERSION_CODES.S) -@Composable -private fun AudioSampleScreen(viewModel: AudioDeviceViewModel) { - val context = LocalContext.current - val uiStateAvailableDevices by viewModel.availableDeviceUiState.collectAsState() - val uiStateActiveDevice by viewModel.activeDeviceUiState.collectAsState() - val uiStateErrorMessage by viewModel.errorUiState.collectAsState() - val uiStateRecording by viewModel.isRecording.collectAsState() - - if (uiStateErrorMessage != null) { - LaunchedEffect(uiStateErrorMessage) { - Toast.makeText(context, uiStateErrorMessage, Toast.LENGTH_LONG).show() - viewModel.onErrorMessageShown() - } - } - - Column( - Modifier - .fillMaxSize() - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - ActiveAudioSource(uiStateActiveDevice) - Button( - onClick = { viewModel.onToggleAudioRecording() }, - ) { - val recordingText = if (uiStateRecording) { - "Stop" - } else { - "Record" - } - Text(text = recordingText) - } - } - Text( - text = stringResource(id = R.string.selectdevice), - modifier = Modifier.padding(8.dp, 12.dp), - style = MaterialTheme.typography.titleLarge, - ) - AvailableDevicesList(uiStateAvailableDevices, viewModel::setAudioDevice) - } -} - - -@Composable -private fun AvailableDevicesList( - audioDeviceWidgetUiState: AudioDeviceViewModel.AudioDeviceListUiState, - onDeviceSelected: (AudioDeviceInfo) -> Unit, -) { - when (audioDeviceWidgetUiState) { - AudioDeviceViewModel.AudioDeviceListUiState.Loading -> { - CircularProgressIndicator() - } - - is AudioDeviceViewModel.AudioDeviceListUiState.Success -> { - ListOfAudioDevices(audioDeviceWidgetUiState.audioDevices, onDeviceSelected) - } - } -} - -@Composable -private fun ActiveAudioSource(activeAudioDeviceUiState: AudioDeviceViewModel.ActiveAudioDeviceUiState) = - when (activeAudioDeviceUiState) { - AudioDeviceViewModel.ActiveAudioDeviceUiState.NotActive -> { - ActiveAudioSource( - title = stringResource(id = R.string.nodevice), - subTitle = "", - resId = R.drawable.phone_icon, - ) - } - - is AudioDeviceViewModel.ActiveAudioDeviceUiState.OnActiveDevice -> { - ActiveAudioSource( - title = stringResource(id = R.string.connected), - subTitle = activeAudioDeviceUiState.audioDevice.getDeviceName(), - resId = activeAudioDeviceUiState.audioDevice.resIconId, - ) - } - } - -/** - * Shows user the active audio source - */ -@Composable -private fun ActiveAudioSource(title: String, subTitle: String, resId: Int) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 12.dp), - ) { - Icon( - painterResource(resId), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Column { - Text( - title, modifier = Modifier.padding(8.dp, 0.dp), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleSmall, - ) - Text( - subTitle, modifier = Modifier.padding(8.dp, 0.dp), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineSmall, - ) - } - } -} - -/** - * Build an list of Audio Devices we can connect to - */ -@Composable -private fun ListOfAudioDevices( - devices: List, - onDeviceSelected: (AudioDeviceInfo) -> Unit, -) { - if (devices.isEmpty()) { - Text(text = "No devices found", modifier = Modifier.padding(8.dp)) - } else { - LazyColumn { - items(devices) { item -> - AudioItem(audioDevice = item, onDeviceSelected = onDeviceSelected) - } - } - } -} - -/** - * Displays the audio device with Icon and Text - */ -@Composable -private fun AudioItem( - audioDevice: AudioDeviceUI, - onDeviceSelected: (AudioDeviceInfo) -> Unit, -) { - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { onDeviceSelected(audioDevice.audioDeviceInfo) }, - ) { - Row( - modifier = Modifier.padding(12.dp, 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painterResource(audioDevice.resIconId), - contentDescription = null, - tint = Color.White, - ) - Text( - text = audioDevice.getDeviceName(), - color = audioDevice.getStatusColor(), - ) - } - } -} diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/PlatformAudioSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/PlatformAudioSource.kt deleted file mode 100644 index 9958c353..00000000 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/PlatformAudioSource.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.audio.datasource - -import android.media.AudioDeviceCallback -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.os.Build -import androidx.annotation.RequiresApi -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.launch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit - -/** - * Manages Audio Device states and keeps a list of list we can connect to. - * Keeps track of active audio device - * Can switch to any audio device in the audio platform include bluetooth and LEA devices - * Keeps the states of what we are trying connect too - */ -@RequiresApi(Build.VERSION_CODES.S) -class PlatformAudioSource( - private val audioManager: AudioManager, -) { - - //Flow based on the active audio stream - val getActivePlatformAudioSourceStream: Flow = callbackFlow { - - val listener = - AudioManager.OnCommunicationDeviceChangedListener { - onConnectingDeviceStateChangedListener.onStateChanged(DeviceState.Connected) - trySend(it) - } - - audioManager.addOnCommunicationDeviceChangedListener( - Executors.newSingleThreadExecutor(), - listener - ) - - trySend(audioManager.communicationDevice) - - awaitClose { - audioManager.removeOnCommunicationDeviceChangedListener(listener) - - //Audio manager needs clearing in order to clean any bluetooth connections it may have. - audioManager.clearCommunicationDevice() - } - } - - - //This flow keeps a list of audio devices available in the platform - val getAudioDevicesStream: Flow> = callbackFlow { - - trySend(audioManager.availableCommunicationDevices) - - val audioDeviceCallback: AudioDeviceCallback = object : AudioDeviceCallback() { - override fun onAudioDevicesAdded(addedDevices: Array) { - trySend(audioManager.availableCommunicationDevices) - } - - override fun onAudioDevicesRemoved(removedDevices: Array) { - trySend(audioManager.availableCommunicationDevices) - } - } - audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) - - val onStateChangeListener = object : OnConnectingDeviceStateChangedListener { - override fun onStateChanged(state: DeviceState) { - - if (state == DeviceState.Connected || state == DeviceState.Failed) { - pendingDeviceId = Int.MIN_VALUE - waitForDeviceCallback?.cancel() - setAudioSourceResponse.complete(state == DeviceState.Connected) - } - - trySend(audioManager.availableCommunicationDevices) - } - } - - setOnConnectingDeviceStateChangedListener(onStateChangeListener) - - awaitClose { - audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) - } - } - - private var waitForDeviceCallback: Job? = null - private var setAudioSourceResponse: CompletableDeferred = CompletableDeferred() - private lateinit var onConnectingDeviceStateChangedListener: OnConnectingDeviceStateChangedListener - var pendingDeviceId: Int = Int.MIN_VALUE - - /** - * Switches platform audio source - * Bluetooth devices can take upto 30 seconds to connect - */ - suspend fun setAudioSource(audioDeviceInfo: AudioDeviceInfo): Boolean { - setAudioSourceResponse = CompletableDeferred() - pendingDeviceId = audioDeviceInfo.id - - onConnectingDeviceStateChangedListener.onStateChanged(DeviceState.Pending) - - waitForDeviceCallback = CoroutineScope(Dispatchers.Main).launch { - - if (!audioManager.setCommunicationDevice(audioDeviceInfo)) { - onConnectingDeviceStateChangedListener.onStateChanged(DeviceState.Failed) - } - // wait a max of 30 seconds. Sometimes bluetooth devices can timeout - delay(TimeUnit.SECONDS.toMillis(10)) - onConnectingDeviceStateChangedListener.onStateChanged(DeviceState.Failed) - } - - return setAudioSourceResponse.await() - } - - enum class DeviceState { - Pending, - Failed, - Connected - } - - /** - * Updates listener with the state of the device we are trying to connect to - * Pending, Failed or Connected will be return - */ - private interface OnConnectingDeviceStateChangedListener { - fun onStateChanged(state: DeviceState) - } - - private fun setOnConnectingDeviceStateChangedListener(listener: OnConnectingDeviceStateChangedListener) { - onConnectingDeviceStateChangedListener = listener - } -} \ No newline at end of file diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceUI.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceUI.kt deleted file mode 100644 index c0c82d2c..00000000 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceUI.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.audio.viewmodel - -import android.media.AudioDeviceInfo -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import com.example.platform.connectivity.audio.R - -enum class AudioDeviceState { - Connected, - Connecting, - Available, -} - -/** - * Data class for showing AudioDevice in our UI. - */ -data class AudioDeviceUI( - val friendlyName: String, - val name: Int?, - val resIconId: Int, - var audioDeviceState: AudioDeviceState, - val audioDeviceInfo: AudioDeviceInfo -) - -/** - * Convert AudioDeviceInfo into a ViewModel so we can display it. - */ -@RequiresApi(Build.VERSION_CODES.M) -fun AudioDeviceInfo.toAudioDeviceUI(audioDeviceState: AudioDeviceState): AudioDeviceUI { - return AudioDeviceUI( - friendlyName = productName.toString(), - name = getDeviceResourceName(type), - resIconId = getDeviceIcon(type), - audioDeviceState = audioDeviceState, - audioDeviceInfo = this, - ) -} - -/** - * Convert type to a friendlier name - * Product name will return the devices name for internal audio such as Speakers and earpiece - * this is not user friendly - */ -fun getDeviceResourceName(type: Int): Int? { - return when (type) { - AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> R.string.phone - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> R.string.speaker - AudioDeviceInfo.TYPE_HEARING_AID -> R.string.hearingaid - else -> { - null - } - } -} - -/** - * Returns an Icon based on the device type - */ -fun getDeviceIcon(type: Int): Int { - return when (type) { - AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> R.drawable.phone_icon - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> R.drawable.speaker - AudioDeviceInfo.TYPE_BLE_HEADSET -> R.drawable.headphones - AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> R.drawable.headphones - else -> { - R.drawable.phone_icon - } - } -} - -@Composable -fun AudioDeviceUI.getDeviceName(): String { - return if (name == null) { - friendlyName - } else - stringResource(id = name) -} - -/** - * Get a color based on the connection status of the device - */ -@Composable -fun AudioDeviceUI.getStatusColor(): Color { - return when (audioDeviceState) { - AudioDeviceState.Connected -> MaterialTheme.colorScheme.primary - AudioDeviceState.Connecting -> MaterialTheme.colorScheme.error - AudioDeviceState.Available -> MaterialTheme.colorScheme.onSurface - } -} diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceViewModel.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceViewModel.kt deleted file mode 100644 index 88079978..00000000 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceViewModel.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.audio.viewmodel - -import android.media.AudioDeviceInfo -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.platform.connectivity.audio.datasource.AudioLoopSource -import com.example.platform.connectivity.audio.datasource.PlatformAudioSource -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * View model for Active device and list of devices we can connect to - */ -@RequiresApi(Build.VERSION_CODES.S) -class AudioDeviceViewModel(private val platformAudioSource: PlatformAudioSource) : ViewModel() { - - private val audioLoopSource = AudioLoopSource() - val isRecording: StateFlow = audioLoopSource.isRecording.asStateFlow() - - /** - * Get active audio device and pass to UI - */ - val activeDeviceUiState: StateFlow = - platformAudioSource.getActivePlatformAudioSourceStream.map { device -> - val deviceUI = device?.toAudioDeviceUI(AudioDeviceState.Connected) - if (deviceUI != null) { - ActiveAudioDeviceUiState.OnActiveDevice(deviceUI) - } else { - ActiveAudioDeviceUiState.NotActive - } - }.stateIn( - scope = viewModelScope, - initialValue = ActiveAudioDeviceUiState.NotActive, - //Keep flow subscribed for 5 seconds, helps with configuration changes - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000), - ) - - // Convert AudioDeviceInfo to a ViewModel, also removes the connected device so we list only - // the device we can connect to - private var availableDevices: Flow> = - combine( - platformAudioSource.getActivePlatformAudioSourceStream, - platformAudioSource.getAudioDevicesStream, - ) { activeDevice: AudioDeviceInfo?, devices: List -> - devices.map { audioDeviceInfo -> - if (audioDeviceInfo.id == platformAudioSource.pendingDeviceId) { - audioDeviceInfo.toAudioDeviceUI(AudioDeviceState.Connecting) - } else if (audioDeviceInfo.id == activeDevice?.id) { - audioDeviceInfo.toAudioDeviceUI(AudioDeviceState.Connected) - } else { - audioDeviceInfo.toAudioDeviceUI(AudioDeviceState.Available) - } - } - }.map { value -> - value.filterIndexed { _, audioDeviceUI -> - audioDeviceUI.audioDeviceState != AudioDeviceState.Connected - } - } - - val availableDeviceUiState: StateFlow = - availableDevices.map { device -> - AudioDeviceListUiState.Success(device) - }.stateIn( - scope = viewModelScope, - initialValue = AudioDeviceListUiState.Loading, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000), - ) - - private val _errorUiState = MutableStateFlow(null) - val errorUiState: StateFlow = _errorUiState.asStateFlow() - - /** - * Try and connect to audio device - */ - fun setAudioDevice(audioDeviceInfo: AudioDeviceInfo) { - viewModelScope.launch { - val success: Boolean = platformAudioSource.setAudioSource(audioDeviceInfo) - - if (!success) { - _errorUiState.update { "Error Connecting to Device" } - } else { - audioLoopSource.setPreferredDevice(audioDeviceInfo) - } - } - } - - fun onErrorMessageShown() { - _errorUiState.update { null } - } - - fun onToggleAudioRecording() { - if (!audioLoopSource.isRecording.value) { - if (!audioLoopSource.startAudioLoop()) { - _errorUiState.update { "Error Starting Recording" } - } - } else { - audioLoopSource.stopAudioLoop() - } - } - - override fun onCleared() { - super.onCleared() - audioLoopSource.stopAudioLoop() - } - - sealed interface AudioDeviceListUiState { - object Loading : AudioDeviceListUiState - data class Success(val audioDevices: List) : AudioDeviceListUiState - } - - sealed interface ActiveAudioDeviceUiState { - object NotActive : ActiveAudioDeviceUiState - data class OnActiveDevice(val audioDevice: AudioDeviceUI) : ActiveAudioDeviceUiState - } - -} diff --git a/samples/connectivity/audio/src/main/res/drawable/headphones.xml b/samples/connectivity/audio/src/main/res/drawable/headphones.xml deleted file mode 100644 index f4eef0cc..00000000 --- a/samples/connectivity/audio/src/main/res/drawable/headphones.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - diff --git a/samples/connectivity/audio/src/main/res/drawable/phone_icon.xml b/samples/connectivity/audio/src/main/res/drawable/phone_icon.xml deleted file mode 100644 index f159d13a..00000000 --- a/samples/connectivity/audio/src/main/res/drawable/phone_icon.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - diff --git a/samples/connectivity/audio/src/main/res/drawable/speaker.xml b/samples/connectivity/audio/src/main/res/drawable/speaker.xml deleted file mode 100644 index f86272dc..00000000 --- a/samples/connectivity/audio/src/main/res/drawable/speaker.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/samples/connectivity/audio/src/main/res/values/strings.xml b/samples/connectivity/audio/src/main/res/values/strings.xml deleted file mode 100644 index eeb423e0..00000000 --- a/samples/connectivity/audio/src/main/res/values/strings.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - Phone - Speaker - HearingAid - Select a Device - No Device Active - Connected - \ No newline at end of file