blob: 67fde15319a63eec8660f31cd59ea1ac91c0550a [file] [log] [blame]
/*
* Copyright (C) 2022 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
*
* http://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.android.blueberry
import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.*
import android.util.Log
import blueberry.A2DPGrpc.A2DPImplBase
import blueberry.A2dpProto.*
import io.grpc.Status
import io.grpc.stub.StreamObserver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
@kotlinx.coroutines.ExperimentalCoroutinesApi
class A2dp(val context: Context) : A2DPImplBase() {
private val TAG = "BlueberryA2dp"
private val scope: CoroutineScope
private val flow: Flow<Intent>
private val audioManager: AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val bluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter = bluetoothManager.adapter
private val bluetoothA2dp = getProfileProxy<BluetoothA2dp>(context, BluetoothProfile.A2DP)
private val audioTrack: AudioTrack =
AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(44100)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build()
)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(44100 * 2 * 2)
.build()
init {
scope = CoroutineScope(Dispatchers.Default)
val intentFilter = IntentFilter()
intentFilter.addAction(BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED)
intentFilter.addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
flow = intentFlow(context, intentFilter).shareIn(scope, SharingStarted.Eagerly)
}
fun deinit() {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, bluetoothA2dp)
scope.cancel()
}
override fun openSource(
request: OpenSourceRequest,
responseObserver: StreamObserver<OpenSourceResponse>
) {
grpcUnary<OpenSourceResponse>(scope, responseObserver) {
val address = request.connection.cookie.toByteArray().decodeToString()
val device = bluetoothAdapter.getRemoteDevice(address)
Log.i(TAG, "openSource: address=$address")
if (device.getBondState() != BluetoothDevice.BOND_BONDED) {
Log.e(TAG, "Device is not bonded, cannot openSource")
throw Status.UNKNOWN.asException()
}
if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
bluetoothA2dp.connect(device)
val state =
flow
.filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED }
.map { it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR) }
.filter {
it == BluetoothProfile.STATE_CONNECTED || it == BluetoothProfile.STATE_DISCONNECTED
}
.first()
if (state == BluetoothProfile.STATE_DISCONNECTED) {
Log.e(TAG, "openSource failed, A2DP has been disconnected")
throw Status.UNKNOWN.asException()
}
}
val source = Source.newBuilder().setCookie(request.connection.cookie).build()
OpenSourceResponse.newBuilder().setSource(source).build()
}
}
override fun waitSource(
request: WaitSourceRequest,
responseObserver: StreamObserver<WaitSourceResponse>
) {
grpcUnary<WaitSourceResponse>(scope, responseObserver) {
val address = request.connection.cookie.toByteArray().decodeToString()
val device = bluetoothAdapter.getRemoteDevice(address)
Log.i(TAG, "waitSource: address=$address")
if (device.getBondState() != BluetoothDevice.BOND_BONDED) {
Log.e(TAG, "Device is not bonded, cannot openSource")
throw Status.UNKNOWN.asException()
}
if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
val state =
flow
.filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED }
.map { it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR) }
.filter {
it == BluetoothProfile.STATE_CONNECTED || it == BluetoothProfile.STATE_DISCONNECTED
}
.first()
if (state == BluetoothProfile.STATE_DISCONNECTED) {
Log.e(TAG, "waitSource failed, A2DP has been disconnected")
throw Status.UNKNOWN.asException()
}
}
val source = Source.newBuilder().setCookie(request.connection.cookie).build()
WaitSourceResponse.newBuilder().setSource(source).build()
}
}
override fun start(request: StartRequest, responseObserver: StreamObserver<StartResponse>) {
grpcUnary<StartResponse>(scope, responseObserver) {
val address = request.source.cookie.toByteArray().decodeToString()
val device = bluetoothAdapter.getRemoteDevice(address)
Log.i(TAG, "start: address=$address")
if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
Log.e(TAG, "Device is not connected, cannot start")
throw Status.UNKNOWN.asException()
}
audioTrack.play()
// If A2dp is not already playing, wait for it
if (!bluetoothA2dp.isA2dpPlaying(device)) {
flow
.filter { it.getAction() == BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED }
.filter {
it.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE).address == address
}
.map { it.getIntExtra(BluetoothA2dp.EXTRA_STATE, BluetoothAdapter.ERROR) }
.filter { it == BluetoothA2dp.STATE_PLAYING }
.first()
}
StartResponse.getDefaultInstance()
}
}
override fun suspend(request: SuspendRequest, responseObserver: StreamObserver<SuspendResponse>) {
grpcUnary<SuspendResponse>(scope, responseObserver) {
val address = request.source.cookie.toByteArray().decodeToString()
val device = bluetoothAdapter.getRemoteDevice(address)
Log.i(TAG, "suspend: address=$address")
if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
Log.e(TAG, "Device is not connected, cannot suspend")
throw Status.UNKNOWN.asException()
}
if (!bluetoothA2dp.isA2dpPlaying(device)) {
Log.e(TAG, "Device is already suspended, cannot suspend")
throw Status.UNKNOWN.asException()
}
val a2dpPlayingStateFlow =
flow
.filter { it.getAction() == BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED }
.filter {
it.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE).address == address
}
.map { it.getIntExtra(BluetoothA2dp.EXTRA_STATE, BluetoothAdapter.ERROR) }
audioTrack.pause()
a2dpPlayingStateFlow.filter { it == BluetoothA2dp.STATE_NOT_PLAYING }.first()
SuspendResponse.getDefaultInstance()
}
}
override fun isSuspended(
request: IsSuspendedRequest,
responseObserver: StreamObserver<IsSuspendedResponse>
) {
grpcUnary<IsSuspendedResponse>(scope, responseObserver) {
val address = request.source.cookie.toByteArray().decodeToString()
val device = bluetoothAdapter.getRemoteDevice(address)
Log.i(TAG, "isSuspended: address=$address")
if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
Log.e(TAG, "Device is not connected, cannot get suspend state")
throw Status.UNKNOWN.asException()
}
val isSuspended = bluetoothA2dp.isA2dpPlaying(device)
IsSuspendedResponse.newBuilder().setIsSuspended(isSuspended).build()
}
}
override fun close(request: CloseRequest, responseObserver: StreamObserver<CloseResponse>) {
grpcUnary<CloseResponse>(scope, responseObserver) {
val address = request.source.cookie.toByteArray().decodeToString()
val device = bluetoothAdapter.getRemoteDevice(address)
Log.i(TAG, "close: address=$address")
if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
Log.e(TAG, "Device is not connected, cannot close")
throw Status.UNKNOWN.asException()
}
val a2dpConnectionStateChangedFlow =
flow
.filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED }
.filter {
it.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE).address == address
}
.map { it.getIntExtra(BluetoothA2dp.EXTRA_STATE, BluetoothAdapter.ERROR) }
bluetoothA2dp.disconnect(device)
a2dpConnectionStateChangedFlow.filter { it == BluetoothA2dp.STATE_DISCONNECTED }.first()
CloseResponse.getDefaultInstance()
}
}
override fun playbackAudio(
responseObserver: StreamObserver<PlaybackAudioResponse>
): StreamObserver<PlaybackAudioRequest> {
Log.i(TAG, "playbackAudio")
// Volume is maxed out to avoid any amplitude modification of the provided audio data,
// enabling the test runner to do comparisons between input and output audio signal.
// Any volume modification should be done before providing the audio data.
if (audioManager.isVolumeFixed) {
Log.w(TAG, "Volume is fixed, cannot max out the volume")
} else {
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
if (audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) < maxVolume) {
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
maxVolume,
AudioManager.FLAG_SHOW_UI
)
}
}
return object : StreamObserver<PlaybackAudioRequest> {
override fun onNext(request: PlaybackAudioRequest) {
val data = request.data.toByteArray()
audioTrack.write(data, 0, data.size)
}
override fun onError(t: Throwable?) {
Log.e(TAG, t.toString())
responseObserver.onError(t)
}
override fun onCompleted() {
responseObserver.onNext(PlaybackAudioResponse.getDefaultInstance())
responseObserver.onCompleted()
}
}
}
override fun getAudioEncoding(
request: GetAudioEncodingRequest,
responseObserver: StreamObserver<GetAudioEncodingResponse>
) {
grpcUnary<GetAudioEncodingResponse>(scope, responseObserver) {
val address = request.source.cookie.toByteArray().decodeToString()
val device = bluetoothAdapter.getRemoteDevice(address)
Log.i(TAG, "getAudioEncoding: address=$address")
if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
Log.e(TAG, "Device is not connected, cannot getAudioEncoding")
throw Status.UNKNOWN.asException()
}
// For now, we only support 44100 kHz sampling rate.
GetAudioEncodingResponse.newBuilder()
.setEncoding(AudioEncoding.PCM_S16_LE_44K1_STEREO)
.build()
}
}
// TODO: Remove reflection and import framework bluetooth library when it will be available
// on AOSP.
fun BluetoothA2dp.connect(device: BluetoothDevice) =
this.javaClass.getMethod("connect", BluetoothDevice::class.java).invoke(this, device)
fun BluetoothA2dp.disconnect(device: BluetoothDevice) =
this.javaClass.getMethod("disconnect", BluetoothDevice::class.java).invoke(this, device)
}