Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 14f25639 authored by Charlie Boutier's avatar Charlie Boutier Committed by Gerrit Code Review
Browse files

Merge changes I6f75f998,If9280a31

* changes:
  PandoraServer: Remove common root package
  PandoraServer: Format with kotlinlang style
parents eec12315 ea0c0f75
Loading
Loading
Loading
Loading
+312 −0
Original line number Original line Diff line number Diff line
/*
 * 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.pandora

import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothAdapter
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 io.grpc.Status
import io.grpc.stub.StreamObserver
import java.io.Closeable
import java.io.PrintWriter
import java.io.StringWriter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
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
import pandora.A2DPGrpc.A2DPImplBase
import pandora.A2dpProto.*

@kotlinx.coroutines.ExperimentalCoroutinesApi
class A2dp(val context: Context) : A2DPImplBase(), Closeable {
    private val TAG = "PandoraA2dp"

    private val scope: CoroutineScope
    private val flow: Flow<Intent>

    private val audioManager = context.getSystemService(AudioManager::class.java)!!

    private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)!!
    private val bluetoothAdapter = bluetoothManager.adapter
    private val bluetoothA2dp = getProfileProxy<BluetoothA2dp>(context, BluetoothProfile.A2DP)

    private var audioTrack: AudioTrack? = null

    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)
    }

    override fun close() {
        bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, bluetoothA2dp)
        scope.cancel()
    }

    override fun openSource(
        request: OpenSourceRequest,
        responseObserver: StreamObserver<OpenSourceResponse>
    ) {
        grpcUnary<OpenSourceResponse>(scope, responseObserver) {
            val device = request.connection.toBluetoothDevice(bluetoothAdapter)
            Log.i(TAG, "openSource: device=$device")

            if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
                bluetoothA2dp.connect(device)
                val state =
                    flow
                        .filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED }
                        .filter { it.getBluetoothDeviceExtra() == device }
                        .map {
                            it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR)
                        }
                        .filter {
                            it == BluetoothProfile.STATE_CONNECTED ||
                                it == BluetoothProfile.STATE_DISCONNECTED
                        }
                        .first()

                if (state == BluetoothProfile.STATE_DISCONNECTED) {
                    throw RuntimeException("openSource failed, A2DP has been disconnected")
                }
            }

            // TODO: b/234891800, AVDTP start request sometimes never sent if playback starts too
            // early.
            delay(2000L)

            val source = Source.newBuilder().setConnection(request.connection).build()
            OpenSourceResponse.newBuilder().setSource(source).build()
        }
    }

    override fun waitSource(
        request: WaitSourceRequest,
        responseObserver: StreamObserver<WaitSourceResponse>
    ) {
        grpcUnary<WaitSourceResponse>(scope, responseObserver) {
            val device = request.connection.toBluetoothDevice(bluetoothAdapter)
            Log.i(TAG, "waitSource: device=$device")

            if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
                val state =
                    flow
                        .filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED }
                        .filter { it.getBluetoothDeviceExtra() == device }
                        .map {
                            it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR)
                        }
                        .filter {
                            it == BluetoothProfile.STATE_CONNECTED ||
                                it == BluetoothProfile.STATE_DISCONNECTED
                        }
                        .first()

                if (state == BluetoothProfile.STATE_DISCONNECTED) {
                    throw RuntimeException("waitSource failed, A2DP has been disconnected")
                }
            }

            // TODO: b/234891800, AVDTP start request sometimes never sent if playback starts too
            // early.
            delay(2000L)

            val source = Source.newBuilder().setConnection(request.connection).build()
            WaitSourceResponse.newBuilder().setSource(source).build()
        }
    }

    override fun start(request: StartRequest, responseObserver: StreamObserver<StartResponse>) {
        grpcUnary<StartResponse>(scope, responseObserver) {
            if (audioTrack == null) {
                audioTrack = buildAudioTrack()
            }
            val device = request.source.connection.toBluetoothDevice(bluetoothAdapter)
            Log.i(TAG, "start: device=$device")

            if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
                throw RuntimeException("Device is not connected, cannot start")
            }

            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.getBluetoothDeviceExtra() == device }
                    .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 device = request.source.connection.toBluetoothDevice(bluetoothAdapter)
            Log.i(TAG, "suspend: device=$device")

            if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
                throw RuntimeException("Device is not connected, cannot suspend")
            }

            if (!bluetoothA2dp.isA2dpPlaying(device)) {
                throw RuntimeException("Device is already suspended, cannot suspend")
            }

            val a2dpPlayingStateFlow =
                flow
                    .filter { it.getAction() == BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED }
                    .filter { it.getBluetoothDeviceExtra() == device }
                    .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 device = request.source.connection.toBluetoothDevice(bluetoothAdapter)
            Log.i(TAG, "isSuspended: device=$device")

            if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
                throw RuntimeException("Device is not connected, cannot get suspend state")
            }

            val isSuspended = bluetoothA2dp.isA2dpPlaying(device)
            IsSuspendedResponse.newBuilder().setIsSuspended(isSuspended).build()
        }
    }

    override fun close(request: CloseRequest, responseObserver: StreamObserver<CloseResponse>) {
        grpcUnary<CloseResponse>(scope, responseObserver) {
            val device = request.source.connection.toBluetoothDevice(bluetoothAdapter)
            Log.i(TAG, "close: device=$device")

            if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
                throw RuntimeException("Device is not connected, cannot close")
            }

            val a2dpConnectionStateChangedFlow =
                flow
                    .filter { it.getAction() == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED }
                    .filter { it.getBluetoothDeviceExtra() == device }
                    .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")

        if (audioTrack!!.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) {
            responseObserver.onError(
                Status.UNKNOWN.withDescription("AudioTrack is not started").asException()
            )
        }

        // 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()
                val written = synchronized(audioTrack!!) { audioTrack!!.write(data, 0, data.size) }
                if (written != data.size) {
                    responseObserver.onError(
                        Status.UNKNOWN.withDescription("AudioTrack write failed").asException()
                    )
                }
            }
            override fun onError(t: Throwable) {
                t.printStackTrace()
                val sw = StringWriter()
                t.printStackTrace(PrintWriter(sw))
                responseObserver.onError(
                    Status.UNKNOWN.withCause(t).withDescription(sw.toString()).asException()
                )
            }
            override fun onCompleted() {
                responseObserver.onNext(PlaybackAudioResponse.getDefaultInstance())
                responseObserver.onCompleted()
            }
        }
    }

    override fun getAudioEncoding(
        request: GetAudioEncodingRequest,
        responseObserver: StreamObserver<GetAudioEncodingResponse>
    ) {
        grpcUnary<GetAudioEncodingResponse>(scope, responseObserver) {
            val device = request.source.connection.toBluetoothDevice(bluetoothAdapter)
            Log.i(TAG, "getAudioEncoding: device=$device")

            if (bluetoothA2dp.getConnectionState(device) != BluetoothA2dp.STATE_CONNECTED) {
                throw RuntimeException("Device is not connected, cannot getAudioEncoding")
            }

            // For now, we only support 44100 kHz sampling rate.
            GetAudioEncodingResponse.newBuilder()
                .setEncoding(AudioEncoding.PCM_S16_LE_44K1_STEREO)
                .build()
        }
    }
}
+126 −0
Original line number Original line Diff line number Diff line
@@ -75,11 +75,16 @@ class A2dpSink(val context: Context) : A2DPImplBase(), Closeable {
            if (bluetoothA2dpSink.getConnectionState(device) != BluetoothProfile.STATE_CONNECTED) {
            if (bluetoothA2dpSink.getConnectionState(device) != BluetoothProfile.STATE_CONNECTED) {
                val state =
                val state =
                    flow
                    flow
            .filter { it.getAction() == BluetoothA2dpSink.ACTION_CONNECTION_STATE_CHANGED }
                        .filter {
                            it.getAction() == BluetoothA2dpSink.ACTION_CONNECTION_STATE_CHANGED
                        }
                        .filter { it.getBluetoothDeviceExtra() == device }
                        .filter { it.getBluetoothDeviceExtra() == device }
            .map { it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR) }
                        .map {
                            it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR)
                        }
                        .filter {
                        .filter {
              it == BluetoothProfile.STATE_CONNECTED || it == BluetoothProfile.STATE_DISCONNECTED
                            it == BluetoothProfile.STATE_CONNECTED ||
                                it == BluetoothProfile.STATE_DISCONNECTED
                        }
                        }
                        .first()
                        .first()


@@ -107,8 +112,13 @@ class A2dpSink(val context: Context) : A2DPImplBase(), Closeable {
                    .filter { it.getBluetoothDeviceExtra() == device }
                    .filter { it.getBluetoothDeviceExtra() == device }
                    .map { it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR) }
                    .map { it.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR) }


      bluetoothA2dpSink.setConnectionPolicy(device, BluetoothProfile.CONNECTION_POLICY_FORBIDDEN)
            bluetoothA2dpSink.setConnectionPolicy(
      a2dpConnectionStateChangedFlow.filter { it == BluetoothProfile.STATE_DISCONNECTED }.first()
                device,
                BluetoothProfile.CONNECTION_POLICY_FORBIDDEN
            )
            a2dpConnectionStateChangedFlow
                .filter { it == BluetoothProfile.STATE_DISCONNECTED }
                .first()


            CloseResponse.getDefaultInstance()
            CloseResponse.getDefaultInstance()
        }
        }
+251 −0
Original line number Original line Diff line number Diff line
/*
 * 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.pandora

import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.ComponentName
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Environment
import android.provider.MediaStore.Images.Media
import android.provider.MediaStore.MediaColumns
import android.provider.Telephony.*
import android.telephony.SmsManager
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import android.util.Log
import androidx.test.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import com.google.protobuf.Empty
import io.grpc.stub.StreamObserver
import java.io.Closeable
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import pandora.AndroidGrpc.AndroidImplBase
import pandora.AndroidProto.*

private const val TAG = "PandoraAndroidInternal"

@kotlinx.coroutines.ExperimentalCoroutinesApi
class AndroidInternal(val context: Context) : AndroidImplBase(), Closeable {

    private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
    private val INCOMING_FILE_ACCEPT_BTN = "ACCEPT"
    private val INCOMING_FILE_TITLE = "Incoming file"
    private val INCOMING_FILE_WAIT_TIMEOUT = 2000L

    // PTS does not configure the Extended Inquiry Response with the
    // device name; the device will be found after the Inquiry Timeout
    // (12.8sec) has elapsed.
    private val BT_DEVICE_SELECT_WAIT_TIMEOUT = 20000L
    private val IMAGE_FILE_NAME = "OPP_TEST_IMAGE.bmp"

    private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)!!
    private val bluetoothAdapter = bluetoothManager.adapter
    private var telephonyManager = context.getSystemService(TelephonyManager::class.java)
    private val DEFAULT_MESSAGE_LEN = 130
    private var device: UiDevice =
        UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

    init {
        createImageFile()
    }

    override fun close() {
        scope.cancel()

        val file =
            File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
                IMAGE_FILE_NAME
            )

        if (file.exists()) {
            file.delete()
        }
    }

    override fun log(request: LogRequest, responseObserver: StreamObserver<LogResponse>) {
        grpcUnary(scope, responseObserver) {
            Log.i(TAG, request.text)
            LogResponse.getDefaultInstance()
        }
    }

    override fun setAccessPermission(
        request: SetAccessPermissionRequest,
        responseObserver: StreamObserver<Empty>
    ) {
        grpcUnary<Empty>(scope, responseObserver) {
            val bluetoothDevice = request.address.toBluetoothDevice(bluetoothAdapter)
            when (request.accessType!!) {
                AccessType.ACCESS_MESSAGE ->
                    bluetoothDevice.setMessageAccessPermission(BluetoothDevice.ACCESS_ALLOWED)
                AccessType.ACCESS_PHONEBOOK ->
                    bluetoothDevice.setPhonebookAccessPermission(BluetoothDevice.ACCESS_ALLOWED)
                AccessType.ACCESS_SIM ->
                    bluetoothDevice.setSimAccessPermission(BluetoothDevice.ACCESS_ALLOWED)
                else -> {}
            }
            Empty.getDefaultInstance()
        }
    }

    override fun sendSMS(request: Empty, responseObserver: StreamObserver<Empty>) {
        grpcUnary<Empty>(scope, responseObserver) {
            val smsManager = SmsManager.getDefault()
            val defaultSmsSub = SubscriptionManager.getDefaultSmsSubscriptionId()
            telephonyManager = telephonyManager.createForSubscriptionId(defaultSmsSub)
            val avdPhoneNumber = telephonyManager.getLine1Number()

            smsManager.sendTextMessage(
                avdPhoneNumber,
                avdPhoneNumber,
                generateAlphanumericString(DEFAULT_MESSAGE_LEN),
                null,
                null
            )
            Empty.getDefaultInstance()
        }
    }

    override fun acceptIncomingFile(request: Empty, responseObserver: StreamObserver<Empty>) {
        grpcUnary<Empty>(scope, responseObserver) {
            device
                .wait(Until.findObject(By.text(INCOMING_FILE_TITLE)), INCOMING_FILE_WAIT_TIMEOUT)
                .click()
            device
                .wait(
                    Until.findObject(By.text(INCOMING_FILE_ACCEPT_BTN)),
                    INCOMING_FILE_WAIT_TIMEOUT
                )
                .click()
            Empty.getDefaultInstance()
        }
    }

    override fun sendFile(request: SendFileRequest, responseObserver: StreamObserver<Empty>) {
        grpcUnary<Empty>(scope, responseObserver) {
            initiateSendFile(getImageId(IMAGE_FILE_NAME), "image/bmp")
            waitAndSelectBluetoothDevice(request.name)
            Empty.getDefaultInstance()
        }
    }

    override fun sendPing(request: SendPingRequest, responseObserver: StreamObserver<Empty>) {
        grpcUnary<Empty>(scope, responseObserver) {
            val pingStatus =
                Runtime.getRuntime().exec("ping -I bt-pan -c 1 ${request.ipAddress}").waitFor()
            Empty.getDefaultInstance()
        }
    }

    suspend private fun waitAndSelectBluetoothDevice(name: String) {
        var selectJob =
            scope.async {
                device
                    .wait(Until.findObject(By.textContains(name)), BT_DEVICE_SELECT_WAIT_TIMEOUT)
                    .click()
            }
        selectJob.await()
    }

    private fun initiateSendFile(imageId: Long, type: String) {
        val contentUri = ContentUris.withAppendedId(Media.EXTERNAL_CONTENT_URI, imageId)

        try {
            var sendingIntent = Intent(Intent.ACTION_SEND)
            sendingIntent.setType(type)
            val activity =
                context.packageManager!!
                    .queryIntentActivities(
                        sendingIntent,
                        PackageManager.ResolveInfoFlags.of(
                            PackageManager.MATCH_DEFAULT_ONLY.toLong()
                        )
                    )
                    .filter { it!!.loadLabel(context.packageManager) == "Bluetooth" }
                    .first()
                    .activityInfo
            sendingIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
            sendingIntent.setComponent(
                ComponentName(activity.applicationInfo.packageName, activity.name)
            )
            sendingIntent.putExtra(Intent.EXTRA_STREAM, contentUri)
            context.startActivity(sendingIntent)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    private fun getImageId(fileName: String): Long {
        val selection = MediaColumns.DISPLAY_NAME + "=?"
        val selectionArgs = arrayOf(fileName)
        val cursor =
            context
                .getContentResolver()
                .query(Media.EXTERNAL_CONTENT_URI, null, selection, selectionArgs, null)

        cursor?.use {
            it.let {
                it.moveToFirst()
                return it.getLong(it.getColumnIndexOrThrow(Media._ID))
            }
        }
        return 0L
    }

    private fun createImageFile() {
        val bitmapImage = Bitmap.createBitmap(30, 20, Bitmap.Config.ARGB_8888)
        val file =
            File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
                IMAGE_FILE_NAME
            )
        var fileOutputStream: FileOutputStream? = null

        if (file.exists()) {
            file.delete()
        }
        file.createNewFile()
        try {
            fileOutputStream = FileOutputStream(file)
            bitmapImage.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream)
            fileOutputStream.flush()
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            try {
                if (fileOutputStream != null) {
                    fileOutputStream.close()
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }
}
+275 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading