1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.pandora 18 19 import android.bluetooth.BluetoothA2dp 20 import android.bluetooth.BluetoothAdapter 21 import android.bluetooth.BluetoothManager 22 import android.bluetooth.BluetoothProfile 23 import android.content.Context 24 import android.content.Intent 25 import android.content.IntentFilter 26 import android.media.* 27 import android.util.Log 28 import com.google.protobuf.BoolValue 29 import com.google.protobuf.ByteString 30 import io.grpc.Status 31 import io.grpc.stub.StreamObserver 32 import java.io.Closeable 33 import java.io.PrintWriter 34 import java.io.StringWriter 35 import kotlinx.coroutines.CoroutineScope 36 import kotlinx.coroutines.Dispatchers 37 import kotlinx.coroutines.cancel 38 import kotlinx.coroutines.delay 39 import kotlinx.coroutines.flow.Flow 40 import kotlinx.coroutines.flow.SharingStarted 41 import kotlinx.coroutines.flow.filter 42 import kotlinx.coroutines.flow.first 43 import kotlinx.coroutines.flow.map 44 import kotlinx.coroutines.flow.shareIn 45 import pandora.A2DPGrpc.A2DPImplBase 46 import pandora.A2DPProto.* 47 48 @kotlinx.coroutines.ExperimentalCoroutinesApi 49 class A2dp(val context: Context) : A2DPImplBase(), Closeable { 50 private val TAG = "PandoraA2dp" 51 52 private val scope: CoroutineScope 53 private val flow: Flow<Intent> 54 55 private val audioManager = context.getSystemService(AudioManager::class.java)!! 56 57 private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)!! 58 private val bluetoothAdapter = bluetoothManager.adapter 59 private val bluetoothA2dp = getProfileProxy<BluetoothA2dp>(context, BluetoothProfile.A2DP) 60 61 private var audioTrack: AudioTrack? = null 62 63 init { 64 scope = CoroutineScope(Dispatchers.Default.limitedParallelism(1)) 65 val intentFilter = IntentFilter() 66 intentFilter.addAction(BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED) 67 intentFilter.addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED) 68 69 flow = intentFlow(context, intentFilter, scope).shareIn(scope, SharingStarted.Eagerly) 70 } 71 closenull72 override fun close() { 73 bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, bluetoothA2dp) 74 scope.cancel() 75 } 76 openSourcenull77 override fun openSource( 78 request: OpenSourceRequest, 79 responseObserver: StreamObserver<OpenSourceResponse> 80 ) { 81 grpcUnary<OpenSourceResponse>(scope, responseObserver) { 82 val device = request.connection.toBluetoothDevice(bluetoothAdapter) 83 Log.i(TAG, "openSource: device=$device") 84 85 if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) { 86 bluetoothA2dp.connect(device) 87 val state = 88 flow 89 .filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED } 90 .filter { it.getBluetoothDeviceExtra() == device } 91 .map { 92 it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR) 93 } 94 .filter { 95 it == BluetoothProfile.STATE_CONNECTED || 96 it == BluetoothProfile.STATE_DISCONNECTED 97 } 98 .first() 99 100 if (state == BluetoothProfile.STATE_DISCONNECTED) { 101 throw RuntimeException("openSource failed, A2DP has been disconnected") 102 } 103 } 104 105 // TODO: b/234891800, AVDTP start request sometimes never sent if playback starts too 106 // early. 107 delay(2000L) 108 109 val source = 110 Source.newBuilder().setCookie(ByteString.copyFrom(device.getAddress(), "UTF-8")) 111 OpenSourceResponse.newBuilder().setSource(source).build() 112 } 113 } 114 waitSourcenull115 override fun waitSource( 116 request: WaitSourceRequest, 117 responseObserver: StreamObserver<WaitSourceResponse> 118 ) { 119 grpcUnary<WaitSourceResponse>(scope, responseObserver) { 120 val device = request.connection.toBluetoothDevice(bluetoothAdapter) 121 Log.i(TAG, "waitSource: device=$device") 122 123 if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) { 124 val state = 125 flow 126 .filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED } 127 .filter { it.getBluetoothDeviceExtra() == device } 128 .map { 129 it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR) 130 } 131 .filter { 132 it == BluetoothProfile.STATE_CONNECTED || 133 it == BluetoothProfile.STATE_DISCONNECTED 134 } 135 .first() 136 137 if (state == BluetoothProfile.STATE_DISCONNECTED) { 138 throw RuntimeException("waitSource failed, A2DP has been disconnected") 139 } 140 } 141 142 // TODO: b/234891800, AVDTP start request sometimes never sent if playback starts too 143 // early. 144 delay(2000L) 145 146 val source = 147 Source.newBuilder().setCookie(ByteString.copyFrom(device.getAddress(), "UTF-8")) 148 WaitSourceResponse.newBuilder().setSource(source).build() 149 } 150 } 151 startnull152 override fun start(request: StartRequest, responseObserver: StreamObserver<StartResponse>) { 153 grpcUnary<StartResponse>(scope, responseObserver) { 154 if (audioTrack == null) { 155 audioTrack = buildAudioTrack() 156 } 157 val device = bluetoothAdapter.getRemoteDevice(request.source.cookie.toString("UTF-8")) 158 Log.i(TAG, "start: device=$device") 159 160 if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) { 161 throw RuntimeException("Device is not connected, cannot start") 162 } 163 164 audioTrack!!.play() 165 166 // If A2dp is not already playing, wait for it 167 if (!bluetoothA2dp.isA2dpPlaying(device)) { 168 flow 169 .filter { it.getAction() == BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED } 170 .filter { it.getBluetoothDeviceExtra() == device } 171 .map { it.getIntExtra(BluetoothA2dp.EXTRA_STATE, BluetoothAdapter.ERROR) } 172 .filter { it == BluetoothA2dp.STATE_PLAYING } 173 .first() 174 } 175 StartResponse.getDefaultInstance() 176 } 177 } 178 suspendnull179 override fun suspend( 180 request: SuspendRequest, 181 responseObserver: StreamObserver<SuspendResponse> 182 ) { 183 grpcUnary<SuspendResponse>(scope, responseObserver) { 184 val device = bluetoothAdapter.getRemoteDevice(request.source.cookie.toString("UTF-8")) 185 Log.i(TAG, "suspend: device=$device") 186 187 if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) { 188 throw RuntimeException("Device is not connected, cannot suspend") 189 } 190 191 if (!bluetoothA2dp.isA2dpPlaying(device)) { 192 throw RuntimeException("Device is already suspended, cannot suspend") 193 } 194 195 val a2dpPlayingStateFlow = 196 flow 197 .filter { it.getAction() == BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED } 198 .filter { it.getBluetoothDeviceExtra() == device } 199 .map { it.getIntExtra(BluetoothA2dp.EXTRA_STATE, BluetoothAdapter.ERROR) } 200 201 audioTrack!!.pause() 202 a2dpPlayingStateFlow.filter { it == BluetoothA2dp.STATE_NOT_PLAYING }.first() 203 SuspendResponse.getDefaultInstance() 204 } 205 } 206 isSuspendednull207 override fun isSuspended( 208 request: IsSuspendedRequest, 209 responseObserver: StreamObserver<BoolValue> 210 ) { 211 grpcUnary(scope, responseObserver) { 212 val device = bluetoothAdapter.getRemoteDevice(request.source.cookie.toString("UTF-8")) 213 Log.i(TAG, "isSuspended: device=$device") 214 215 if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) { 216 throw RuntimeException("Device is not connected, cannot get suspend state") 217 } 218 219 val isSuspended = bluetoothA2dp.isA2dpPlaying(device) 220 221 BoolValue.newBuilder().setValue(isSuspended).build() 222 } 223 } 224 closenull225 override fun close(request: CloseRequest, responseObserver: StreamObserver<CloseResponse>) { 226 grpcUnary<CloseResponse>(scope, responseObserver) { 227 val device = bluetoothAdapter.getRemoteDevice(request.source.cookie.toString("UTF-8")) 228 Log.i(TAG, "close: device=$device") 229 230 if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) { 231 throw RuntimeException("Device is not connected, cannot close") 232 } 233 234 val a2dpConnectionStateChangedFlow = 235 flow 236 .filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED } 237 .filter { it.getBluetoothDeviceExtra() == device } 238 .map { it.getIntExtra(BluetoothA2dp.EXTRA_STATE, BluetoothAdapter.ERROR) } 239 240 bluetoothA2dp.disconnect(device) 241 a2dpConnectionStateChangedFlow.filter { it == BluetoothA2dp.STATE_DISCONNECTED }.first() 242 243 CloseResponse.getDefaultInstance() 244 } 245 } 246 playbackAudionull247 override fun playbackAudio( 248 responseObserver: StreamObserver<PlaybackAudioResponse> 249 ): StreamObserver<PlaybackAudioRequest> { 250 Log.i(TAG, "playbackAudio") 251 252 if (audioTrack!!.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) { 253 responseObserver.onError( 254 Status.UNKNOWN.withDescription("AudioTrack is not started").asException() 255 ) 256 } 257 258 // Volume is maxed out to avoid any amplitude modification of the provided audio data, 259 // enabling the test runner to do comparisons between input and output audio signal. 260 // Any volume modification should be done before providing the audio data. 261 if (audioManager.isVolumeFixed) { 262 Log.w(TAG, "Volume is fixed, cannot max out the volume") 263 } else { 264 val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) 265 if (audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) < maxVolume) { 266 audioManager.setStreamVolume( 267 AudioManager.STREAM_MUSIC, 268 maxVolume, 269 AudioManager.FLAG_SHOW_UI 270 ) 271 } 272 } 273 274 return object : StreamObserver<PlaybackAudioRequest> { 275 override fun onNext(request: PlaybackAudioRequest) { 276 val data = request.data.toByteArray() 277 val written = synchronized(audioTrack!!) { audioTrack!!.write(data, 0, data.size) } 278 if (written != data.size) { 279 responseObserver.onError( 280 Status.UNKNOWN.withDescription("AudioTrack write failed").asException() 281 ) 282 } 283 } 284 override fun onError(t: Throwable) { 285 t.printStackTrace() 286 val sw = StringWriter() 287 t.printStackTrace(PrintWriter(sw)) 288 responseObserver.onError( 289 Status.UNKNOWN.withCause(t).withDescription(sw.toString()).asException() 290 ) 291 } 292 override fun onCompleted() { 293 responseObserver.onNext(PlaybackAudioResponse.getDefaultInstance()) 294 responseObserver.onCompleted() 295 } 296 } 297 } 298 getAudioEncodingnull299 override fun getAudioEncoding( 300 request: GetAudioEncodingRequest, 301 responseObserver: StreamObserver<GetAudioEncodingResponse> 302 ) { 303 grpcUnary<GetAudioEncodingResponse>(scope, responseObserver) { 304 val device = bluetoothAdapter.getRemoteDevice(request.source.cookie.toString("UTF-8")) 305 Log.i(TAG, "getAudioEncoding: device=$device") 306 307 if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) { 308 throw RuntimeException("Device is not connected, cannot getAudioEncoding") 309 } 310 311 // For now, we only support 44100 kHz sampling rate. 312 GetAudioEncodingResponse.newBuilder() 313 .setEncoding(AudioEncoding.PCM_S16_LE_44K1_STEREO) 314 .build() 315 } 316 } 317 } 318