├── audioswitch
├── .gitignore
├── consumer-rules.pro
├── src
│ ├── test
│ │ ├── resources
│ │ │ └── mockito-extensions
│ │ │ │ └── org.mockito.plugins.MockMaker
│ │ └── java
│ │ │ └── com
│ │ │ └── twilio
│ │ │ └── audioswitch
│ │ │ ├── UnitTestLogger.kt
│ │ │ ├── TestUtil.kt
│ │ │ ├── android
│ │ │ └── BluetoothDeviceWrapperImplTest.kt
│ │ │ ├── wired
│ │ │ └── WiredHeadsetReceiverTest.kt
│ │ │ ├── AudioSwitchTestParams.kt
│ │ │ ├── BaseTest.kt
│ │ │ ├── AudioSwitchJavaTest.java
│ │ │ └── bluetooth
│ │ │ └── BluetoothHeadsetManagerTest.kt
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── twilio
│ │ │ │ └── audioswitch
│ │ │ │ ├── android
│ │ │ │ ├── PermissionsCheckStrategy.kt
│ │ │ │ ├── BluetoothDeviceWrapper.kt
│ │ │ │ ├── BuildWrapper.kt
│ │ │ │ ├── SystemClockWrapper.kt
│ │ │ │ ├── BluetoothIntentProcessor.kt
│ │ │ │ ├── Logger.kt
│ │ │ │ ├── BluetoothDeviceWrapperImpl.kt
│ │ │ │ ├── BluetoothIntentProcessorImpl.kt
│ │ │ │ └── ProductionLogger.kt
│ │ │ │ ├── wired
│ │ │ │ ├── WiredDeviceConnectionListener.kt
│ │ │ │ └── WiredHeadsetReceiver.kt
│ │ │ │ ├── AudioDeviceChangeListener.kt
│ │ │ │ ├── bluetooth
│ │ │ │ ├── BluetoothHeadsetConnectionListener.kt
│ │ │ │ ├── BluetoothScoJob.kt
│ │ │ │ └── BluetoothHeadsetManager.kt
│ │ │ │ ├── AudioFocusRequestWrapper.kt
│ │ │ │ ├── AudioDevice.kt
│ │ │ │ ├── AudioDeviceManager.kt
│ │ │ │ └── AudioSwitch.kt
│ │ └── AndroidManifest.xml
│ └── androidTest
│ │ ├── java
│ │ ├── com.twilio.audioswitch
│ │ │ ├── android
│ │ │ │ ├── FakeBluetoothIntentProcessor.kt
│ │ │ │ └── FakeDevice.kt
│ │ │ ├── AndroidTestBase.kt
│ │ │ ├── AudioFocusUtil.kt
│ │ │ ├── MultipleBluetoothHeadsetsTest.kt
│ │ │ ├── UserDeviceSelectionTest.kt
│ │ │ ├── AutomaticDeviceSelectionTest.kt
│ │ │ ├── TestUtil.kt
│ │ │ └── AudioSwitchIntegrationTest.kt
│ │ └── com
│ │ │ └── twilio
│ │ │ └── audioswitch
│ │ │ └── manual
│ │ │ └── ConnectedBluetoothHeadsetTest.kt
│ │ └── AndroidManifest.xml
├── ftl
│ └── app-debug.apk
├── proguard-rules.pro
├── build.gradle
└── lint-baseline.xml
├── settings.gradle
├── images
└── audioswitch-logo.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── ui-test-args.yaml
├── CONTRIBUTORS.md
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── PULL_REQUEST_TEMPLATE.md
├── .gitignore
├── gradlew.bat
├── .circleci
└── config.yml
├── README.md
├── gradlew
├── LICENSE.txt
└── CHANGELOG.md
/audioswitch/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/audioswitch/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':audioswitch'
2 |
--------------------------------------------------------------------------------
/audioswitch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker:
--------------------------------------------------------------------------------
1 | mock-maker-inline
--------------------------------------------------------------------------------
/images/audioswitch-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/twilio/audioswitch/HEAD/images/audioswitch-logo.png
--------------------------------------------------------------------------------
/audioswitch/ftl/app-debug.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/twilio/audioswitch/HEAD/audioswitch/ftl/app-debug.apk
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/twilio/audioswitch/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | #Wed Aug 27 18:43:45 UTC 2025
2 | versionPatch=5
3 | org.gradle.jvmargs=-Xmx1536m
4 | versionMajor=1
5 | versionMinor=2
6 | android.useAndroidX=true
7 | android.enableJetifier=true
8 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/PermissionsCheckStrategy.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | interface PermissionsCheckStrategy {
4 | fun hasPermissions(): Boolean
5 | }
6 |
--------------------------------------------------------------------------------
/audioswitch/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/BluetoothDeviceWrapper.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | internal interface BluetoothDeviceWrapper {
4 | val name: String
5 | val deviceClass: Int?
6 | }
7 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/BuildWrapper.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.os.Build
4 |
5 | internal class BuildWrapper {
6 | fun getVersion(): Int = Build.VERSION.SDK_INT
7 | }
8 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/wired/WiredDeviceConnectionListener.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.wired
2 |
3 | internal interface WiredDeviceConnectionListener {
4 | fun onDeviceConnected()
5 |
6 | fun onDeviceDisconnected()
7 | }
8 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/SystemClockWrapper.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.os.SystemClock
4 |
5 | internal class SystemClockWrapper {
6 | fun elapsedRealtime() = SystemClock.elapsedRealtime()
7 | }
8 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Jul 08 01:40:30 PDT 2023
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/BluetoothIntentProcessor.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.content.Intent
4 |
5 | internal interface BluetoothIntentProcessor {
6 | fun getBluetoothDevice(intent: Intent): BluetoothDeviceWrapper?
7 | }
8 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/android/FakeBluetoothIntentProcessor.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.content.Intent
4 |
5 | const val DEVICE_NAME = "DEVICE_NAME"
6 |
7 | internal class FakeBluetoothIntentProcessor : BluetoothIntentProcessor {
8 | override fun getBluetoothDevice(intent: Intent): BluetoothDeviceWrapper? =
9 | FakeBluetoothDevice(name = intent.getStringExtra(DEVICE_NAME)!!)
10 | }
11 |
--------------------------------------------------------------------------------
/ui-test-args.yaml:
--------------------------------------------------------------------------------
1 | integration-tests:
2 | timeout: 30m
3 | type: instrumentation
4 | app: audioswitch/ftl/app-debug.apk
5 | test: audioswitch/build/outputs/apk/androidTest/debug/audioswitch-debug-androidTest.apk
6 | device:
7 | - {model: austin, version: 33}
8 | - {model: starlte, version: 29}
9 | - {model: crownqlteue, version: 29}
10 | - {model: cactus, version: 27}
11 | - {model: q4q, version: 33}
12 | - {model: redfin, version: 30}
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/android/FakeDevice.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.bluetooth.BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE
4 |
5 | internal const val HEADSET_NAME = "Fake Headset"
6 | internal const val HEADSET_2_NAME = "Fake Headset 2"
7 |
8 | internal data class FakeBluetoothDevice(
9 | override val name: String,
10 | override val deviceClass: Int? = AUDIO_VIDEO_HANDSFREE,
11 | ) : BluetoothDeviceWrapper
12 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/Logger.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | internal interface Logger {
4 | var loggingEnabled: Boolean
5 |
6 | fun d(
7 | tag: String,
8 | message: String,
9 | )
10 |
11 | fun w(
12 | tag: String,
13 | message: String,
14 | )
15 |
16 | fun e(
17 | tag: String,
18 | message: String,
19 | )
20 |
21 | fun e(
22 | tag: String,
23 | message: String,
24 | throwable: Throwable,
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/BluetoothDeviceWrapperImpl.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.annotation.SuppressLint
4 | import android.bluetooth.BluetoothDevice
5 |
6 | internal const val DEFAULT_DEVICE_NAME = "Bluetooth"
7 |
8 | @SuppressLint("MissingPermission")
9 | internal data class BluetoothDeviceWrapperImpl(
10 | val device: BluetoothDevice,
11 | override val name: String = device.name ?: DEFAULT_DEVICE_NAME,
12 | override val deviceClass: Int? = device.bluetoothClass?.deviceClass,
13 | ) : BluetoothDeviceWrapper
14 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/AudioDeviceChangeListener.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | /**
4 | * Receives a list of the most recently available [AudioDevice]s. Also provides the
5 | * currently selected [AudioDevice] from [AudioSwitch].
6 | * - audioDevices - The list of [AudioDevice]s or an empty list if none are available.
7 | * - selectedAudioDevice - The currently selected device or null if no device has been selected.
8 | */
9 | typealias AudioDeviceChangeListener = (
10 | audioDevices: List,
11 | selectedAudioDevice: AudioDevice?,
12 | ) -> Unit
13 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/AndroidTestBase.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.Manifest
4 | import androidx.test.rule.GrantPermissionRule
5 | import org.junit.Rule
6 |
7 | open class AndroidTestBase {
8 | @get:Rule
9 | val bluetoothPermissionRules: GrantPermissionRule by lazy {
10 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
11 | GrantPermissionRule.grant(Manifest.permission.BLUETOOTH_CONNECT)
12 | } else {
13 | GrantPermissionRule.grant(Manifest.permission.BLUETOOTH)
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/CONTRIBUTORS.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | Thank you to all our contributors!
8 |
9 | * [John Qualls](https://github.com/Alton09)
10 | * [Aaron Alaniz](https://github.com/aaalaniz)
11 | * [Tejas Nandanikar](https://github.com/tejas-n)
12 | * [Ryan C. Payne](https://github.com/paynerc)
13 | * [Ardavon Falls](https://github.com/afalls-twilio)
14 | * [Magnus Martikainen](https://github.com/mmartikainen)
15 | * [Win Nu](https://github.com/winnuz)
16 | * [Rob Holmes](https://github.com/robholmes)
17 | * [Thien My](https://github.com/myntvn)
18 | * [David Liu](https://github.com/davidliu)
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is.
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/BluetoothIntentProcessorImpl.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.bluetooth.BluetoothDevice
4 | import android.bluetooth.BluetoothDevice.EXTRA_DEVICE
5 | import android.content.Intent
6 | import android.os.Build
7 |
8 | internal class BluetoothIntentProcessorImpl : BluetoothIntentProcessor {
9 | override fun getBluetoothDevice(intent: Intent): BluetoothDeviceWrapper? =
10 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
11 | intent.getParcelableExtra(EXTRA_DEVICE, BluetoothDevice::class.java)
12 | } else {
13 | intent.getParcelableExtra(EXTRA_DEVICE)
14 | }?.let { device -> BluetoothDeviceWrapperImpl(device) }
15 | }
16 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 | [Description of the Pull Request]
4 |
5 | ## Breakdown
6 |
7 | - [Bulleted summary of changes]
8 | - [eg. Add new public function to `Authenticator.kt` ]
9 | - [eg. Add new string resources to `strings.xml`]
10 |
11 | ## Validation
12 |
13 | - [Bulleted summary of validation steps]
14 | - [eg. Add new unit tests to validate changes]
15 | - [eg. Verified all CI checks pass on the feature branch]
16 |
17 | ## Additional Notes
18 |
19 | [Any additional comments, notes, or information relevant to reviewers.]
20 |
21 | ## Submission Checklist
22 | - [ ] The source has been evaluated for semantic versioning changes and are reflected in `gradle.properties`
23 | - [ ] The `CHANGELOG.md` reflects any **feature**, **bug fixes**, or **known issues** made in the source code
24 |
--------------------------------------------------------------------------------
/audioswitch/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 | -keep class com.twilio.audioswitch.** { *; }
23 | -keepattributes InnerClasses
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/bluetooth/BluetoothHeadsetConnectionListener.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.bluetooth
2 |
3 | import android.bluetooth.BluetoothHeadset
4 | import android.media.AudioManager
5 |
6 | /**
7 | * Notifies if Bluetooth device state has changed (connect, disconnect, audio connect, audio disconnect) or failed to connect.
8 | */
9 | interface BluetoothHeadsetConnectionListener {
10 | /**
11 | * @param headsetName name of the headset
12 | * @param state provided by [BluetoothHeadset]
13 | */
14 | fun onBluetoothHeadsetStateChanged(
15 | headsetName: String? = null,
16 | state: Int = 0,
17 | )
18 |
19 | /**
20 | * @param state provided by [AudioManager]
21 | */
22 | fun onBluetoothScoStateChanged(state: Int = 0)
23 |
24 | /**
25 | * Triggered when Bluetooth SCO job has timed out.
26 | */
27 | fun onBluetoothHeadsetActivationError()
28 | }
29 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/UnitTestLogger.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import com.twilio.audioswitch.android.Logger
4 |
5 | class UnitTestLogger(
6 | override var loggingEnabled: Boolean = true,
7 | ) : Logger {
8 | override fun d(
9 | tag: String,
10 | message: String,
11 | ) {
12 | printMessage(message)
13 | }
14 |
15 | override fun w(
16 | tag: String,
17 | message: String,
18 | ) {
19 | printMessage(message)
20 | }
21 |
22 | override fun e(
23 | tag: String,
24 | message: String,
25 | ) {
26 | printMessage(message)
27 | }
28 |
29 | override fun e(
30 | tag: String,
31 | message: String,
32 | throwable: Throwable,
33 | ) {
34 | printMessage(message)
35 | throwable.printStackTrace()
36 | }
37 |
38 | private fun printMessage(message: String) {
39 | if (loggingEnabled) {
40 | println(message)
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/AudioFocusRequestWrapper.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.annotation.SuppressLint
4 | import android.media.AudioAttributes
5 | import android.media.AudioFocusRequest
6 | import android.media.AudioManager
7 | import android.media.AudioManager.OnAudioFocusChangeListener
8 |
9 | internal class AudioFocusRequestWrapper {
10 | @SuppressLint("NewApi")
11 | fun buildRequest(audioFocusChangeListener: OnAudioFocusChangeListener): AudioFocusRequest {
12 | val playbackAttributes =
13 | AudioAttributes
14 | .Builder()
15 | .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
16 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
17 | .build()
18 | return AudioFocusRequest
19 | .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
20 | .setAudioAttributes(playbackAttributes)
21 | .setAcceptsDelayedFocusGain(true)
22 | .setOnAudioFocusChangeListener(audioFocusChangeListener)
23 | .build()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/AudioDevice.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | /**
4 | * This class represents a single audio device that has been retrieved by the [AudioSwitch].
5 | */
6 | sealed class AudioDevice {
7 | /** The friendly name of the device.*/
8 | abstract val name: String
9 |
10 | /** An [AudioDevice] representing a Bluetooth Headset.*/
11 | data class BluetoothHeadset internal constructor(
12 | override val name: String = "Bluetooth",
13 | ) : AudioDevice()
14 |
15 | /** An [AudioDevice] representing a Wired Headset.*/
16 | data class WiredHeadset internal constructor(
17 | override val name: String = "Wired Headset",
18 | ) : AudioDevice()
19 |
20 | /** An [AudioDevice] representing the Earpiece.*/
21 | data class Earpiece internal constructor(
22 | override val name: String = "Earpiece",
23 | ) : AudioDevice()
24 |
25 | /** An [AudioDevice] representing the Speakerphone.*/
26 | data class Speakerphone internal constructor(
27 | override val name: String = "Speakerphone",
28 | ) : AudioDevice()
29 | }
30 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/android/ProductionLogger.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.util.Log
4 |
5 | private const val TAG_PREFIX = "AS/"
6 |
7 | internal class ProductionLogger(
8 | override var loggingEnabled: Boolean = false,
9 | ) : Logger {
10 | override fun d(
11 | tag: String,
12 | message: String,
13 | ) {
14 | if (loggingEnabled) {
15 | Log.d(createTag(tag), message)
16 | }
17 | }
18 |
19 | override fun w(
20 | tag: String,
21 | message: String,
22 | ) {
23 | if (loggingEnabled) {
24 | Log.w(createTag(tag), message)
25 | }
26 | }
27 |
28 | override fun e(
29 | tag: String,
30 | message: String,
31 | ) {
32 | if (loggingEnabled) {
33 | Log.e(createTag(tag), message)
34 | }
35 | }
36 |
37 | override fun e(
38 | tag: String,
39 | message: String,
40 | throwable: Throwable,
41 | ) {
42 | if (loggingEnabled) {
43 | Log.e(createTag(tag), message, throwable)
44 | }
45 | }
46 |
47 | private fun createTag(tag: String): String = "$TAG_PREFIX$tag"
48 | }
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.aar
3 | *.ap_
4 | *.aab
5 |
6 | # Files for the ART/Dalvik VM
7 | *.dex
8 |
9 | # Java class files
10 | *.class
11 |
12 | # Generated files
13 | bin/
14 | gen/
15 | out/
16 | # Uncomment the following line in case you need and you don't have the release build type files in your app
17 | # release/
18 |
19 | # Gradle files
20 | .gradle/
21 | build/
22 |
23 | # Local configuration file (sdk path, etc)
24 | local.properties
25 |
26 | # Proguard folder generated by Eclipse
27 | proguard/
28 |
29 | # Log Files
30 | *.log
31 |
32 | # Android Studio Navigation editor temp files
33 | .navigation/
34 |
35 | # Android Studio captures folder
36 | captures/
37 |
38 | # IntelliJ
39 | *.iml
40 | .idea/
41 | .DS_Store
42 |
43 | # Keystore files
44 | # Uncomment the following lines if you do not want to check your keystore files in.
45 | #*.jks
46 | #*.keystore
47 |
48 | # External native build folder generated in Android Studio 2.2 and later
49 | .externalNativeBuild
50 | .cxx/
51 |
52 | # Google Services (e.g. APIs or Firebase)
53 | # google-services.json
54 |
55 | # Freeline
56 | freeline.py
57 | freeline/
58 | freeline_project_description.json
59 |
60 | # fastlane
61 | fastlane/report.xml
62 | fastlane/Preview.html
63 | fastlane/screenshots
64 | fastlane/test_output
65 | fastlane/readme.md
66 |
67 | # Version control
68 | vcs.xml
69 |
70 | # lint
71 | lint/intermediates/
72 | lint/generated/
73 | lint/outputs/
74 | lint/tmp/
75 | # lint/reports/
76 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 | > Before filing an issue please check that the issue is not already addressed by the following:
12 | > * [Github Issues](https://github.com/twilio/audioswitch/issues)
13 | > * [Changelog](https://github.com/twilio/audioswitch/blob/master/CHANGELOG.md)
14 |
15 | > Please ensure that you are not sharing any
16 | [Personally Identifiable Information(PII)](https://www.twilio.com/docs/glossary/what-is-personally-identifiable-information-pii)
17 | or sensitive account information (API keys, credentials, etc.) when reporting an issue.
18 |
19 | **Describe the bug**
20 | A clear and concise description of what the bug is.
21 |
22 | **To Reproduce**
23 | Steps to reproduce the behavior:
24 | 1. Go to '...'
25 | 2. Click on '....'
26 | 3. Scroll down to '....'
27 | 4. See error
28 |
29 | **Expected behavior**
30 | A clear and concise description of what you expected to happen.
31 |
32 | **Actual behavior**
33 | A clear and concise description of what actually happens.
34 |
35 | **Application Logs**
36 | - Logcat logs with [logging enabled](https://twilio.github.io/audioswitch/latest/audioswitch/com.twilio.audioswitch/-audio-switch/-audio-switch.html) and `AS/` logcat filter applied.
37 |
38 | **AudioSwitch Version**
39 | - Version: (e.g. 1.0.0)
40 |
41 | **Android Device (please complete the following information):**
42 | - Device: (e.g. Pixel 4)
43 | - API Version: (e.g. API 29)
44 |
45 | **Screenshots**
46 | If applicable, add screenshots to help explain your problem.
47 |
48 | **Additional context**
49 | Add any other context about the problem here.
50 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/AudioFocusUtil.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.media.AudioAttributes
4 | import android.media.AudioFocusRequest
5 | import android.media.AudioManager
6 | import android.os.Build
7 |
8 | class AudioFocusUtil(
9 | private val audioManager: AudioManager,
10 | private val audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener,
11 | ) {
12 | private lateinit var request: AudioFocusRequest
13 |
14 | fun requestFocus() {
15 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
16 | val playbackAttributes =
17 | AudioAttributes
18 | .Builder()
19 | .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
20 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
21 | .build()
22 | request =
23 | AudioFocusRequest
24 | .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
25 | .setAudioAttributes(playbackAttributes)
26 | .setAcceptsDelayedFocusGain(true)
27 | .setOnAudioFocusChangeListener(audioFocusChangeListener)
28 | .build()
29 | audioManager.requestAudioFocus(request)
30 | } else {
31 | audioManager.requestAudioFocus(
32 | audioFocusChangeListener,
33 | AudioManager.STREAM_VOICE_CALL,
34 | AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
35 | )
36 | }
37 | }
38 |
39 | fun abandonFocus() {
40 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
41 | audioManager.abandonAudioFocusRequest(request)
42 | } else {
43 | audioManager.abandonAudioFocus(audioFocusChangeListener)
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/MultipleBluetoothHeadsetsTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import androidx.test.annotation.UiThreadTest
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.LargeTest
6 | import com.twilio.audioswitch.android.HEADSET_2_NAME
7 | import com.twilio.audioswitch.android.HEADSET_NAME
8 | import org.hamcrest.CoreMatchers.equalTo
9 | import org.hamcrest.CoreMatchers.`is`
10 | import org.hamcrest.CoreMatchers.nullValue
11 | import org.junit.Assert.assertThat
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 |
15 | @RunWith(AndroidJUnit4::class)
16 | @LargeTest
17 | class MultipleBluetoothHeadsetsTest : AndroidTestBase() {
18 | @UiThreadTest
19 | @Test
20 | fun `it_should_assert_the_second_bluetooth_headset_when_two_are_connected`() {
21 | val (audioSwitch, bluetoothHeadsetReceiver) = setupFakeAudioSwitch(getInstrumentationContext())
22 |
23 | audioSwitch.start { _, _ -> }
24 | audioSwitch.activate()
25 | simulateBluetoothSystemIntent(
26 | getInstrumentationContext(),
27 | bluetoothHeadsetReceiver,
28 | )
29 | simulateBluetoothSystemIntent(
30 | getInstrumentationContext(),
31 | bluetoothHeadsetReceiver,
32 | HEADSET_2_NAME,
33 | )
34 |
35 | assertThat(audioSwitch.selectedAudioDevice!!.name, equalTo(HEADSET_2_NAME))
36 | assertThat(audioSwitch.availableAudioDevices.first().name, equalTo(HEADSET_2_NAME))
37 | assertThat(
38 | audioSwitch.availableAudioDevices.find { it.name == HEADSET_NAME },
39 | `is`(nullValue()),
40 | )
41 | assertThat(isSpeakerPhoneOn(), equalTo(false)) // Best we can do for asserting if a fake BT headset is activated
42 | audioSwitch.stop()
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/wired/WiredHeadsetReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.wired
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import com.twilio.audioswitch.android.Logger
8 |
9 | private const val TAG = "WiredHeadsetReceiver"
10 | internal const val STATE_UNPLUGGED = 0
11 | internal const val STATE_PLUGGED = 1
12 | internal const val INTENT_STATE = "state"
13 | internal const val INTENT_NAME = "name"
14 |
15 | internal class WiredHeadsetReceiver(
16 | private val context: Context,
17 | private val logger: Logger,
18 | ) : BroadcastReceiver() {
19 | internal var deviceListener: WiredDeviceConnectionListener? = null
20 |
21 | override fun onReceive(
22 | context: Context,
23 | intent: Intent,
24 | ) {
25 | intent.getIntExtra(INTENT_STATE, STATE_UNPLUGGED).let { state ->
26 | if (state == STATE_PLUGGED) {
27 | intent.getStringExtra(INTENT_NAME).let { name ->
28 | logger.d(TAG, "Wired headset device ${name ?: ""} connected")
29 | }
30 | deviceListener?.onDeviceConnected()
31 | } else {
32 | intent.getStringExtra(INTENT_NAME).let { name ->
33 | logger.d(TAG, "Wired headset device ${name ?: ""} disconnected")
34 | }
35 | deviceListener?.onDeviceDisconnected()
36 | }
37 | }
38 | }
39 |
40 | fun start(deviceListener: WiredDeviceConnectionListener) {
41 | this.deviceListener = deviceListener
42 | context.registerReceiver(this, IntentFilter(Intent.ACTION_HEADSET_PLUG))
43 | }
44 |
45 | fun stop() {
46 | deviceListener = null
47 | context.unregisterReceiver(this)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/TestUtil.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.bluetooth.BluetoothProfile
4 | import android.media.AudioManager
5 | import android.os.Handler
6 | import com.twilio.audioswitch.android.PermissionsCheckStrategy
7 | import com.twilio.audioswitch.android.SystemClockWrapper
8 | import com.twilio.audioswitch.bluetooth.BluetoothScoJob
9 | import org.mockito.kotlin.any
10 | import org.mockito.kotlin.eq
11 | import org.mockito.kotlin.isA
12 | import org.mockito.kotlin.mock
13 | import org.mockito.kotlin.times
14 | import org.mockito.kotlin.verify
15 | import org.mockito.kotlin.whenever
16 |
17 | const val DEVICE_NAME = "Bluetooth"
18 |
19 | internal fun setupAudioManagerMock() =
20 | mock {
21 | whenever(mock.mode).thenReturn(AudioManager.MODE_NORMAL)
22 | whenever(mock.isMicrophoneMute).thenReturn(true)
23 | whenever(mock.isSpeakerphoneOn).thenReturn(true)
24 | whenever(mock.getDevices(AudioManager.GET_DEVICES_OUTPUTS)).thenReturn(emptyArray())
25 | }
26 |
27 | internal fun setupScoHandlerMock() =
28 | mock {
29 | whenever(mock.post(any())).thenAnswer {
30 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run()
31 | true
32 | }
33 | }
34 |
35 | internal fun setupSystemClockMock() =
36 | mock {
37 | whenever(mock.elapsedRealtime()).thenReturn(0)
38 | }
39 |
40 | internal fun BaseTest.assertBluetoothHeadsetSetup() {
41 | verify(bluetoothAdapter).getProfileProxy(
42 | context,
43 | headsetManager,
44 | BluetoothProfile.HEADSET,
45 | )
46 | verify(context, times(3)).registerReceiver(eq(headsetManager), isA())
47 | }
48 |
49 | internal fun setupPermissionsCheckStrategy() =
50 | mock {
51 | whenever(mock.hasPermissions()).thenReturn(true)
52 | }
53 |
54 | fun createHeadset(name: String): AudioDevice.BluetoothHeadset = AudioDevice.BluetoothHeadset(name)
55 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/bluetooth/BluetoothScoJob.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.bluetooth
2 |
3 | import android.os.Handler
4 | import androidx.annotation.VisibleForTesting
5 | import com.twilio.audioswitch.android.Logger
6 | import com.twilio.audioswitch.android.SystemClockWrapper
7 | import java.util.concurrent.TimeoutException
8 |
9 | internal const val TIMEOUT = 5000L
10 | private const val TAG = "BluetoothScoJob"
11 |
12 | internal abstract class BluetoothScoJob(
13 | private val logger: Logger,
14 | private val bluetoothScoHandler: Handler,
15 | private val systemClockWrapper: SystemClockWrapper,
16 | ) {
17 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
18 | var bluetoothScoRunnable: BluetoothScoRunnable? = null
19 |
20 | protected abstract fun scoAction()
21 |
22 | open fun scoTimeOutAction() {}
23 |
24 | fun executeBluetoothScoJob() {
25 | // cancel existing runnable
26 | bluetoothScoRunnable?.let { bluetoothScoHandler.removeCallbacks(it) }
27 |
28 | BluetoothScoRunnable().apply {
29 | bluetoothScoRunnable = this
30 | bluetoothScoHandler.post(this)
31 | }
32 | logger.d(TAG, "Scheduled bluetooth sco job")
33 | }
34 |
35 | fun cancelBluetoothScoJob() {
36 | bluetoothScoRunnable?.let {
37 | bluetoothScoHandler.removeCallbacks(it)
38 | bluetoothScoRunnable = null
39 | logger.d(TAG, "Canceled bluetooth sco job")
40 | }
41 | }
42 |
43 | inner class BluetoothScoRunnable : Runnable {
44 | private val startTime = systemClockWrapper.elapsedRealtime()
45 | private var elapsedTime = 0L
46 |
47 | override fun run() {
48 | if (elapsedTime < TIMEOUT) {
49 | scoAction()
50 | elapsedTime = systemClockWrapper.elapsedRealtime() - startTime
51 | bluetoothScoHandler.postDelayed(this, 500)
52 | } else {
53 | logger.e(TAG, "Bluetooth sco job timed out", TimeoutException())
54 | scoTimeOutAction()
55 | cancelBluetoothScoJob()
56 | }
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/android/BluetoothDeviceWrapperImplTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.android
2 |
3 | import android.bluetooth.BluetoothClass
4 | import android.bluetooth.BluetoothClass.Device.AUDIO_VIDEO_HEADPHONES
5 | import android.bluetooth.BluetoothDevice
6 | import org.hamcrest.CoreMatchers.equalTo
7 | import org.hamcrest.CoreMatchers.`is`
8 | import org.hamcrest.CoreMatchers.nullValue
9 | import org.hamcrest.MatcherAssert.assertThat
10 | import org.junit.Test
11 | import org.mockito.kotlin.mock
12 | import org.mockito.kotlin.whenever
13 |
14 | class BluetoothDeviceWrapperImplTest {
15 | @Test
16 | fun `name should return a generic bluetooth device name if none was returned from the BluetoothDevice class`() {
17 | val device = BluetoothDeviceWrapperImpl(mock())
18 |
19 | assertThat(device.name, equalTo(DEFAULT_DEVICE_NAME))
20 | }
21 |
22 | @Test
23 | fun `name should return a the BluetoothDevice name`() {
24 | val bluetoothDevice =
25 | mock {
26 | whenever(mock.name).thenReturn("Some Device Name")
27 | }
28 | val deviceWrapper = BluetoothDeviceWrapperImpl(bluetoothDevice)
29 |
30 | assertThat(deviceWrapper.name, equalTo("Some Device Name"))
31 | }
32 |
33 | @Test
34 | fun `deviceClass should return null if the BluetoothDevice device class is null`() {
35 | val bluetoothDevice = mock()
36 | val deviceWrapper = BluetoothDeviceWrapperImpl(bluetoothDevice)
37 |
38 | assertThat(deviceWrapper.deviceClass, `is`(nullValue()))
39 | }
40 |
41 | @Test
42 | fun `deviceClass should return bluetooth class from the BluetoothDevice device class`() {
43 | val deviceClass =
44 | mock {
45 | whenever(mock.deviceClass).thenReturn(AUDIO_VIDEO_HEADPHONES)
46 | }
47 | val bluetoothDevice =
48 | mock {
49 | whenever(mock.bluetoothClass).thenReturn(deviceClass)
50 | }
51 | val deviceWrapper = BluetoothDeviceWrapperImpl(bluetoothDevice)
52 |
53 | assertThat(deviceWrapper.deviceClass, equalTo(AUDIO_VIDEO_HEADPHONES))
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/UserDeviceSelectionTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import androidx.test.annotation.UiThreadTest
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.LargeTest
6 | import androidx.test.platform.app.InstrumentationRegistry
7 | import com.twilio.audioswitch.AudioDevice.BluetoothHeadset
8 | import com.twilio.audioswitch.AudioDevice.Earpiece
9 | import com.twilio.audioswitch.AudioDevice.Speakerphone
10 | import org.hamcrest.CoreMatchers.equalTo
11 | import org.hamcrest.CoreMatchers.`is`
12 | import org.hamcrest.CoreMatchers.notNullValue
13 | import org.hamcrest.MatcherAssert.assertThat
14 | import org.junit.Test
15 | import org.junit.runner.RunWith
16 |
17 | @RunWith(AndroidJUnit4::class)
18 | @LargeTest
19 | class UserDeviceSelectionTest : AndroidTestBase() {
20 | private val context = InstrumentationRegistry.getInstrumentation().context
21 |
22 | @UiThreadTest
23 | @Test
24 | fun `it_should_select_the_earpiece_audio_device_when_the_user_selects_it`() {
25 | val audioSwitch = AudioSwitch(context)
26 | audioSwitch.start { _, _ -> }
27 | val earpiece =
28 | audioSwitch.availableAudioDevices
29 | .find { it is Earpiece }
30 | assertThat(earpiece, `is`(notNullValue()))
31 |
32 | audioSwitch.selectDevice(earpiece!!)
33 |
34 | assertThat(audioSwitch.selectedAudioDevice, equalTo(earpiece))
35 | audioSwitch.stop()
36 | }
37 |
38 | @UiThreadTest
39 | @Test
40 | fun `it_should_select_the_speakerphone_audio_device_when_the_user_selects_it`() {
41 | val audioSwitch = AudioSwitch(context)
42 | audioSwitch.start { _, _ -> }
43 | val speakerphone =
44 | audioSwitch.availableAudioDevices
45 | .find { it is Speakerphone }
46 | assertThat(speakerphone, `is`(notNullValue()))
47 |
48 | audioSwitch.selectDevice(speakerphone!!)
49 |
50 | assertThat(audioSwitch.selectedAudioDevice, equalTo(speakerphone))
51 | audioSwitch.stop()
52 | }
53 |
54 | @UiThreadTest
55 | @Test
56 | fun `it_should_select_the_bluetooth_audio_device_when_the_user_selects_it`() {
57 | val (audioSwitch, bluetoothHeadsetReceiver) = setupFakeAudioSwitch(context)
58 | audioSwitch.start { _, _ -> }
59 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver)
60 | val bluetoothDevice =
61 | audioSwitch.availableAudioDevices
62 | .find { it is BluetoothHeadset }
63 | assertThat(bluetoothDevice, `is`(notNullValue()))
64 |
65 | audioSwitch.selectDevice(bluetoothDevice!!)
66 |
67 | assertThat(audioSwitch.selectedAudioDevice, equalTo(bluetoothDevice))
68 | audioSwitch.stop()
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/audioswitch/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.library'
2 | apply plugin: 'maven-publish'
3 | apply plugin: 'signing'
4 | apply plugin: 'kotlin-android'
5 | apply plugin: "org.jetbrains.dokka"
6 | apply plugin: 'org.jetbrains.kotlin.android'
7 |
8 | android {
9 | compileSdkVersion 34
10 | namespace "com.twilio.audioswitch"
11 | testNamespace "com.twilio.audioswitch.test"
12 |
13 | buildFeatures {
14 | buildConfig = true
15 | }
16 |
17 | defaultConfig {
18 | minSdkVersion 21
19 | targetSdkVersion 34
20 | buildConfigField("String", "VERSION_NAME",
21 | "\"${audioSwitchVersion}\"")
22 |
23 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
24 | testInstrumentationRunnerArguments clearPackageData: 'true'
25 | consumerProguardFiles 'consumer-rules.pro'
26 | }
27 |
28 | compileOptions {
29 | sourceCompatibility JavaVersion.VERSION_11
30 | targetCompatibility JavaVersion.VERSION_11
31 | }
32 |
33 | kotlinOptions {
34 | jvmTarget = JavaVersion.VERSION_11
35 | }
36 |
37 | testOptions {
38 | execution 'ANDROIDX_TEST_ORCHESTRATOR'
39 | }
40 |
41 | buildTypes {
42 | release {
43 | minifyEnabled false
44 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
45 | }
46 | }
47 |
48 | lint {
49 | baseline = file("lint-baseline.xml")
50 | }
51 |
52 | dokkaHtml.configure {
53 | dokkaSourceSets {
54 | named("main") {
55 | noAndroidSdkLink.set(false)
56 | includeNonPublic = false
57 | reportUndocumented = true
58 | skipEmptyPackages = true
59 | }
60 | }
61 | }
62 |
63 | publishing {
64 | singleVariant('release') {
65 | withSourcesJar()
66 | }
67 | }
68 | }
69 |
70 | dependencies {
71 | implementation "androidx.annotation:annotation:1.6.0"
72 | testImplementation 'junit:junit:4.13.2'
73 | testImplementation 'org.mockito.kotlin:mockito-kotlin:5.1.0'
74 | testImplementation 'pl.pragmatists:JUnitParams:1.1.1'
75 | def androidXTest = '1.5.0'
76 | androidTestUtil "androidx.test:orchestrator:$androidXTest"
77 | androidTestImplementation "androidx.test:runner:$androidXTest"
78 | androidTestImplementation "androidx.test:rules:$androidXTest"
79 | androidTestImplementation 'androidx.test.ext:junit:1.1.3'
80 | androidTestImplementation 'com.android.support.test:runner:1.0.2'
81 | }
82 |
83 | /*
84 | * The publishing block enables publishing to MavenCentral
85 | */
86 | publishing {
87 | publications {
88 | audioSwitchRelease(MavenPublication) {
89 |
90 | groupId = 'com.twilio'
91 | artifactId = 'audioswitch'
92 | version = audioSwitchVersion
93 |
94 | pom {
95 | name = 'Audioswitch'
96 | description = 'An Android audio management library for real-time communication apps.'
97 | url = 'https://github.com/twilio/audioswitch'
98 | licenses {
99 | license {
100 | name = 'Apache 2.0'
101 | url = 'https://github.com/twilio/audioswitch/blob/master/LICENSE.txt'
102 | }
103 | }
104 | developers {
105 | developer {
106 | id = 'Twilio'
107 | name = 'Twilio'
108 | }
109 | }
110 | scm {
111 | connection = 'scm:git:github.com/twilio/audioswitch.git'
112 | developerConnection = 'scm:git:ssh://github.com/twilio/audioswitch.git'
113 | url = 'https://github.com/twilio/audioswitch/tree/main'
114 | }
115 | }
116 |
117 | afterEvaluate {
118 | from components.release
119 | }
120 | }
121 | }
122 | }
123 |
124 | signing {
125 | sign publishing.publications
126 | }
127 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/wired/WiredHeadsetReceiverTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.wired
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import com.twilio.audioswitch.android.Logger
6 | import org.hamcrest.CoreMatchers.equalTo
7 | import org.hamcrest.CoreMatchers.`is`
8 | import org.hamcrest.CoreMatchers.nullValue
9 | import org.junit.Assert.assertThat
10 | import org.junit.Assert.fail
11 | import org.junit.Test
12 | import org.mockito.kotlin.eq
13 | import org.mockito.kotlin.isA
14 | import org.mockito.kotlin.mock
15 | import org.mockito.kotlin.verify
16 | import org.mockito.kotlin.whenever
17 |
18 | class WiredHeadsetReceiverTest {
19 | private val context = mock()
20 | private val logger = mock()
21 | private val wiredDeviceConnectionListener = mock()
22 | private val wiredHeadsetReceiver =
23 | WiredHeadsetReceiver(
24 | context,
25 | logger,
26 | )
27 |
28 | @Test
29 | fun `onReceive should notify listener when a wired headset has been plugged in`() {
30 | val intent =
31 | mock {
32 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED))
33 | .thenReturn(STATE_PLUGGED)
34 | }
35 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener)
36 |
37 | wiredHeadsetReceiver.onReceive(context, intent)
38 |
39 | verify(wiredDeviceConnectionListener).onDeviceConnected()
40 | }
41 |
42 | @Test
43 | fun `onReceive should not notify listener when a wired headset has been plugged in but the listener is null`() {
44 | wiredHeadsetReceiver.deviceListener = null
45 | val intent =
46 | mock {
47 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED))
48 | .thenReturn(STATE_PLUGGED)
49 | }
50 |
51 | try {
52 | wiredHeadsetReceiver.onReceive(context, intent)
53 | } catch (e: NullPointerException) {
54 | fail("NullPointerException should not have been thrown")
55 | }
56 | }
57 |
58 | @Test
59 | fun `onReceive should notify listener when a wired headset has been unplugged`() {
60 | val intent =
61 | mock {
62 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED))
63 | .thenReturn(STATE_UNPLUGGED)
64 | }
65 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener)
66 |
67 | wiredHeadsetReceiver.onReceive(context, intent)
68 |
69 | verify(wiredDeviceConnectionListener).onDeviceDisconnected()
70 | }
71 |
72 | @Test
73 | fun `onReceive should not notify listener when a wired headset has been unplugged but the listener is null`() {
74 | wiredHeadsetReceiver.deviceListener = null
75 | val intent =
76 | mock {
77 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED))
78 | .thenReturn(STATE_UNPLUGGED)
79 | }
80 |
81 | try {
82 | wiredHeadsetReceiver.onReceive(context, intent)
83 | } catch (e: NullPointerException) {
84 | fail("NullPointerException should not have been thrown")
85 | }
86 | }
87 |
88 | @Test
89 | fun `start should register the device listener`() {
90 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener)
91 |
92 | assertThat(wiredHeadsetReceiver.deviceListener, equalTo(wiredDeviceConnectionListener))
93 | }
94 |
95 | @Test
96 | fun `start should register the broadcast receiver`() {
97 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener)
98 |
99 | verify(context).registerReceiver(eq(wiredHeadsetReceiver), isA())
100 | }
101 |
102 | @Test
103 | fun `stop should close resources successfully`() {
104 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener)
105 |
106 | wiredHeadsetReceiver.stop()
107 |
108 | assertThat(wiredHeadsetReceiver.deviceListener, `is`(nullValue()))
109 | verify(context).unregisterReceiver(wiredHeadsetReceiver)
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/AutomaticDeviceSelectionTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import androidx.test.annotation.UiThreadTest
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.LargeTest
6 | import com.twilio.audioswitch.AudioDevice.Earpiece
7 | import com.twilio.audioswitch.AudioDevice.Speakerphone
8 | import com.twilio.audioswitch.AudioDevice.WiredHeadset
9 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
10 | import junit.framework.TestCase.assertTrue
11 | import org.hamcrest.CoreMatchers.equalTo
12 | import org.hamcrest.MatcherAssert.assertThat
13 | import org.junit.Test
14 | import org.junit.runner.RunWith
15 | import java.util.concurrent.CountDownLatch
16 | import java.util.concurrent.TimeUnit
17 |
18 | @RunWith(AndroidJUnit4::class)
19 | @LargeTest
20 | class AutomaticDeviceSelectionTest : AndroidTestBase() {
21 | @UiThreadTest
22 | @Test
23 | fun `it_should_select_the_bluetooth_audio_device_by_default`() {
24 | val context = getInstrumentationContext()
25 | val (audioSwitch, bluetoothHeadsetReceiver, wiredHeadsetReceiver) = setupFakeAudioSwitch(context)
26 |
27 | audioSwitch.start { _, _ -> }
28 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver)
29 | simulateWiredHeadsetSystemIntent(context, wiredHeadsetReceiver)
30 |
31 | assertThat(audioSwitch.selectedAudioDevice!! is AudioDevice.BluetoothHeadset, equalTo(true))
32 | audioSwitch.stop()
33 | }
34 |
35 | @UiThreadTest
36 | @Test
37 | fun `it_should_notify_callback_when_bluetooth_connects`() {
38 | val context = getInstrumentationContext()
39 | val bluetoothConnectedLatch = CountDownLatch(1)
40 | val bluetoothListener =
41 | object : BluetoothHeadsetConnectionListener {
42 | override fun onBluetoothHeadsetStateChanged(
43 | headsetName: String?,
44 | state: Int,
45 | ) {
46 | bluetoothConnectedLatch.countDown()
47 | }
48 |
49 | override fun onBluetoothScoStateChanged(state: Int) {}
50 |
51 | override fun onBluetoothHeadsetActivationError() {}
52 | }
53 | val (audioSwitch, bluetoothHeadsetReceiver, wiredHeadsetReceiver) =
54 | setupFakeAudioSwitch(
55 | context,
56 | bluetoothListener = bluetoothListener,
57 | )
58 |
59 | audioSwitch.start { _, _ -> }
60 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver)
61 | simulateWiredHeadsetSystemIntent(context, wiredHeadsetReceiver)
62 |
63 | assertThat(audioSwitch.selectedAudioDevice!! is AudioDevice.BluetoothHeadset, equalTo(true))
64 | assertTrue(bluetoothConnectedLatch.await(5, TimeUnit.SECONDS))
65 | audioSwitch.stop()
66 | }
67 |
68 | @UiThreadTest
69 | @Test
70 | fun `it_should_select_the_wired_headset_by_default`() {
71 | val context = getInstrumentationContext()
72 | val (audioSwitch, bluetoothHeadsetReceiver, wiredHeadsetReceiver) =
73 | setupFakeAudioSwitch(context, listOf(WiredHeadset::class.java))
74 |
75 | audioSwitch.start { _, _ -> }
76 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver)
77 | simulateWiredHeadsetSystemIntent(context, wiredHeadsetReceiver)
78 |
79 | assertThat(audioSwitch.selectedAudioDevice!! is WiredHeadset, equalTo(true))
80 | audioSwitch.stop()
81 | }
82 |
83 | @UiThreadTest
84 | @Test
85 | fun `it_should_select_the_earpiece_audio_device_by_default`() {
86 | val context = getInstrumentationContext()
87 | val (audioSwitch, bluetoothHeadsetReceiver) =
88 | setupFakeAudioSwitch(context, listOf(Earpiece::class.java))
89 | audioSwitch.start { _, _ -> }
90 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver)
91 |
92 | assertThat(audioSwitch.selectedAudioDevice!! is Earpiece, equalTo(true))
93 | audioSwitch.stop()
94 | }
95 |
96 | @UiThreadTest
97 | @Test
98 | fun `it_should_select_the_speakerphone_audio_device_by_default`() {
99 | val context = getInstrumentationContext()
100 | val (audioSwitch, bluetoothHeadsetReceiver) =
101 | setupFakeAudioSwitch(context, listOf(Speakerphone::class.java))
102 | audioSwitch.start { _, _ -> }
103 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver)
104 |
105 | assertThat(audioSwitch.selectedAudioDevice!! is Speakerphone, equalTo(true))
106 | audioSwitch.stop()
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/TestUtil.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.bluetooth.BluetoothAdapter
4 | import android.bluetooth.BluetoothHeadset
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.media.AudioManager
8 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
9 | import com.twilio.audioswitch.AudioDevice.Earpiece
10 | import com.twilio.audioswitch.AudioDevice.Speakerphone
11 | import com.twilio.audioswitch.AudioDevice.WiredHeadset
12 | import com.twilio.audioswitch.android.BuildWrapper
13 | import com.twilio.audioswitch.android.DEVICE_NAME
14 | import com.twilio.audioswitch.android.FakeBluetoothIntentProcessor
15 | import com.twilio.audioswitch.android.HEADSET_NAME
16 | import com.twilio.audioswitch.android.ProductionLogger
17 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
18 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager
19 | import com.twilio.audioswitch.wired.INTENT_STATE
20 | import com.twilio.audioswitch.wired.STATE_PLUGGED
21 | import com.twilio.audioswitch.wired.WiredHeadsetReceiver
22 | import java.util.concurrent.TimeoutException
23 |
24 | internal fun setupFakeAudioSwitch(
25 | context: Context,
26 | preferredDevicesList: List> =
27 | listOf(
28 | AudioDevice.BluetoothHeadset::class.java,
29 | WiredHeadset::class.java,
30 | Earpiece::class.java,
31 | Speakerphone::class.java,
32 | ),
33 | bluetoothListener: BluetoothHeadsetConnectionListener? = null,
34 | ): Triple {
35 | val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
36 | val logger = ProductionLogger(true)
37 | val audioDeviceManager =
38 | AudioDeviceManager(
39 | context,
40 | logger,
41 | audioManager,
42 | BuildWrapper(),
43 | AudioFocusRequestWrapper(),
44 | {},
45 | )
46 | val wiredHeadsetReceiver = WiredHeadsetReceiver(context, logger)
47 | val headsetManager =
48 | BluetoothAdapter.getDefaultAdapter()?.let { bluetoothAdapter ->
49 | BluetoothHeadsetManager(
50 | context,
51 | logger,
52 | bluetoothAdapter,
53 | audioDeviceManager,
54 | bluetoothIntentProcessor = FakeBluetoothIntentProcessor(),
55 | )
56 | } ?: run {
57 | null
58 | }
59 | return Triple(
60 | AudioSwitch(
61 | context,
62 | bluetoothListener,
63 | logger,
64 | {},
65 | preferredDevicesList,
66 | audioDeviceManager,
67 | wiredHeadsetReceiver,
68 | bluetoothHeadsetManager = headsetManager,
69 | ),
70 | headsetManager!!,
71 | wiredHeadsetReceiver,
72 | )
73 | }
74 |
75 | internal fun simulateBluetoothSystemIntent(
76 | context: Context,
77 | headsetManager: BluetoothHeadsetManager,
78 | deviceName: String = HEADSET_NAME,
79 | action: String = BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED,
80 | connectionState: Int = BluetoothHeadset.STATE_CONNECTED,
81 | ) {
82 | val intent =
83 | Intent(action).apply {
84 | putExtra(BluetoothHeadset.EXTRA_STATE, connectionState)
85 | putExtra(DEVICE_NAME, deviceName)
86 | }
87 | headsetManager.onReceive(context, intent)
88 | }
89 |
90 | internal fun simulateWiredHeadsetSystemIntent(
91 | context: Context,
92 | wiredHeadsetReceiver: WiredHeadsetReceiver,
93 | ) {
94 | val intent =
95 | Intent().apply {
96 | putExtra(INTENT_STATE, STATE_PLUGGED)
97 | }
98 | wiredHeadsetReceiver.onReceive(context, intent)
99 | }
100 |
101 | fun getTargetContext(): Context = getInstrumentation().targetContext
102 |
103 | fun getInstrumentationContext(): Context = getInstrumentation().context
104 |
105 | fun isSpeakerPhoneOn() =
106 | (getTargetContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager?)?.let {
107 | it.isSpeakerphoneOn
108 | } ?: false
109 |
110 | fun retryAssertion(
111 | timeoutInMilliseconds: Long = 10000L,
112 | assertionAction: () -> Unit,
113 | ) {
114 | val startTime = System.currentTimeMillis()
115 | var currentTime = 0L
116 | while (currentTime <= timeoutInMilliseconds) {
117 | try {
118 | assertionAction()
119 | return
120 | } catch (error: AssertionError) {
121 | currentTime = System.currentTimeMillis() - startTime
122 | Thread.sleep(10)
123 | }
124 | }
125 | throw TimeoutException("Assertion timeout occurred")
126 | }
127 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/AudioSwitchTestParams.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import com.twilio.audioswitch.AudioDevice.BluetoothHeadset
4 | import com.twilio.audioswitch.AudioDevice.Earpiece
5 | import com.twilio.audioswitch.AudioDevice.Speakerphone
6 | import com.twilio.audioswitch.AudioDevice.WiredHeadset
7 | import com.twilio.audioswitch.TestType.BluetoothHeadsetTest
8 | import com.twilio.audioswitch.TestType.EarpieceAndSpeakerTest
9 | import com.twilio.audioswitch.TestType.WiredHeadsetTest
10 |
11 | private sealed class TestType {
12 | object EarpieceAndSpeakerTest : TestType()
13 |
14 | object WiredHeadsetTest : TestType()
15 |
16 | object BluetoothHeadsetTest : TestType()
17 | }
18 |
19 | private val commonTestCases =
20 | listOf(
21 | listOf(
22 | BluetoothHeadset::class.java,
23 | WiredHeadset::class.java,
24 | Earpiece::class.java,
25 | Speakerphone::class.java,
26 | ),
27 | listOf(
28 | WiredHeadset::class.java,
29 | BluetoothHeadset::class.java,
30 | Earpiece::class.java,
31 | Speakerphone::class.java,
32 | ),
33 | listOf(
34 | WiredHeadset::class.java,
35 | Earpiece::class.java,
36 | BluetoothHeadset::class.java,
37 | Speakerphone::class.java,
38 | ),
39 | listOf(
40 | WiredHeadset::class.java,
41 | Earpiece::class.java,
42 | Speakerphone::class.java,
43 | BluetoothHeadset::class.java,
44 | ),
45 | listOf(
46 | BluetoothHeadset::class.java,
47 | Earpiece::class.java,
48 | WiredHeadset::class.java,
49 | Speakerphone::class.java,
50 | ),
51 | listOf(
52 | BluetoothHeadset::class.java,
53 | Earpiece::class.java,
54 | Speakerphone::class.java,
55 | WiredHeadset::class.java,
56 | ),
57 | listOf(
58 | Earpiece::class.java,
59 | BluetoothHeadset::class.java,
60 | WiredHeadset::class.java,
61 | Speakerphone::class.java,
62 | ),
63 | listOf(
64 | BluetoothHeadset::class.java,
65 | WiredHeadset::class.java,
66 | Speakerphone::class.java,
67 | Earpiece::class.java,
68 | ),
69 | listOf(
70 | Speakerphone::class.java,
71 | BluetoothHeadset::class.java,
72 | WiredHeadset::class.java,
73 | Earpiece::class.java,
74 | ),
75 | listOf(
76 | BluetoothHeadset::class.java,
77 | Speakerphone::class.java,
78 | WiredHeadset::class.java,
79 | Earpiece::class.java,
80 | ),
81 | listOf(
82 | BluetoothHeadset::class.java,
83 | Speakerphone::class.java,
84 | WiredHeadset::class.java,
85 | ),
86 | listOf(
87 | Earpiece::class.java,
88 | BluetoothHeadset::class.java,
89 | ),
90 | listOf(Speakerphone::class.java),
91 | listOf(),
92 | )
93 |
94 | private fun getTestInput(testType: TestType): Array =
95 | mutableListOf>()
96 | .apply {
97 | commonTestCases.forEachIndexed { index, devices ->
98 | add(arrayOf(devices, getExpectedDevice(testType, devices)))
99 | }
100 | }.toTypedArray()
101 |
102 | private fun getExpectedDevice(
103 | testType: TestType,
104 | preferredDeviceList: List>,
105 | ): AudioDevice =
106 | when (testType) {
107 | EarpieceAndSpeakerTest -> {
108 | preferredDeviceList
109 | .find {
110 | it == Earpiece::class.java || it == Speakerphone::class.java
111 | }?.newInstance() ?: Earpiece()
112 | }
113 | WiredHeadsetTest -> {
114 | preferredDeviceList
115 | .find {
116 | it == WiredHeadset::class.java || it == Speakerphone::class.java
117 | }?.newInstance() ?: WiredHeadset()
118 | }
119 | BluetoothHeadsetTest -> {
120 | preferredDeviceList
121 | .find {
122 | it == BluetoothHeadset::class.java || it == Earpiece::class.java || it == Speakerphone::class.java
123 | }?.newInstance() ?: BluetoothHeadset()
124 | }
125 | }
126 |
127 | class EarpieceAndSpeakerParams {
128 | companion object {
129 | @JvmStatic
130 | fun provideParams(): Array = getTestInput(EarpieceAndSpeakerTest)
131 | }
132 | }
133 |
134 | class WiredHeadsetParams {
135 | companion object {
136 | @JvmStatic
137 | fun provideParams(): Array = getTestInput(WiredHeadsetTest)
138 | }
139 | }
140 |
141 | class BluetoothHeadsetParams {
142 | companion object {
143 | @JvmStatic
144 | fun provideParams(): Array = getTestInput(BluetoothHeadsetTest)
145 | }
146 | }
147 |
148 | class DefaultDeviceParams {
149 | companion object {
150 | @JvmStatic
151 | fun provideParams() = commonTestCases
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com.twilio.audioswitch/AudioSwitchIntegrationTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.content.Context
4 | import android.media.AudioManager
5 | import androidx.test.annotation.UiThreadTest
6 | import androidx.test.ext.junit.runners.AndroidJUnit4
7 | import androidx.test.platform.app.InstrumentationRegistry
8 | import junit.framework.TestCase.assertEquals
9 | import junit.framework.TestCase.assertFalse
10 | import junit.framework.TestCase.assertNotNull
11 | import junit.framework.TestCase.assertTrue
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 | import java.util.concurrent.CountDownLatch
15 | import java.util.concurrent.TimeUnit
16 |
17 | @RunWith(AndroidJUnit4::class)
18 | class AudioSwitchIntegrationTest : AndroidTestBase() {
19 | @Test
20 | @UiThreadTest
21 | fun it_should_disable_logging_by_default() {
22 | val audioSwitch = AudioSwitch(getInstrumentationContext())
23 |
24 | assertFalse(audioSwitch.loggingEnabled)
25 | }
26 |
27 | @Test
28 | @UiThreadTest
29 | fun it_should_allow_enabling_logging() {
30 | val audioSwitch = AudioSwitch(getInstrumentationContext())
31 |
32 | audioSwitch.loggingEnabled = true
33 |
34 | assertTrue(audioSwitch.loggingEnabled)
35 | }
36 |
37 | @Test
38 | @UiThreadTest
39 | fun it_should_allow_enabling_logging_at_construction() {
40 | val audioSwitch = AudioSwitch(getInstrumentationContext(), loggingEnabled = true)
41 |
42 | assertTrue(audioSwitch.loggingEnabled)
43 | }
44 |
45 | @Test
46 | @UiThreadTest
47 | fun it_should_allow_toggling_logging_while_in_use() {
48 | val audioSwitch = AudioSwitch(getInstrumentationContext())
49 | audioSwitch.loggingEnabled = true
50 | assertTrue(audioSwitch.loggingEnabled)
51 | audioSwitch.start { _, _ -> }
52 | val earpiece =
53 | audioSwitch.availableAudioDevices
54 | .find { it is AudioDevice.Earpiece }
55 | assertNotNull(earpiece)
56 | audioSwitch.selectDevice(earpiece!!)
57 | assertEquals(earpiece, audioSwitch.selectedAudioDevice)
58 | audioSwitch.stop()
59 |
60 | audioSwitch.loggingEnabled = false
61 | assertFalse(audioSwitch.loggingEnabled)
62 |
63 | audioSwitch.start { _, _ -> }
64 | audioSwitch.stop()
65 | }
66 |
67 | @Test
68 | @UiThreadTest
69 | fun `it_should_return_valid_semver_formatted_version`() {
70 | val semVerRegex =
71 | Regex(
72 | "^([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-" +
73 | "Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+)?$",
74 | )
75 | val version: String = AudioSwitch.VERSION
76 | assertNotNull(version)
77 | assertTrue(version.matches(semVerRegex))
78 | }
79 |
80 | @Test
81 | fun it_should_receive_audio_focus_changes_if_configured() {
82 | val audioFocusLostLatch = CountDownLatch(1)
83 | val audioFocusGainedLatch = CountDownLatch(1)
84 | val audioFocusChangeListener =
85 | AudioManager.OnAudioFocusChangeListener { focusChange ->
86 | when (focusChange) {
87 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> audioFocusLostLatch.countDown()
88 | AudioManager.AUDIOFOCUS_GAIN -> audioFocusGainedLatch.countDown()
89 | }
90 | }
91 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
92 | val audioSwitch = AudioSwitch(getTargetContext(), null, true, audioFocusChangeListener)
93 | audioSwitch.start { _, _ -> }
94 | audioSwitch.activate()
95 | }
96 |
97 | val audioManager =
98 | getInstrumentationContext()
99 | .getSystemService(Context.AUDIO_SERVICE) as AudioManager
100 | val audioFocusUtil = AudioFocusUtil(audioManager, audioFocusChangeListener)
101 | audioFocusUtil.requestFocus()
102 |
103 | assertTrue(audioFocusLostLatch.await(5, TimeUnit.SECONDS))
104 | audioFocusUtil.abandonFocus()
105 | assertTrue(audioFocusGainedLatch.await(5, TimeUnit.SECONDS))
106 | }
107 |
108 | @Test
109 | fun it_should_acquire_audio_focus_if_it_is_already_acquired_in_the_system() {
110 | val audioFocusLostLatch = CountDownLatch(1)
111 | val audioFocusGainedLatch = CountDownLatch(1)
112 | val audioFocusChangeListener =
113 | AudioManager.OnAudioFocusChangeListener { focusChange ->
114 | when (focusChange) {
115 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> audioFocusLostLatch.countDown()
116 | AudioManager.AUDIOFOCUS_GAIN -> audioFocusGainedLatch.countDown()
117 | }
118 | }
119 | val audioManager =
120 | getInstrumentationContext()
121 | .getSystemService(Context.AUDIO_SERVICE) as AudioManager
122 | val audioFocusUtil = AudioFocusUtil(audioManager, audioFocusChangeListener)
123 | audioFocusUtil.requestFocus()
124 |
125 | val audioSwitch = AudioSwitch(getTargetContext(), null, true)
126 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
127 | audioSwitch.start { _, _ -> }
128 | audioSwitch.activate()
129 | }
130 |
131 | assertTrue(audioFocusLostLatch.await(5, TimeUnit.SECONDS))
132 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
133 | audioSwitch.stop()
134 | }
135 | assertTrue(audioFocusGainedLatch.await(5, TimeUnit.SECONDS))
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | parameters:
4 | # this flag allows you to disable the default workflow, e.g. when running the standalone publish-snapshot workflow
5 | enable-default-workflow:
6 | description: "enables the main workflow that builds and tests on all branches and publishes a snapshot on master"
7 | type: boolean
8 | default: true
9 |
10 | aliases:
11 | - &workspace
12 | ~/audioswitch
13 |
14 | - &gradle-cache-key
15 | jars-{{ checksum "build.gradle" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }}
16 |
17 | - &release-filter
18 | filters:
19 | tags:
20 | only:
21 | - /^\d+\.\d+\.\d+$/
22 | branches:
23 | ignore: /.*/
24 |
25 | commands:
26 | restore_gradle_cache:
27 | steps:
28 | - restore_cache:
29 | key: *gradle-cache-key
30 | name: Restore Gradle Cache
31 |
32 | save_gradle_cache:
33 | steps:
34 | - save_cache:
35 | key: *gradle-cache-key
36 | name: Save Gradle Cache
37 | paths:
38 | - ~/.gradle/caches
39 | - ~/.gradle/wrapper
40 |
41 | setup_git_user:
42 | description: Configure git user
43 | steps:
44 | - run:
45 | name: Configure git user name and email
46 | command: |
47 | git config --global user.email $GIT_USER_EMAIL
48 | git config --global user.name $GIT_USER_NAME
49 |
50 | setup_gcloud:
51 | description: Authenticate with Google Cloud
52 | steps:
53 | - run:
54 | name: Setup GCloud Auth
55 | command: |
56 | echo $GCP_KEY | base64 -d | gcloud auth activate-service-account --key-file=-
57 |
58 | install_signing_key:
59 | steps:
60 | - run:
61 | name: Install signing key
62 | command: |
63 | echo $SIGNING_KEY | base64 -d >> $SIGNING_SECRET_KEY_RING_FILE
64 |
65 | publish_to_sonatype:
66 | description: Publish to Sonatype Repository
67 | steps:
68 | - run:
69 | name: Publish AudioSwitch release
70 | command: |
71 | ./gradlew -q sonatypeAudioSwitchReleaseUpload
72 |
73 | executors:
74 | build-executor:
75 | working_directory: *workspace
76 | docker:
77 | - image: cimg/android:2024.01.1-node
78 | resource_class: large
79 | environment:
80 | _JAVA_OPTIONS: "-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport"
81 |
82 | integration-test-executor:
83 | working_directory: *workspace
84 | docker:
85 | - image: google/cloud-sdk:latest
86 | resource_class: medium+
87 |
88 | jobs:
89 | lint:
90 | executor: build-executor
91 | steps:
92 | - checkout
93 | - restore_gradle_cache
94 | - run:
95 | name: Lint
96 | command: ./gradlew -q lint
97 | - store_artifacts:
98 | path: audioswitch/build/reports/lint-results.html
99 | destination: audioswitch
100 | - save_gradle_cache
101 |
102 | check-format:
103 | executor: build-executor
104 | resource_class: medium+
105 | steps:
106 | - checkout
107 | - restore_gradle_cache
108 | - run:
109 | name: Spotless Check
110 | command: ./gradlew -q spotlessCheck
111 | - save_gradle_cache
112 |
113 | build-audioswitch:
114 | executor: build-executor
115 | steps:
116 | - checkout
117 | - attach_workspace:
118 | at: *workspace
119 | - restore_gradle_cache
120 | - run:
121 | name: Build AudioSwitch and Tests
122 | command: ./gradlew -q audioswitch:assemble audioswitch:assembleAndroidTest
123 | - persist_to_workspace:
124 | root: .
125 | paths:
126 | - audioswitch/build
127 | - save_gradle_cache
128 |
129 | unit-tests:
130 | executor: build-executor
131 | steps:
132 | - checkout
133 | - attach_workspace:
134 | at: *workspace
135 | - restore_gradle_cache
136 | - run:
137 | name: Unit Tests
138 | command: ./gradlew audioswitch:testDebugUnitTest
139 | - save_gradle_cache
140 |
141 | integration-tests:
142 | executor: integration-test-executor
143 | steps:
144 | - checkout
145 | - attach_workspace:
146 | at: *workspace
147 | - setup_gcloud
148 | - run:
149 | name: Run Integration Tests
150 | command: >
151 | gcloud firebase test android run --use-orchestrator --environment-variables clearPackageData=true --no-record-video --project video-app-79418
152 | ui-test-args.yaml:integration-tests
153 |
154 | publish-release:
155 | executor: build-executor
156 | steps:
157 | - checkout
158 | - attach_workspace:
159 | at: *workspace
160 | - restore_gradle_cache
161 | - install_signing_key
162 | - publish_to_sonatype
163 | - save_gradle_cache
164 |
165 | bump-version:
166 | executor: build-executor
167 | steps:
168 | - checkout
169 | - attach_workspace:
170 | at: *workspace
171 | - restore_gradle_cache
172 | - setup_git_user
173 | - run:
174 | name: Bump Version
175 | command: ./gradlew incrementVersion
176 | - save_gradle_cache
177 |
178 | publish-docs:
179 | executor: build-executor
180 | steps:
181 | - checkout
182 | - attach_workspace:
183 | at: *workspace
184 | - restore_gradle_cache
185 | - setup_git_user
186 | - run:
187 | name: Publish Docs
188 | command: ./gradlew publishDocs
189 | - save_gradle_cache
190 |
191 | workflows:
192 | # Default workflow. Triggered by all commits. Runs checks and tests on all branches.
193 | build-test:
194 | when: << pipeline.parameters.enable-default-workflow >>
195 | jobs:
196 | - lint
197 | - check-format
198 | - build-audioswitch
199 | - unit-tests:
200 | requires:
201 | - build-audioswitch
202 | - lint
203 | - check-format
204 | - integration-tests:
205 | requires:
206 | - build-audioswitch
207 | - lint
208 | - check-format
209 |
210 | # Workflow to publish a release. Triggered by new git tags that match a version number, e.g. '1.2.3'.
211 | release:
212 | jobs:
213 | - publish-release:
214 | <<: *release-filter
215 | - publish-docs:
216 | <<: *release-filter
217 | requires:
218 | - publish-release
219 | - bump-version:
220 | <<: *release-filter
221 | requires:
222 | - publish-docs
223 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/AudioDeviceManager.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Context
5 | import android.content.pm.PackageManager
6 | import android.media.AudioDeviceInfo
7 | import android.media.AudioFocusRequest
8 | import android.media.AudioManager
9 | import android.media.AudioManager.OnAudioFocusChangeListener
10 | import android.os.Build
11 | import com.twilio.audioswitch.android.BuildWrapper
12 | import com.twilio.audioswitch.android.Logger
13 |
14 | private const val TAG = "AudioDeviceManager"
15 |
16 | internal class AudioDeviceManager(
17 | private val context: Context,
18 | private val logger: Logger,
19 | private val audioManager: AudioManager,
20 | private val build: BuildWrapper = BuildWrapper(),
21 | private val audioFocusRequest: AudioFocusRequestWrapper = AudioFocusRequestWrapper(),
22 | private val audioFocusChangeListener: OnAudioFocusChangeListener,
23 | ) {
24 | private var savedAudioMode = 0
25 | private var savedIsMicrophoneMuted = false
26 | private var savedSpeakerphoneEnabled = false
27 | private var audioRequest: AudioFocusRequest? = null
28 |
29 | @SuppressLint("NewApi")
30 | fun hasEarpiece(): Boolean {
31 | var hasEarpiece = false
32 | if (build.getVersion() >= Build.VERSION_CODES.M &&
33 | context.packageManager
34 | .hasSystemFeature(PackageManager.FEATURE_AUDIO_OUTPUT)
35 | ) {
36 | val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
37 | for (device in devices) {
38 | if (device.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE) {
39 | logger.d(TAG, "Builtin Earpiece available")
40 | hasEarpiece = true
41 | }
42 | }
43 | } else {
44 | hasEarpiece = context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
45 | }
46 | return hasEarpiece
47 | }
48 |
49 | @SuppressLint("NewApi")
50 | fun hasSpeakerphone(): Boolean {
51 | return if (build.getVersion() >= Build.VERSION_CODES.M &&
52 | context.packageManager
53 | .hasSystemFeature(PackageManager.FEATURE_AUDIO_OUTPUT)
54 | ) {
55 | val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
56 | for (device in devices) {
57 | if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
58 | logger.d(TAG, "Speakerphone available")
59 | return true
60 | }
61 | }
62 | false
63 | } else {
64 | logger.d(TAG, "Speakerphone available")
65 | true
66 | }
67 | }
68 |
69 | @SuppressLint("NewApi")
70 | fun setAudioFocus() {
71 | // Request audio focus before making any device switch.
72 | if (build.getVersion() >= Build.VERSION_CODES.O) {
73 | audioRequest = audioFocusRequest.buildRequest(audioFocusChangeListener)
74 | audioRequest?.let { audioManager.requestAudioFocus(it) }
75 | } else {
76 | audioManager.requestAudioFocus(
77 | audioFocusChangeListener,
78 | AudioManager.STREAM_VOICE_CALL,
79 | AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
80 | )
81 | }
82 | /*
83 | * Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
84 | * required to be in this mode when playout and/or recording starts for
85 | * best possible VoIP performance. Some devices have difficulties with speaker mode
86 | * if this is not set.
87 | */
88 | audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
89 | }
90 |
91 | @SuppressLint("NewApi")
92 | fun enableBluetoothSco(enable: Boolean) {
93 | if (build.getVersion() >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
94 | if (enable) {
95 | audioManager.availableCommunicationDevices
96 | .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
97 | ?.let { device ->
98 | audioManager.setCommunicationDevice(device)
99 | }
100 | } else {
101 | audioManager.clearCommunicationDevice()
102 | }
103 | } else {
104 | audioManager.run { if (enable) startBluetoothSco() else stopBluetoothSco() }
105 | }
106 | }
107 |
108 | @SuppressLint("NewApi")
109 | fun enableSpeakerphone(enable: Boolean) {
110 | var speakerEnabled: Boolean
111 |
112 | if (build.getVersion() >= Build.VERSION_CODES.S) {
113 | val currentDevice = audioManager.communicationDevice
114 | speakerEnabled = currentDevice?.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
115 |
116 | if (enable) {
117 | audioManager.availableCommunicationDevices
118 | .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
119 | ?.let { device ->
120 | audioManager.setCommunicationDevice(device)
121 | }
122 | }
123 | } else {
124 | audioManager.isSpeakerphoneOn = enable
125 | speakerEnabled = audioManager.isSpeakerphoneOn
126 | }
127 |
128 | /**
129 | * Some Samsung devices (reported Galaxy s9, s21) fail to route audio through USB headset
130 | * when in MODE_IN_COMMUNICATION
131 | */
132 | if (!speakerEnabled && "^SM-G(960|99)".toRegex().containsMatchIn(Build.MODEL)) {
133 | val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
134 | for (device in devices) {
135 | if (device.type == AudioDeviceInfo.TYPE_USB_HEADSET) {
136 | audioManager.mode = AudioManager.MODE_NORMAL
137 | break
138 | }
139 | }
140 | }
141 | }
142 |
143 | fun mute(mute: Boolean) {
144 | audioManager.isMicrophoneMute = mute
145 | }
146 |
147 | // TODO Consider persisting audio state in the event of process death
148 | @SuppressLint("NewApi")
149 | fun cacheAudioState() {
150 | savedAudioMode = audioManager.mode
151 | savedIsMicrophoneMuted = audioManager.isMicrophoneMute
152 |
153 | if (build.getVersion() >= Build.VERSION_CODES.S) {
154 | val currentDevice = audioManager.communicationDevice
155 | savedSpeakerphoneEnabled = currentDevice?.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
156 | } else {
157 | savedSpeakerphoneEnabled = audioManager.isSpeakerphoneOn
158 | }
159 | }
160 |
161 | @SuppressLint("NewApi")
162 | fun restoreAudioState() {
163 | audioManager.mode = savedAudioMode
164 | mute(savedIsMicrophoneMuted)
165 | enableSpeakerphone(savedSpeakerphoneEnabled)
166 | if (build.getVersion() >= Build.VERSION_CODES.O) {
167 | audioRequest?.let { audioManager.abandonAudioFocusRequest(it) }
168 | } else {
169 | audioManager.abandonAudioFocus(audioFocusChangeListener)
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/BaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.app.Application
4 | import android.bluetooth.BluetoothAdapter
5 | import android.bluetooth.BluetoothClass
6 | import android.bluetooth.BluetoothDevice
7 | import android.bluetooth.BluetoothHeadset
8 | import android.bluetooth.BluetoothManager
9 | import android.bluetooth.BluetoothProfile
10 | import android.content.Context
11 | import android.content.Intent
12 | import android.media.AudioManager.OnAudioFocusChangeListener
13 | import com.twilio.audioswitch.AudioDevice.Earpiece
14 | import com.twilio.audioswitch.AudioDevice.Speakerphone
15 | import com.twilio.audioswitch.AudioDevice.WiredHeadset
16 | import com.twilio.audioswitch.android.BuildWrapper
17 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
18 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager
19 | import com.twilio.audioswitch.wired.WiredHeadsetReceiver
20 | import org.hamcrest.CoreMatchers
21 | import org.hamcrest.MatcherAssert.assertThat
22 | import org.mockito.kotlin.mock
23 | import org.mockito.kotlin.verify
24 | import org.mockito.kotlin.whenever
25 |
26 | open class BaseTest {
27 | internal val bluetoothClass =
28 | mock {
29 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE)
30 | }
31 | internal val expectedBluetoothDevice =
32 | mock {
33 | whenever(mock.name).thenReturn(DEVICE_NAME)
34 | whenever(mock.bluetoothClass).thenReturn(bluetoothClass)
35 | }
36 | internal val application = mock()
37 | internal val context =
38 | mock {
39 | whenever(mock.applicationContext).thenReturn(application)
40 | }
41 | internal val bluetoothListener = mock()
42 | internal val logger = UnitTestLogger()
43 | internal val audioManager = setupAudioManagerMock()
44 | internal val bluetoothManager = mock()
45 | internal val bluetoothAdapter = mock()
46 |
47 | internal val audioDeviceChangeListener = mock()
48 | internal val buildWrapper = mock()
49 | internal val audioFocusRequest = mock()
50 | internal val defaultAudioFocusChangeListener = mock()
51 | internal val audioDeviceManager =
52 | AudioDeviceManager(
53 | context,
54 | logger,
55 | audioManager,
56 | buildWrapper,
57 | audioFocusRequest,
58 | defaultAudioFocusChangeListener,
59 | )
60 | internal val wiredHeadsetReceiver = WiredHeadsetReceiver(context, logger)
61 | internal var handler = setupScoHandlerMock()
62 | internal var systemClockWrapper = setupSystemClockMock()
63 | internal val headsetProxy = mock()
64 | internal val preferredDeviceList =
65 | listOf(
66 | AudioDevice.BluetoothHeadset::class.java,
67 | WiredHeadset::class.java,
68 | Earpiece::class.java,
69 | Speakerphone::class.java,
70 | )
71 | internal val permissionsStrategyProxy = setupPermissionsCheckStrategy()
72 | internal var headsetManager: BluetoothHeadsetManager =
73 | BluetoothHeadsetManager(
74 | context,
75 | logger,
76 | bluetoothAdapter,
77 | audioDeviceManager,
78 | bluetoothScoHandler = handler,
79 | systemClockWrapper = systemClockWrapper,
80 | headsetProxy = headsetProxy,
81 | )
82 |
83 | internal var audioSwitch =
84 | AudioSwitch(
85 | context = context,
86 | bluetoothHeadsetConnectionListener = bluetoothListener,
87 | logger = logger,
88 | audioDeviceManager = audioDeviceManager,
89 | wiredHeadsetReceiver = wiredHeadsetReceiver,
90 | audioFocusChangeListener = defaultAudioFocusChangeListener,
91 | preferredDeviceList = preferredDeviceList,
92 | permissionsCheckStrategy = permissionsStrategyProxy,
93 | bluetoothManager = bluetoothManager,
94 | bluetoothHeadsetManager = headsetManager,
95 | )
96 |
97 | // in Kotlin 1.8+ when methods are mapped to java, they are no longer by name but by hash
98 | // (except for protected members), ie:
99 | // getContext$audioswitch_debug() -> getContext$kotlin_module_name$1a2b3c4d() which is hard to
100 | // mock and brittle, so lets force an actual implementation here
101 | protected fun getContext() = context
102 |
103 | protected fun getDefaultAudioFocusChangeListener() = defaultAudioFocusChangeListener
104 |
105 | protected fun getBluetoothListener() = bluetoothListener
106 |
107 | protected fun getLogger() = logger
108 |
109 | protected fun getPreferredDeviceList() = preferredDeviceList
110 |
111 | protected fun getPermissionsStrategyProxy() = permissionsStrategyProxy
112 |
113 | protected fun getBluetoothManager() = bluetoothManager
114 |
115 | // needed to not violate scoping issues with internal classes
116 | @JvmName("getAudioDeviceManager")
117 | internal fun getAudioDeviceManager() = audioDeviceManager
118 |
119 | @JvmName("getWiredHeadsetReceiver")
120 | internal fun getWiredHeadsetReceiver() = wiredHeadsetReceiver
121 |
122 | @JvmName("getHeadsetManager")
123 | internal fun getHeadsetManager() = headsetManager
124 |
125 | internal fun assertBluetoothHeadsetTeardown() {
126 | assertThat(headsetManager.headsetListener, CoreMatchers.`is`(CoreMatchers.nullValue()))
127 | verify(bluetoothAdapter).closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy)
128 | verify(context).unregisterReceiver(headsetManager)
129 | }
130 |
131 | internal fun simulateNewBluetoothHeadsetConnection(bluetoothDevice: BluetoothDevice = expectedBluetoothDevice) {
132 | val intent =
133 | mock {
134 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
135 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
136 | .thenReturn(BluetoothHeadset.STATE_CONNECTED)
137 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
138 | .thenReturn(bluetoothDevice)
139 | }
140 | headsetManager.onReceive(context, intent)
141 | }
142 |
143 | internal fun simulateDisconnectedBluetoothHeadsetConnection(bluetoothDevice: BluetoothDevice = expectedBluetoothDevice) {
144 | val intent =
145 | mock {
146 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
147 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
148 | .thenReturn(BluetoothHeadset.STATE_DISCONNECTED)
149 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
150 | .thenReturn(bluetoothDevice)
151 | }
152 | headsetManager.onReceive(context, intent)
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/AudioSwitchJavaTest.java:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch;
2 |
3 | import static junit.framework.TestCase.assertEquals;
4 | import static junit.framework.TestCase.assertFalse;
5 | import static junit.framework.TestCase.assertNotNull;
6 | import static org.junit.Assert.assertTrue;
7 | import static org.mockito.ArgumentMatchers.any;
8 | import static org.mockito.Mockito.when;
9 |
10 | import android.content.pm.PackageManager;
11 | import com.twilio.audioswitch.AudioDevice.BluetoothHeadset;
12 | import com.twilio.audioswitch.AudioDevice.Earpiece;
13 | import com.twilio.audioswitch.AudioDevice.Speakerphone;
14 | import com.twilio.audioswitch.AudioDevice.WiredHeadset;
15 | import java.util.ArrayList;
16 | import java.util.List;
17 | import kotlin.Unit;
18 | import kotlin.jvm.functions.Function2;
19 | import org.junit.Before;
20 | import org.junit.Test;
21 | import org.junit.runner.RunWith;
22 | import org.mockito.Mock;
23 | import org.mockito.junit.MockitoJUnitRunner;
24 |
25 | @RunWith(MockitoJUnitRunner.Silent.class)
26 | public class AudioSwitchJavaTest extends BaseTest {
27 | private AudioSwitch javaAudioSwitch;
28 | @Mock PackageManager packageManager;
29 |
30 | @Before
31 | public void setUp() {
32 | when(packageManager.hasSystemFeature(any())).thenReturn(true);
33 | when(getContext().getPackageManager()).thenReturn(packageManager);
34 | javaAudioSwitch =
35 | new AudioSwitch(
36 | getContext(),
37 | null,
38 | new UnitTestLogger(false),
39 | getDefaultAudioFocusChangeListener(),
40 | getPreferredDeviceList(),
41 | getAudioDeviceManager(),
42 | getWiredHeadsetReceiver(),
43 | getPermissionsStrategyProxy(),
44 | getBluetoothManager(),
45 | getHeadsetManager());
46 | }
47 |
48 | @Test
49 | public void shouldAllowConstruction() {
50 | assertNotNull(javaAudioSwitch);
51 | }
52 |
53 | @Test
54 | public void shouldAllowStart() {
55 | Function2, AudioDevice, Unit> audioDeviceListener =
56 | (audioDevices, audioDevice) -> {
57 | assertFalse(audioDevices.isEmpty());
58 | assertNotNull(audioDevice);
59 | return Unit.INSTANCE;
60 | };
61 |
62 | javaAudioSwitch.start(audioDeviceListener);
63 | }
64 |
65 | @Test
66 | public void shouldAllowActivate() {
67 | startAudioSwitch();
68 |
69 | javaAudioSwitch.activate();
70 | }
71 |
72 | @Test
73 | public void shouldAllowDeactivate() {
74 | javaAudioSwitch.deactivate();
75 | }
76 |
77 | @Test
78 | public void shouldAllowStop() {
79 | javaAudioSwitch.stop();
80 | }
81 |
82 | @Test
83 | public void shouldAllowGettingAvailableDevices() {
84 | startAudioSwitch();
85 | List availableDevices = javaAudioSwitch.getAvailableAudioDevices();
86 |
87 | assertFalse(availableDevices.isEmpty());
88 | }
89 |
90 | @Test
91 | public void shouldAllowGettingSelectedAudioDevice() {
92 | startAudioSwitch();
93 | AudioDevice audioDevice = javaAudioSwitch.getSelectedAudioDevice();
94 |
95 | assertNotNull(audioDevice);
96 | }
97 |
98 | @Test
99 | public void shouldAllowSelectingAudioDevice() {
100 | Earpiece earpiece = new Earpiece();
101 | javaAudioSwitch.selectDevice(earpiece);
102 |
103 | assertEquals(earpiece, javaAudioSwitch.getSelectedAudioDevice());
104 | }
105 |
106 | @Test
107 | public void shouldDisableLoggingByDefault() {
108 | assertFalse(javaAudioSwitch.getLoggingEnabled());
109 | }
110 |
111 | @Test
112 | public void shouldAllowEnablingLogging() {
113 | javaAudioSwitch.setLoggingEnabled(true);
114 | }
115 |
116 | @Test
117 | public void getVersion_shouldReturnValidSemverFormattedVersion() {
118 | String semVerRegex =
119 | "^([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-"
120 | + "Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+)?$";
121 |
122 | assertNotNull(AudioSwitch.VERSION);
123 | assertTrue(AudioSwitch.VERSION.matches(semVerRegex));
124 | }
125 |
126 | @Test
127 | public void shouldAllowChangingThePreferredDeviceList() {
128 | List> preferredDeviceList = new ArrayList<>();
129 | preferredDeviceList.add(Speakerphone.class);
130 | preferredDeviceList.add(Earpiece.class);
131 | preferredDeviceList.add(WiredHeadset.class);
132 | preferredDeviceList.add(BluetoothHeadset.class);
133 | javaAudioSwitch =
134 | new AudioSwitch(
135 | getContext(),
136 | getBluetoothListener(),
137 | getLogger(),
138 | getDefaultAudioFocusChangeListener(),
139 | preferredDeviceList,
140 | getAudioDeviceManager(),
141 | getWiredHeadsetReceiver(),
142 | getPermissionsStrategyProxy(),
143 | getBluetoothManager(),
144 | getHeadsetManager());
145 |
146 | startAudioSwitch();
147 |
148 | assertEquals(new Speakerphone(), javaAudioSwitch.getSelectedAudioDevice());
149 | }
150 |
151 | @Test
152 | public void shouldAllowAddingAudioDeviceListener() {
153 | javaAudioSwitch.start(null);
154 | Function2, AudioDevice, Unit> audioDeviceListener =
155 | (audioDevices, audioDevice) -> {
156 | assertFalse(audioDevices.isEmpty());
157 | assertNotNull(audioDevice);
158 | return Unit.INSTANCE;
159 | };
160 | javaAudioSwitch.setAudioDeviceChangeListener(audioDeviceListener);
161 | }
162 |
163 | @Test
164 | public void shouldAllowRemovingAudioDeviceListener() {
165 | Function2, AudioDevice, Unit> audioDeviceListener =
166 | (audioDevices, audioDevice) -> {
167 | assertFalse(audioDevices.isEmpty());
168 | assertNotNull(audioDevice);
169 | return Unit.INSTANCE;
170 | };
171 | javaAudioSwitch.start(audioDeviceListener);
172 | javaAudioSwitch.setAudioDeviceChangeListener(null);
173 | }
174 |
175 | private void startAudioSwitch() {
176 | Function2, AudioDevice, Unit> audioDeviceListener =
177 | (audioDevices, audioDevice) -> {
178 | assertFalse(audioDevices.isEmpty());
179 | assertNotNull(audioDevice);
180 | return Unit.INSTANCE;
181 | };
182 | javaAudioSwitch.start(
183 | (audioDevices, audioDevice) -> {
184 | assertFalse(audioDevices.isEmpty());
185 | assertNotNull(audioDevice);
186 | return Unit.INSTANCE;
187 | });
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AudioSwitch
2 |
3 | [](https://circleci.com/gh/twilio/audioswitch)
4 |
5 | An Android audio management library for real-time communication apps.
6 |
7 | 
8 |
9 | ## Features
10 |
11 | - [x] Manage [audio focus](https://developer.android.com/guide/topics/media-apps/audio-focus) for typical VoIP and Video conferencing use cases.
12 | - [x] Manage audio input and output devices.
13 | - [x] Detect changes in available audio devices
14 | - [x] Enumerate audio devices
15 | - [x] Select an audio device
16 |
17 | ## Requirements
18 |
19 | Android Studio Version | Android API Version Min
20 | ------------ | -------------
21 | 3.6+ | 16
22 |
23 | ## Documentation
24 |
25 | The KDoc for this library can be found [here](https://twilio.github.io/audioswitch/latest).
26 |
27 | ## Getting Started
28 |
29 | To get started using this library, follow the steps below.
30 |
31 | ### Gradle Setup
32 |
33 | [ ](https://maven-badges.herokuapp.com/maven-central/com.twilio/audioswitch)
34 |
35 | Ensure that you have `mavenCentral` listed in your project's buildscript repositories section:
36 | ```groovy
37 | buildscript {
38 | repositories {
39 | mavenCentral()
40 | // ...
41 | }
42 | }
43 | ```
44 |
45 | Add this line as a new Gradle dependency:
46 | ```groovy
47 | implementation 'com.twilio:audioswitch:$version'
48 | ```
49 |
50 | Pull requests merged to master result in a snapshot artifact being published to the Maven Central snapshots repository. You can
51 | access these snapshots by adding the following to your gradle file `repositories`:
52 |
53 | ```groovy
54 | maven {
55 | url = uri("https://oss.sonatype.org/content/repositories/snapshots")
56 | }
57 | ```
58 |
59 | Add a `-SNAPSHOT` suffix to the Gradle dependency version:
60 |
61 | ```groovy
62 | implementation 'com.twilio:audioswitch:$version-SNAPSHOT'
63 | ```
64 |
65 | ### AudioSwitch Setup
66 | Instantiate an instance of the [AudioSwitch](audioswitch/src/main/java/com/twilio/audioswitch/AudioSwitch.kt) class, passing a reference to the application context.
67 |
68 | ```kotlin
69 | val audioSwitch = AudioSwitch(applicationContext)
70 | ```
71 |
72 | ### Listen for Devices
73 | To begin listening for live audio device changes, call the start function and pass a lambda that will receive [AudioDevices](audioswitch/src/main/java/com/twilio/audioswitch/AudioDevice.kt) when they become available.
74 |
75 | ```kotlin
76 | audioSwitch.start { audioDevices, selectedDevice ->
77 | // TODO update UI with audio devices
78 | }
79 | ```
80 | You can also retrieve the available and selected audio devices manually at any time by calling the following properties:
81 | ```kotlin
82 | val devices: List = audioSwitch.availableAudioDevices
83 | val selectedDevice: AudioDevice? = audioSwitch.selectedAudioDevice
84 | ```
85 | **Note:** Don't forget to stop listening for audio devices when no longer needed in order to prevent a memory leak.
86 | ```kotlin
87 | audioSwitch.stop()
88 | ```
89 |
90 | ### Select a Device
91 | Before activating an AudioDevice, it needs to be selected first.
92 | ```kotlin
93 | devices.find { it is AudioDevice.Speakerphone }?.let { audioSwitch.selectDevice(it) }
94 | ```
95 | If no device is selected, then the library will automatically select a device based on the following priority: `BluetoothHeadset -> WiredHeadset -> Earpiece -> Speakerphone`.
96 |
97 | ### Activate a Device
98 | Activating a device acquires audio focus with [voice communication usage](https://developer.android.com/reference/android/media/AudioAttributes#USAGE_VOICE_COMMUNICATION) and begins routing audio input/output to the selected device.
99 | ```kotlin
100 | audioSwitch.activate()
101 | ```
102 | Make sure to revert back to the prior audio state when it makes sense to do so in your app.
103 | ```kotlin
104 | audioSwitch.deactivate()
105 | ```
106 | **Note:** The `stop()` function will call `deactivate()` before closing AudioSwitch resources.
107 |
108 | ## Bluetooth Support
109 |
110 | Multiple connected bluetooth headsets are supported.
111 | - Bluetooth support requires BLUETOOTH_CONNECT or BLUETOOTH permission. These permission have to be added to the application using AudioSwitch, they do not come with the library.
112 | - The library will accurately display the up to date active bluetooth headset within the `AudioSwitch` `availableAudioDevices` and `selectedAudioDevice` functions.
113 | - Other connected headsets are not stored by the library at this moment.
114 | - In the event of a failure to connecting audio to a bluetooth headset, the library will revert the selected audio device (this is usually the Earpiece on a phone).
115 | - Additionally [BluetoothHeadsetConnectionListener](audioswitch/src/main/java/com/twilio/audioswitch/bluetooth/BluetoothHeadsetConnectionListener.kt) can be provided to AudioSwitch constructor to monitor state changes and connection failures.
116 | - If a user would like to switch between multiple Bluetooth headsets, then they need to switch the active bluetooth headset from the system Bluetooth settings.
117 | - The newly activated headset will be propagated to the `AudioSwitch` `availableAudioDevices` and `selectedAudioDevice` functions.
118 |
119 | ## Java Compatibility
120 |
121 | Audioswitch is compatible with apps written in Java that [target Java 8](https://developer.android.com/studio/write/java8-support), and follows the recommendations provided in the [Kotlin for Java consumption guide](https://developer.android.com/kotlin/interop#kotlin_for_java_consumption). The project includes [Java specific unit tests](https://github.com/twilio/audioswitch/tree/master/audioswitch/src/test/java/com/twilio/audioswitch) that demonstrate how to use Audioswitch from a Java based application. If you have any Java compatibility questions please [open an issue](https://github.com/twilio/audioswitch/issues).
122 |
123 | ## Logging
124 |
125 | By default, AudioSwitch logging is disabled. Reference the following snippet to enable AudioSwitch logging:
126 |
127 | ```kotlin
128 | val audioSwitch = AudioSwitch(context, loggingEnabled = true)
129 |
130 | audioSwitch.start { _, _ -> }
131 | ```
132 |
133 | ## Permissions
134 | On Android 12 and greater, the application using this library is expected to request the BLUETOOTH_CONNECT permission. Not doing so will disable the use of bluetooth in the audioswitch library.
135 | Pre-Android 12, no user permission requests are needed. All other permissions needed are listed in the library's manifest and are automatically merged from the [manifest file](audioswitch/src/main/AndroidManifest.xml) in this library.
136 |
137 | ## Contributing
138 |
139 | We welcome and encourage contributions to AudioSwitch! However, pull request (PR) validation requires access to credentials that we cannot provide to external contributors. As a result, the contribution process is as follows:
140 |
141 | 1. Submit a PR from a fork with your changes
142 | 1. Our team will review
143 | 1. If the changes are small enough and do not require validation (eg. documentation typo) we will merge your PR directly.
144 | 1. If the changes require integration testing, then, once approved, our team will close your PR and create a new PR from a branch on the main repository and reference your original work.
145 | 1. Our team will handle merging the final PR and releasing a new version with your changes.
146 | 1. (Optional) Submit a PR that adds you to our [CONTRIBUTORS](CONTRIBUTORS.md) file so you show up on the [contributors page](https://github.com/twilio/audioswitch/graphs/contributors).
147 |
148 | ## Usage Examples
149 |
150 | * [Twilio Video Android App](https://github.com/twilio/twilio-video-app-android)
151 | * [Twilio Video Android Quickstart](https://github.com/twilio/video-quickstart-android)
152 | * [Twilio Voice Android Quickstart](https://github.com/twilio/voice-quickstart-android)
153 |
154 | ## License
155 |
156 | Apache 2.0 license. See [LICENSE.txt](LICENSE.txt) for details.
157 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
84 |
85 | APP_NAME="Gradle"
86 | APP_BASE_NAME=${0##*/}
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | MAX_FD=$( ulimit -H -n ) ||
147 | warn "Could not query maximum file descriptor limit"
148 | esac
149 | case $MAX_FD in #(
150 | '' | soft) :;; #(
151 | *)
152 | ulimit -n "$MAX_FD" ||
153 | warn "Could not set maximum file descriptor limit to $MAX_FD"
154 | esac
155 | fi
156 |
157 | # Collect all arguments for the java command, stacking in reverse order:
158 | # * args from the command line
159 | # * the main class name
160 | # * -classpath
161 | # * -D...appname settings
162 | # * --module-path (only if needed)
163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
164 |
165 | # For Cygwin or MSYS, switch paths to Windows format before running java
166 | if "$cygwin" || "$msys" ; then
167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
169 |
170 | JAVACMD=$( cygpath --unix "$JAVACMD" )
171 |
172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
173 | for arg do
174 | if
175 | case $arg in #(
176 | -*) false ;; # don't mess with options #(
177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
178 | [ -e "$t" ] ;; #(
179 | *) false ;;
180 | esac
181 | then
182 | arg=$( cygpath --path --ignore --mixed "$arg" )
183 | fi
184 | # Roll the args list around exactly as many times as the number of
185 | # args, so each arg winds up back in the position where it started, but
186 | # possibly modified.
187 | #
188 | # NB: a `for` loop captures its iteration list before it begins, so
189 | # changing the positional parameters here affects neither the number of
190 | # iterations, nor the values presented in `arg`.
191 | shift # remove old arg
192 | set -- "$@" "$arg" # push replacement arg
193 | done
194 | fi
195 |
196 | # Collect all arguments for the java command;
197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
198 | # shell script including quotes and variable substitutions, so put them in
199 | # double quotes to make sure that they get re-expanded; and
200 | # * put everything else in single quotes, so that it's not re-expanded.
201 |
202 | set -- \
203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
204 | -classpath "$CLASSPATH" \
205 | org.gradle.wrapper.GradleWrapperMain \
206 | "$@"
207 |
208 | # Use "xargs" to parse quoted args.
209 | #
210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
211 | #
212 | # In Bash we could simply go:
213 | #
214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
215 | # set -- "${ARGS[@]}" "$@"
216 | #
217 | # but POSIX shell has neither arrays nor command substitution, so instead we
218 | # post-process each arg (as a line of input to sed) to backslash-escape any
219 | # character that might be a shell metacharacter, then use eval to reverse
220 | # that process (while maintaining the separation between arguments), and wrap
221 | # the whole thing up as a single "set" statement.
222 | #
223 | # This will of course break if any of these variables contains a newline or
224 | # an unmatched quote.
225 | #
226 |
227 | eval "set -- $(
228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
229 | xargs -n1 |
230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
231 | tr '\n' ' '
232 | )" '"$@"'
233 |
234 | exec "$JAVACMD" "$@"
235 |
--------------------------------------------------------------------------------
/audioswitch/lint-baseline.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
13 |
14 |
15 |
20 |
24 |
25 |
26 |
31 |
35 |
36 |
37 |
42 |
46 |
47 |
48 |
53 |
57 |
58 |
59 |
64 |
68 |
69 |
70 |
75 |
79 |
80 |
81 |
86 |
90 |
91 |
92 |
97 |
101 |
102 |
103 |
108 |
112 |
113 |
114 |
119 |
123 |
124 |
125 |
130 |
134 |
135 |
136 |
141 |
145 |
146 |
147 |
152 |
156 |
157 |
158 |
163 |
167 |
168 |
169 |
174 |
178 |
179 |
180 |
185 |
189 |
190 |
191 |
196 |
200 |
201 |
202 |
203 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2020 Twilio, inc.
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ### 1.2.5 (In Progress)
4 |
5 | - Upgraded kotlin to 1.8.22
6 |
7 | ### 1.2.3 (Wed Aug 27, 2025)
8 |
9 | - Updated gradle to version 8.13
10 | - Updated gradle plugin to 8.12.1
11 |
12 | ### 1.2.2 (Jun 30, 2025)
13 |
14 | - Internal test release, no changes
15 |
16 | ### 1.2.1 (Jun 30, 2025)
17 |
18 | Enhancements
19 |
20 | - AudioDeviceChangeListener is now optional parameter when calling `AudioSwitch.start(listener: AudioDeviceChangeListener? = null)`
21 | - Added `AudioSwitch.setAudioDeviceChangeListener(listener: AudioDeviceChangeListener?)`
22 | - BluetoothHeadsetManager now checks for permissions every time it is called, creates an instance if null and permissions granted
23 | - Audio device list gets refreshed upon permissions being granted
24 |
25 | ### 1.2.0 (June 3, 2024)
26 |
27 | Enhancements
28 |
29 | - Updated gradle version to 8.4
30 | - Updated gradle plugin to 8.3.1
31 | - BluetoothHeadsetConnectionListener now can be added to AudioSwitch to notify when bluetooth device has connected or failed to connect.
32 | - BLUETOOTH_CONNECT and/or BLUETOOTH permission have been removed and are optional now. For bluetooth support, permission have to be added to application using
33 | AudioSwitch library. If not provided bluetooth device will not appear in the list of available devices and no callbacks will be received for BluetoothHeadsetConnectionListener.
34 |
35 | ### 1.1.9 (July 13, 2023)
36 |
37 | Enhancements
38 |
39 | - Updated gradle version to 8.0.2
40 | - Updated gradle plugin to 8.0.2
41 |
42 | ### 1.1.8 (Mar 17, 2023)
43 |
44 | Bug Fixes
45 |
46 | - Fixed issue where some Samsung Galaxy devices (S9, S21) would not route audio through USB headset when MODE_IN_COMMUNICATION is set.
47 | - Fixed issue where IllegalStateException would be thrown when activating selected AudioDevice shortly after starting AudioSwitch.
48 | - Fixed issue where after stopping AudioSwitch while having an active Bluetooth device would result in permanent audio focus gain.
49 |
50 | ### 1.1.7 (Feb 21, 2023)
51 |
52 | Bug Fixes
53 |
54 | - Bluetooth permissions now checks for the device version in case the target version is newer
55 | - Documentation is now available again and integration tests now pass
56 | - Fixed issue where reported Bluetooth device list could be incorrect upon AudioSwitch restart
57 |
58 | ### 1.1.5 (June 17, 2022)
59 |
60 | Bug Fixes
61 |
62 | - Fixed issue with lingering EnableBluetoothSCOJob object causing spurious AudioDeviceChangeListener calls after routing switch.
63 |
64 | ### 1.1.4 (January 4, 2022)
65 |
66 | Enhancements
67 |
68 | - Dokka dependency upgraded such that documents can be generated successfully again.
69 | - Updated gradle version to 7.0.2
70 | - Updated gradle plugin to 7.0.3
71 |
72 | Bug Fixes
73 |
74 | - Fixed issue with spurious `AudioDeviceChangedListener` invocations.
75 | - Fixed issue where `InvalidStateException` would be triggered during `audioswitch.stop(..)` if bluetooth permissions were granted after 'AudioSwitch.start()`.
76 |
77 | ### 1.1.3 (November 5, 2021)
78 |
79 | Enhancements
80 |
81 | - Updated the library to support Android 12.
82 | - Updated internal dependencies related to Android 12 support.
83 | - Updated compile and target sdk to Android 12 (31).
84 | - Updated gradle to version 4.2.1.
85 | - Snapshots are now published to the Maven Central snapshots repository.
86 |
87 | ### 1.1.2 (February 24, 2021)
88 |
89 | Enhancements
90 |
91 | - Updated the library to use Android Gradle Plugin 4.1.1.
92 | - Now published to MavenCentral.
93 |
94 | ### 1.1.1 (October 20, 2020)
95 |
96 | Enhancements
97 |
98 | - Added public KDoc documentation for each release. The latest documentation release can be found at https://twilio.github.io/audioswitch/latest
99 |
100 | ### 1.1.0 (October 8, 2020)
101 |
102 | Enhancements
103 |
104 | - Added a constructor parameter named `preferredDeviceList` to configure the order in which audio devices are automatically selected and activated when `selectedAudioDevice` is null.
105 | ```kotlin
106 | val audioSwitch = AudioSwitch(application, preferredDeviceList = listOf(Speakerphone::class.java, BluetoothHeadset::class.java))
107 | ```
108 | - Updated `compileSdkVersion` and `targetSdkVersion` to Android API version `30`.
109 |
110 |
111 | ### 1.0.1 (September 11, 2020)
112 |
113 | Enhancements
114 |
115 | - Upgraded Kotlin to `1.4.0`.
116 | - Improved the Bluetooth headset connection and audio change reliability by registering the `BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED` and `BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED` intent actions instead of relying on `android.bluetooth.BluetoothDevice` and `android.media.AudioManager` intent actions.
117 | - The context provided when constructing `AudioSwitch` can now take any context. Previously the `ApplicationContext` was required.
118 |
119 | Bug Fixes
120 |
121 | - Added the internal access modifier to the `SystemClockWrapper` class since it is not meant to be exposed publicly.
122 |
123 | ### 1.0.0 (August 17, 2020)
124 |
125 | - Promotes 0.4.0 to the first stable release of this library.
126 |
127 | ### 0.4.0 (August 14, 2020)
128 |
129 | Enhancements
130 |
131 | - Added a constructor parameter to enable logging. This argument is disabled by default.
132 |
133 | ```kotlin
134 | val audioSwitch = AudioSwitch(context, loggingEnabled = true)
135 |
136 | audioSwitch.start { _, _ -> }
137 | ```
138 |
139 | - Added another constructor parameter that allows developers to subscribe to system audio focus changes while the library is activated.
140 |
141 | ```kotlin
142 | val audioSwitch = AudioSwitch(context, audioFocusChangeListener = OnAudioFocusChangeListener { focusChange ->
143 | // Do something with audio focus change
144 | })
145 |
146 | audioSwitch.start { _, _ -> }
147 | // Audio focus changes are received after activating
148 | audioSwitch.activate()
149 | ```
150 |
151 | ### 0.3.0 (August 12, 2020)
152 |
153 | Enhancements
154 |
155 | - Changed the name of the `AudioDeviceSelector` class to `AudioSwitch`.
156 | - Added the [MODIFY_AUDIO_SETTINGS](https://developer.android.com/reference/android/Manifest.permission#MODIFY_AUDIO_SETTINGS) to the library manifest so it can be automatically consumed by applications.
157 | - Added `AudioSwitch.VERSION` constant so developers can access the version of AudioSwitch at runtime.
158 | - Added `AudioSwitch.loggingEnabled` property so developers can configure AudioSwitch logging behavior at runtime. By default, AudioSwitch logging is disabled. Reference the following snippet to enable AudioSwitch logging:
159 |
160 | ```kotlin
161 | val audioSwitch = AudioSwitch(context)
162 |
163 | audioSwitch.loggingEnabled = true
164 |
165 | audioSwitch.start { _, _ -> }
166 | ```
167 |
168 | ### 0.2.1 (July 29, 2020)
169 |
170 | Bug Fixes
171 |
172 | - Fixed a bug where the audio focus wasn't being returned to the previous audio focus owner on pre Oreo devices.
173 |
174 | ### 0.2.0 (July 28, 2020)
175 |
176 | Enhancements
177 | - Added support for multiple connected bluetooth headsets.
178 | - The library will now accurately display the up to date active bluetooth headset within the `AudiodDeviceSelector` `availableAudioDevices` and `selectedAudioDevice` functions.
179 | - Other connected headsets are not stored by the library at this moment.
180 | - In the event of a failure to connecting audio to a bluetooth headset, the library will revert the selected audio device (this is usually the Earpiece on a phone).
181 | - If a user would like to switch between multiple Bluetooth headsets, then they need to switch the active bluetooth headset from the system Bluetooth settings.
182 | - The newly activated headset will be propagated to the `AudiodDeviceSelector` `availableAudioDevices` and `selectedAudioDevice` functions.
183 |
184 | Bug Fixes
185 |
186 | - Improved the accuracy of the `BluetoothHeadset` within the `availableAudioDevices` returned from the `AudioDeviceSelector` when multiple Bluetooth Headsets are connected.
187 |
188 | ### 0.1.5 (July 1, 2020)
189 |
190 | Bug Fixes
191 |
192 | - Disabled AAR minification to fix Android Studio issues such as getting stuck in code analysis and not being able to find declarations of AudioSwitch code.
193 |
194 | ### 0.1.4 (June 15, 2020)
195 |
196 | Enhancements
197 | - AAR minification is now enabled for release artifacts.
198 |
199 | Bug Fixes
200 |
201 | - Fixed a bug where the audio output doesn't automatically route to a newly connected bluetooth headset.
202 | - Fixed a bug where the selected audio device doesn't get routed back to the default audio device when an error occurs when attempting to connect to a headset.
203 |
204 | ### 0.1.3 (May 27, 2020)
205 |
206 | Bug Fixes
207 |
208 | - Fixed crash by adding a default bluetooth device name.
209 |
210 | ### 0.1.2 (May 22, 2020)
211 |
212 | Enhancements
213 |
214 | - Added the library source to release artifacts. The sources will now be available when jumping to a library class definition in Android Studio.
215 |
216 | Bug Fixes
217 |
218 | - Added a fix for certain valid bluetooth device classes not being considered as headset devices as reported in [issue #16](https://github.com/twilio/audioswitch/issues/16).
219 |
220 | ### 0.1.1 (May 19, 2020)
221 |
222 | - Fixes bug that did not correctly abandon audio request after deactivate
223 |
224 | ### 0.1.0 (April 28, 2020)
225 |
226 | This release marks the first iteration of the AudioSwitch library: an Android audio management library for real-time communication apps.
227 |
228 | This initial release comes with the following features:
229 |
230 | - Manage [audio focus](https://developer.android.com/guide/topics/media-apps/audio-focus) for typical VoIP and Video conferencing use cases.
231 | - Manage audio input and output devices.
232 | - Detect changes in available audio devices
233 | - Enumerate audio devices
234 | - Select an audio device
235 |
236 | ## Getting Started
237 |
238 | To get started using this library, follow the steps below.
239 |
240 | ### Gradle Setup
241 |
242 | [ ](https://maven-badges.herokuapp.com/maven-central/com.twilio/audioswitch)
243 |
244 | Ensure that you have `mavenCentral` listed in your project's buildscript repositories section:
245 | ```groovy
246 | buildscript {
247 | repositories {
248 | mavenCentral()
249 | // ...
250 | }
251 | }
252 | ```
253 |
254 | Add this line as a new Gradle dependency:
255 | ```groovy
256 | implementation 'com.twilio:audioswitch:$version'
257 | ```
258 |
259 | ### AudioDeviceSelector Setup
260 | Instantiate an instance of the [AudioDeviceSelector](audioswitch/src/main/java/com/twilio/audioswitch/selection/AudioDeviceSelector.kt) class, passing a reference to the application context.
261 |
262 | ```kotlin
263 | val audioDeviceSelector = AudioDeviceSelector(applicationContext)
264 | ```
265 |
266 | ### Listen for Devices
267 | To begin listening for live audio device changes, call the start function and pass a lambda that will receive [AudioDevices](audioswitch/src/main/java/com/twilio/audioswitch/selection/AudioDevice.kt) when they become available.
268 |
269 | ```kotlin
270 | audioDeviceSelector.start { audioDevices, selectedDevice ->
271 | // TODO update UI with audio devices
272 | }
273 | ```
274 | You can also retrieve the available and selected audio devices manually at any time by calling the following properties:
275 | ```kotlin
276 | val devices: List = audioDeviceSelector.availableAudioDevices
277 | val selectedDevice: AudioDevice? = audioDeviceSelector.selectedAudioDevice
278 | ```
279 | **Note:** Don't forget to stop listening for audio devices when no longer needed in order to prevent a memory leak.
280 | ```kotlin
281 | audioDeviceSelector.stop()
282 | ```
283 |
284 | ### Select a Device
285 | Before activating an AudioDevice, it needs to be selected first.
286 | ```kotlin
287 | devices.find { it is AudioDevice.Speakerphone }?.let { audioDeviceSelector.selectDevice(it) }
288 | ```
289 | If no device is selected, then the library will automatically select a device based on the following priority: `BluetoothHeadset -> WiredHeadset -> Earpiece -> Speakerphone`.
290 |
291 | ### Activate a Device
292 | Activating a device acquires audio focus with [voice communication usage](https://developer.android.com/reference/android/media/AudioAttributes#USAGE_VOICE_COMMUNICATION) and begins routing audio input/output to the selected device.
293 | ```kotlin
294 | audioDeviceSelector.activate()
295 | ```
296 | Make sure to revert back to the prior audio state when it makes sense to do so in your app.
297 | ```kotlin
298 | audioDeviceSelector.deactivate()
299 | ```
300 | **Note:** The `stop()` function will call `deactivate()` before closing AudioDeviceSelector resources.
301 |
--------------------------------------------------------------------------------
/audioswitch/src/androidTest/java/com/twilio/audioswitch/manual/ConnectedBluetoothHeadsetTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.manual
2 |
3 | import android.Manifest
4 | import android.bluetooth.BluetoothAdapter
5 | import android.bluetooth.BluetoothHeadset
6 | import android.bluetooth.BluetoothProfile
7 | import android.content.BroadcastReceiver
8 | import android.content.Context
9 | import android.content.Intent
10 | import android.content.IntentFilter
11 | import android.media.AudioManager
12 | import android.os.Build
13 | import androidx.test.ext.junit.runners.AndroidJUnit4
14 | import androidx.test.platform.app.InstrumentationRegistry
15 | import androidx.test.rule.GrantPermissionRule
16 | import com.twilio.audioswitch.AudioDevice
17 | import com.twilio.audioswitch.AudioSwitch
18 | import com.twilio.audioswitch.getInstrumentationContext
19 | import com.twilio.audioswitch.isSpeakerPhoneOn
20 | import com.twilio.audioswitch.retryAssertion
21 | import junit.framework.TestCase.assertEquals
22 | import junit.framework.TestCase.assertFalse
23 | import junit.framework.TestCase.assertNull
24 | import junit.framework.TestCase.assertTrue
25 | import org.junit.After
26 | import org.junit.Assume.assumeNotNull
27 | import org.junit.Assume.assumeTrue
28 | import org.junit.Before
29 | import org.junit.Rule
30 | import org.junit.Test
31 | import org.junit.runner.RunWith
32 | import java.util.concurrent.CountDownLatch
33 | import java.util.concurrent.TimeUnit
34 |
35 | @RunWith(AndroidJUnit4::class)
36 | class ConnectedBluetoothHeadsetTest {
37 | @Suppress("ktlint:standard:property-naming")
38 | private val BLUETOOTH_TIMEOUT: Long = 7
39 | private val bluetoothAdapter by lazy { BluetoothAdapter.getDefaultAdapter() }
40 | private val previousBluetoothEnabled by lazy { bluetoothAdapter.isEnabled }
41 | private val context by lazy { getInstrumentationContext() }
42 | private val audioSwitch by lazy { AudioSwitch(context) }
43 | private lateinit var bluetoothHeadset: BluetoothHeadset
44 | private lateinit var expectedBluetoothDevice: AudioDevice.BluetoothHeadset
45 | private val bluetoothServiceConnected = CountDownLatch(1)
46 | private val bluetoothServiceListener =
47 | object : BluetoothProfile.ServiceListener {
48 | override fun onServiceConnected(
49 | profile: Int,
50 | proxy: BluetoothProfile?,
51 | ) {
52 | bluetoothHeadset = proxy as BluetoothHeadset
53 | bluetoothServiceConnected.countDown()
54 | }
55 |
56 | override fun onServiceDisconnected(profile: Int) {
57 | }
58 | }
59 | private val bluetoothHeadsetFilter =
60 | IntentFilter().apply {
61 | addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
62 | addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
63 | addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
64 | }
65 | private val bluetoothStateConnected = CountDownLatch(1)
66 | private val bluetoothStateDisconnected = CountDownLatch(1)
67 | private val bluetoothAudioStateConnected = CountDownLatch(1)
68 | private val bluetoothReceiver =
69 | object : BroadcastReceiver() {
70 | override fun onReceive(
71 | context: Context?,
72 | intent: Intent?,
73 | ) {
74 | intent?.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED).let { state ->
75 | when (state) {
76 | BluetoothHeadset.STATE_CONNECTED -> {
77 | bluetoothStateConnected.countDown()
78 | }
79 | BluetoothHeadset.STATE_DISCONNECTED -> {
80 | bluetoothStateDisconnected.countDown()
81 | }
82 | BluetoothHeadset.STATE_AUDIO_CONNECTED -> {
83 | bluetoothAudioStateConnected.countDown()
84 | }
85 | }
86 | }
87 | intent?.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR).let { state ->
88 | when (state) {
89 | AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
90 | bluetoothAudioStateConnected.countDown()
91 | }
92 | }
93 | }
94 | }
95 | }
96 |
97 | @get:Rule
98 | val bluetoothPermissionRules: GrantPermissionRule by lazy {
99 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
100 | GrantPermissionRule.grant(Manifest.permission.BLUETOOTH_CONNECT)
101 | } else {
102 | GrantPermissionRule.grant(Manifest.permission.BLUETOOTH)
103 | }
104 | }
105 |
106 | @Before
107 | fun setup() {
108 | assumeNotNull(bluetoothAdapter)
109 | context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter)
110 | if (!previousBluetoothEnabled) {
111 | bluetoothAdapter.enable()
112 | }
113 | bluetoothAdapter.getProfileProxy(context, bluetoothServiceListener, BluetoothProfile.HEADSET)
114 | assumeTrue(bluetoothServiceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
115 | if (!previousBluetoothEnabled) {
116 | assumeTrue(bluetoothStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
117 | }
118 | assumeTrue(bluetoothHeadset.connectedDevices.size == 1)
119 | expectedBluetoothDevice = AudioDevice.BluetoothHeadset(bluetoothHeadset.connectedDevices.first().name)
120 | }
121 |
122 | @After
123 | fun teardown() {
124 | audioSwitch.deactivate()
125 | audioSwitch.stop()
126 | if (previousBluetoothEnabled) {
127 | bluetoothAdapter.enable()
128 | } else {
129 | bluetoothAdapter.disable()
130 | }
131 | if (bluetoothServiceConnected.count == 0L) {
132 | bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset)
133 | }
134 | context.unregisterReceiver(bluetoothReceiver)
135 | }
136 |
137 | @Test
138 | fun it_should_select_bluetooth_device_by_default() {
139 | val actualBluetoothDevice = startAndAwaitBluetoothDevice()
140 | assertEquals(expectedBluetoothDevice, actualBluetoothDevice)
141 | }
142 |
143 | @Test
144 | fun it_should_remove_bluetooth_device_after_disconnected() {
145 | assumeTrue(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
146 | val bluetoothDeviceConnected = CountDownLatch(1)
147 | lateinit var actualBluetoothDevice: AudioDevice
148 | var noBluetoothDeviceAvailable: CountDownLatch? = null
149 |
150 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
151 | audioSwitch.start { audioDevices, audioDevice ->
152 | audioDevices.find { it is AudioDevice.BluetoothHeadset }
153 | ?: noBluetoothDeviceAvailable?.countDown()
154 |
155 | if (audioDevice is AudioDevice.BluetoothHeadset) {
156 | actualBluetoothDevice = audioDevice
157 | bluetoothDeviceConnected.countDown()
158 | }
159 | }
160 | }
161 |
162 | assertTrue(bluetoothDeviceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
163 | assertEquals(expectedBluetoothDevice, actualBluetoothDevice)
164 |
165 | noBluetoothDeviceAvailable = CountDownLatch(1)
166 | bluetoothAdapter.disable()
167 | retryAssertion { assertFalse(bluetoothAdapter.isEnabled) }
168 | assertTrue(noBluetoothDeviceAvailable.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
169 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
170 | assertNull(audioSwitch.availableAudioDevices.find { it is AudioDevice.BluetoothHeadset })
171 | assertFalse(audioSwitch.selectedAudioDevice is AudioDevice.BluetoothHeadset)
172 | }
173 | }
174 |
175 | @Test
176 | fun it_should_allow_selecting_a_bluetooth_device() {
177 | startAndAwaitBluetoothDevice()
178 |
179 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
180 | audioSwitch.selectDevice(
181 | audioSwitch.availableAudioDevices.find {
182 | it is AudioDevice.BluetoothHeadset
183 | },
184 | )
185 | assertEquals(expectedBluetoothDevice, audioSwitch.selectedAudioDevice)
186 | }
187 | }
188 |
189 | @Test
190 | fun it_should_select_another_audio_device_with_bluetooth_device_connected() {
191 | startAndAwaitBluetoothDevice()
192 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
193 | val expectedAudioDevice =
194 | audioSwitch.availableAudioDevices.find {
195 | it !is AudioDevice.BluetoothHeadset
196 | }
197 | audioSwitch.selectDevice(expectedAudioDevice)
198 | assertEquals(expectedAudioDevice, audioSwitch.selectedAudioDevice)
199 | }
200 | }
201 |
202 | @Test
203 | fun it_should_activate_a_bluetooth_device() {
204 | startAndAwaitBluetoothDevice()
205 |
206 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
207 | audioSwitch.selectDevice(
208 | audioSwitch.availableAudioDevices.find {
209 | it is AudioDevice.BluetoothHeadset
210 | },
211 | )
212 | assertEquals(expectedBluetoothDevice, audioSwitch.selectedAudioDevice)
213 | }
214 |
215 | assertTrue(bluetoothAudioStateConnected.count > 0)
216 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
217 | audioSwitch.activate()
218 | }
219 | assertFalse(isSpeakerPhoneOn())
220 | assertTrue(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
221 | assertTrue(bluetoothHeadset.isAudioConnected(bluetoothHeadset.connectedDevices.first()))
222 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
223 | assertEquals(expectedBluetoothDevice, audioSwitch.selectedAudioDevice)
224 | }
225 | }
226 |
227 | @Test
228 | fun it_should_activate_another_audio_device_with_bluetooth_device_connected() {
229 | startAndAwaitBluetoothDevice()
230 |
231 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
232 | val expectedAudioDevice = audioSwitch.availableAudioDevices.find { it !is AudioDevice.BluetoothHeadset }
233 | audioSwitch.selectDevice(expectedAudioDevice)
234 | assertEquals(expectedAudioDevice, audioSwitch.selectedAudioDevice)
235 | }
236 |
237 | assertTrue(bluetoothAudioStateConnected.count > 0)
238 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
239 | audioSwitch.activate()
240 | }
241 | assertFalse(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
242 | assertFalse(bluetoothHeadset.isAudioConnected(bluetoothHeadset.connectedDevices.first()))
243 |
244 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
245 | assertTrue(audioSwitch.selectedAudioDevice !is AudioDevice.BluetoothHeadset)
246 | }
247 | }
248 |
249 | @Test
250 | fun it_should_automatically_activate_bluetooth_device_if_no_device_selected() {
251 | assumeTrue(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
252 | bluetoothAdapter.disable()
253 | retryAssertion { assertFalse(bluetoothAdapter.isEnabled) }
254 | val bluetoothDeviceConnected = CountDownLatch(1)
255 | lateinit var actualBluetoothDevice: AudioDevice
256 |
257 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
258 | audioSwitch.start { _, audioDevice ->
259 | if (audioDevice is AudioDevice.BluetoothHeadset) {
260 | actualBluetoothDevice = audioDevice
261 | bluetoothDeviceConnected.countDown()
262 | }
263 | }
264 | audioSwitch.activate()
265 | assertTrue(audioSwitch.selectedAudioDevice !is AudioDevice.BluetoothHeadset)
266 | }
267 | assertTrue(bluetoothAudioStateConnected.count > 0)
268 | assertFalse(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
269 | assertTrue(bluetoothHeadset.connectedDevices.isEmpty())
270 | bluetoothAdapter.enable()
271 | assertTrue(bluetoothDeviceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
272 | assertEquals(expectedBluetoothDevice, actualBluetoothDevice)
273 | assertTrue(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
274 | assertTrue(bluetoothHeadset.isAudioConnected(bluetoothHeadset.connectedDevices.first()))
275 | }
276 |
277 | private fun startAndAwaitBluetoothDevice(): AudioDevice {
278 | val bluetoothDeviceConnected = CountDownLatch(1)
279 | lateinit var actualBluetoothDevice: AudioDevice
280 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
281 | audioSwitch.start { _, audioDevice ->
282 | if (audioDevice is AudioDevice.BluetoothHeadset) {
283 | actualBluetoothDevice = audioDevice
284 | bluetoothDeviceConnected.countDown()
285 | }
286 | }
287 | }
288 |
289 | assertTrue(bluetoothDeviceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS))
290 |
291 | return actualBluetoothDevice
292 | }
293 | }
294 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/bluetooth/BluetoothHeadsetManager.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.bluetooth
2 |
3 | import android.annotation.SuppressLint
4 | import android.bluetooth.BluetoothAdapter
5 | import android.bluetooth.BluetoothClass
6 | import android.bluetooth.BluetoothHeadset
7 | import android.bluetooth.BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED
8 | import android.bluetooth.BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED
9 | import android.bluetooth.BluetoothHeadset.STATE_AUDIO_CONNECTED
10 | import android.bluetooth.BluetoothHeadset.STATE_AUDIO_DISCONNECTED
11 | import android.bluetooth.BluetoothHeadset.STATE_CONNECTED
12 | import android.bluetooth.BluetoothHeadset.STATE_DISCONNECTED
13 | import android.bluetooth.BluetoothProfile
14 | import android.content.BroadcastReceiver
15 | import android.content.Context
16 | import android.content.Intent
17 | import android.content.IntentFilter
18 | import android.media.AudioManager
19 | import android.media.AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED
20 | import android.media.AudioManager.SCO_AUDIO_STATE_CONNECTED
21 | import android.media.AudioManager.SCO_AUDIO_STATE_CONNECTING
22 | import android.media.AudioManager.SCO_AUDIO_STATE_DISCONNECTED
23 | import android.os.Handler
24 | import android.os.Looper
25 | import androidx.annotation.VisibleForTesting
26 | import com.twilio.audioswitch.AudioDevice
27 | import com.twilio.audioswitch.AudioDeviceManager
28 | import com.twilio.audioswitch.android.BluetoothDeviceWrapper
29 | import com.twilio.audioswitch.android.BluetoothIntentProcessor
30 | import com.twilio.audioswitch.android.BluetoothIntentProcessorImpl
31 | import com.twilio.audioswitch.android.Logger
32 | import com.twilio.audioswitch.android.SystemClockWrapper
33 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivated
34 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivating
35 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivationError
36 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Connected
37 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Disconnected
38 |
39 | private const val TAG = "BluetoothHeadsetManager"
40 |
41 | internal class BluetoothHeadsetManager
42 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
43 | internal constructor(
44 | private val context: Context,
45 | private val logger: Logger,
46 | private val bluetoothAdapter: BluetoothAdapter,
47 | audioDeviceManager: AudioDeviceManager,
48 | var headsetListener: BluetoothHeadsetConnectionListener? = null,
49 | bluetoothScoHandler: Handler = Handler(Looper.getMainLooper()),
50 | systemClockWrapper: SystemClockWrapper = SystemClockWrapper(),
51 | private val bluetoothIntentProcessor: BluetoothIntentProcessor = BluetoothIntentProcessorImpl(),
52 | private var headsetProxy: BluetoothHeadset? = null,
53 | private var hasRegisteredReceivers: Boolean = false,
54 | ) : BroadcastReceiver(),
55 | BluetoothProfile.ServiceListener {
56 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
57 | internal var headsetState: HeadsetState = Disconnected
58 | set(value) {
59 | if (field != value) {
60 | field = value
61 | logger.d(TAG, "Headset state changed to ${field::class.simpleName}")
62 | if (value == Disconnected) enableBluetoothScoJob.cancelBluetoothScoJob()
63 | }
64 | }
65 |
66 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
67 | internal val enableBluetoothScoJob: EnableBluetoothScoJob =
68 | EnableBluetoothScoJob(
69 | logger,
70 | audioDeviceManager,
71 | bluetoothScoHandler,
72 | systemClockWrapper,
73 | )
74 |
75 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
76 | internal val disableBluetoothScoJob: DisableBluetoothScoJob =
77 | DisableBluetoothScoJob(
78 | logger,
79 | audioDeviceManager,
80 | bluetoothScoHandler,
81 | systemClockWrapper,
82 | )
83 |
84 | companion object {
85 | internal fun newInstance(
86 | context: Context,
87 | logger: Logger,
88 | bluetoothAdapter: BluetoothAdapter?,
89 | audioDeviceManager: AudioDeviceManager,
90 | ): BluetoothHeadsetManager? =
91 | bluetoothAdapter?.let { adapter ->
92 | BluetoothHeadsetManager(context, logger, adapter, audioDeviceManager)
93 | } ?: run {
94 | logger.d(TAG, "Bluetooth is not supported on this device")
95 | null
96 | }
97 | }
98 |
99 | @SuppressLint("MissingPermission")
100 | override fun onServiceConnected(
101 | profile: Int,
102 | bluetoothProfile: BluetoothProfile,
103 | ) {
104 | headsetProxy = bluetoothProfile as BluetoothHeadset
105 | bluetoothProfile.connectedDevices.forEach { device ->
106 | logger.d(TAG, "Bluetooth " + device.name + " connected")
107 | }
108 | if (hasConnectedDevice()) {
109 | connect()
110 | headsetListener?.onBluetoothHeadsetStateChanged(getHeadsetName())
111 | }
112 | }
113 |
114 | override fun onServiceDisconnected(profile: Int) {
115 | logger.d(TAG, "Bluetooth disconnected")
116 | headsetState = Disconnected
117 | headsetListener?.onBluetoothHeadsetStateChanged()
118 | }
119 |
120 | override fun onReceive(
121 | context: Context,
122 | intent: Intent,
123 | ) {
124 | if (isCorrectIntentAction(intent.action)) {
125 | intent.getHeadsetDevice()?.let { bluetoothDevice ->
126 | intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, STATE_DISCONNECTED).let { state ->
127 | when (state) {
128 | STATE_CONNECTED -> {
129 | logger.d(
130 | TAG,
131 | "Bluetooth headset $bluetoothDevice connected",
132 | )
133 | connect()
134 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_CONNECTED)
135 | }
136 | STATE_DISCONNECTED -> {
137 | logger.d(
138 | TAG,
139 | "Bluetooth headset $bluetoothDevice disconnected",
140 | )
141 | disconnect()
142 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_DISCONNECTED)
143 | }
144 | STATE_AUDIO_CONNECTED -> {
145 | logger.d(TAG, "Bluetooth audio connected on device $bluetoothDevice")
146 | enableBluetoothScoJob.cancelBluetoothScoJob()
147 | headsetState = AudioActivated
148 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_AUDIO_CONNECTED)
149 | }
150 | STATE_AUDIO_DISCONNECTED -> {
151 | logger.d(TAG, "Bluetooth audio disconnected on device $bluetoothDevice")
152 | disableBluetoothScoJob.cancelBluetoothScoJob()
153 | /*
154 | * This block is needed to restart bluetooth SCO in the event that
155 | * the active bluetooth headset has changed.
156 | */
157 | if (hasActiveHeadsetChanged()) {
158 | enableBluetoothScoJob.executeBluetoothScoJob()
159 | }
160 |
161 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_AUDIO_DISCONNECTED)
162 | }
163 | else -> {}
164 | }
165 | }
166 | }
167 | intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, SCO_AUDIO_STATE_DISCONNECTED).let { state ->
168 | when (state) {
169 | SCO_AUDIO_STATE_CONNECTING -> {
170 | logger.d(
171 | TAG,
172 | "Bluetooth SCO connecting",
173 | )
174 |
175 | headsetListener?.onBluetoothScoStateChanged(
176 | SCO_AUDIO_STATE_CONNECTING,
177 | )
178 | }
179 | SCO_AUDIO_STATE_CONNECTED -> {
180 | logger.d(
181 | TAG,
182 | "Bluetooth SCO connected",
183 | )
184 |
185 | headsetListener?.onBluetoothScoStateChanged(
186 | SCO_AUDIO_STATE_CONNECTED,
187 | )
188 | }
189 | SCO_AUDIO_STATE_DISCONNECTED -> {
190 | logger.d(
191 | TAG,
192 | "Bluetooth SCO disconnected",
193 | )
194 |
195 | headsetListener?.onBluetoothScoStateChanged(
196 | SCO_AUDIO_STATE_DISCONNECTED,
197 | )
198 | }
199 | else -> {}
200 | }
201 | }
202 | }
203 | }
204 |
205 | fun start(headsetListener: BluetoothHeadsetConnectionListener) {
206 | this.headsetListener = headsetListener
207 |
208 | bluetoothAdapter.getProfileProxy(
209 | context,
210 | this,
211 | BluetoothProfile.HEADSET,
212 | )
213 | if (!hasRegisteredReceivers) {
214 | context.registerReceiver(
215 | this,
216 | IntentFilter(ACTION_CONNECTION_STATE_CHANGED),
217 | )
218 | context.registerReceiver(
219 | this,
220 | IntentFilter(ACTION_AUDIO_STATE_CHANGED),
221 | )
222 | context.registerReceiver(
223 | this,
224 | IntentFilter(ACTION_SCO_AUDIO_STATE_UPDATED),
225 | )
226 | hasRegisteredReceivers = true
227 | }
228 | }
229 |
230 | fun stop() {
231 | headsetListener = null
232 | bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy)
233 | if (hasRegisteredReceivers) {
234 | context.unregisterReceiver(this)
235 | hasRegisteredReceivers = false
236 | }
237 | }
238 |
239 | fun activate() {
240 | if (headsetState == Connected || headsetState == AudioActivationError) {
241 | enableBluetoothScoJob.executeBluetoothScoJob()
242 | } else {
243 | logger.w(TAG, "Cannot activate when in the ${headsetState::class.simpleName} state")
244 | }
245 | }
246 |
247 | fun deactivate() {
248 | if (headsetState == AudioActivated) {
249 | disableBluetoothScoJob.executeBluetoothScoJob()
250 | } else {
251 | logger.w(TAG, "Cannot deactivate when in the ${headsetState::class.simpleName} state")
252 | }
253 | }
254 |
255 | fun hasActivationError(): Boolean = headsetState == AudioActivationError
256 |
257 | // TODO Remove bluetoothHeadsetName param
258 | fun getHeadset(bluetoothHeadsetName: String?): AudioDevice.BluetoothHeadset? =
259 | if (headsetState != Disconnected) {
260 | val headsetName = bluetoothHeadsetName ?: getHeadsetName()
261 | headsetName?.let { AudioDevice.BluetoothHeadset(it) }
262 | ?: AudioDevice.BluetoothHeadset()
263 | } else {
264 | null
265 | }
266 |
267 | private fun isCorrectIntentAction(intentAction: String?) =
268 | intentAction == ACTION_CONNECTION_STATE_CHANGED ||
269 | intentAction == ACTION_AUDIO_STATE_CHANGED ||
270 | intentAction == ACTION_SCO_AUDIO_STATE_UPDATED
271 |
272 | private fun connect() {
273 | if (!hasActiveHeadset()) headsetState = Connected
274 | }
275 |
276 | private fun disconnect() {
277 | headsetState =
278 | when {
279 | hasActiveHeadset() -> {
280 | AudioActivated
281 | }
282 | hasConnectedDevice() -> {
283 | Connected
284 | }
285 | else -> {
286 | Disconnected
287 | }
288 | }
289 | }
290 |
291 | private fun hasActiveHeadsetChanged() = headsetState == AudioActivated && hasConnectedDevice() && !hasActiveHeadset()
292 |
293 | @SuppressLint("MissingPermission")
294 | private fun getHeadsetName(): String? =
295 | headsetProxy?.let { proxy ->
296 | proxy.connectedDevices?.let { devices ->
297 | when {
298 | devices.size > 1 && hasActiveHeadset() -> {
299 | val device = devices.find { proxy.isAudioConnected(it) }?.name
300 | logger.d(TAG, "Device size > 1 with device name: $device")
301 | device
302 | }
303 | devices.size == 1 -> {
304 | val device = devices.first().name
305 | logger.d(TAG, "Device size 1 with device name: $device")
306 | device
307 | }
308 | else -> {
309 | logger.d(TAG, "Device size 0")
310 | null
311 | }
312 | }
313 | }
314 | }
315 |
316 | @SuppressLint("MissingPermission")
317 | private fun hasActiveHeadset() =
318 | headsetProxy?.let { proxy ->
319 | proxy.connectedDevices?.let { devices ->
320 | devices.any { proxy.isAudioConnected(it) }
321 | }
322 | } ?: false
323 |
324 | @SuppressLint("MissingPermission")
325 | private fun hasConnectedDevice() =
326 | headsetProxy?.let { proxy ->
327 | proxy.connectedDevices?.isNotEmpty()
328 | } ?: false
329 |
330 | private fun Intent.getHeadsetDevice(): BluetoothDeviceWrapper? =
331 | bluetoothIntentProcessor.getBluetoothDevice(this)?.let { device ->
332 | if (isHeadsetDevice(device)) device else null
333 | }
334 |
335 | private fun isHeadsetDevice(deviceWrapper: BluetoothDeviceWrapper): Boolean =
336 | deviceWrapper.deviceClass?.let { deviceClass ->
337 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE ||
338 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_WEARABLE_HEADSET ||
339 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO ||
340 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_HEADPHONES ||
341 | deviceClass == BluetoothClass.Device.Major.UNCATEGORIZED
342 | } ?: false
343 |
344 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
345 | internal sealed class HeadsetState {
346 | object Disconnected : HeadsetState()
347 |
348 | object Connected : HeadsetState()
349 |
350 | object AudioActivating : HeadsetState()
351 |
352 | object AudioActivationError : HeadsetState()
353 |
354 | object AudioActivated : HeadsetState()
355 | }
356 |
357 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
358 | internal inner class EnableBluetoothScoJob(
359 | private val logger: Logger,
360 | private val audioDeviceManager: AudioDeviceManager,
361 | bluetoothScoHandler: Handler,
362 | systemClockWrapper: SystemClockWrapper,
363 | ) : BluetoothScoJob(logger, bluetoothScoHandler, systemClockWrapper) {
364 | override fun scoAction() {
365 | logger.d(TAG, "Attempting to enable bluetooth SCO")
366 | audioDeviceManager.enableBluetoothSco(true)
367 | headsetState = AudioActivating
368 | }
369 |
370 | override fun scoTimeOutAction() {
371 | headsetState = AudioActivationError
372 | headsetListener?.onBluetoothHeadsetActivationError()
373 | }
374 | }
375 |
376 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
377 | internal inner class DisableBluetoothScoJob(
378 | private val logger: Logger,
379 | private val audioDeviceManager: AudioDeviceManager,
380 | bluetoothScoHandler: Handler,
381 | systemClockWrapper: SystemClockWrapper,
382 | ) : BluetoothScoJob(logger, bluetoothScoHandler, systemClockWrapper) {
383 | override fun scoAction() {
384 | logger.d(TAG, "Attempting to disable bluetooth SCO")
385 | audioDeviceManager.enableBluetoothSco(false)
386 | headsetState = Connected
387 | }
388 |
389 | override fun scoTimeOutAction() {
390 | headsetState = AudioActivationError
391 | }
392 | }
393 | }
394 |
--------------------------------------------------------------------------------
/audioswitch/src/test/java/com/twilio/audioswitch/bluetooth/BluetoothHeadsetManagerTest.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch.bluetooth
2 |
3 | import android.bluetooth.BluetoothClass
4 | import android.bluetooth.BluetoothDevice
5 | import android.bluetooth.BluetoothHeadset
6 | import android.content.Intent
7 | import android.os.Handler
8 | import com.twilio.audioswitch.BaseTest
9 | import com.twilio.audioswitch.DEVICE_NAME
10 | import com.twilio.audioswitch.assertBluetoothHeadsetSetup
11 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivated
12 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivating
13 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivationError
14 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Connected
15 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Disconnected
16 | import junitparams.JUnitParamsRunner
17 | import junitparams.Parameters
18 | import org.hamcrest.CoreMatchers.equalTo
19 | import org.hamcrest.CoreMatchers.`is`
20 | import org.hamcrest.CoreMatchers.nullValue
21 | import org.hamcrest.MatcherAssert.assertThat
22 | import org.junit.Before
23 | import org.junit.Test
24 | import org.junit.runner.RunWith
25 | import org.mockito.kotlin.any
26 | import org.mockito.kotlin.isA
27 | import org.mockito.kotlin.mock
28 | import org.mockito.kotlin.times
29 | import org.mockito.kotlin.verify
30 | import org.mockito.kotlin.verifyNoInteractions
31 | import org.mockito.kotlin.whenever
32 |
33 | @RunWith(JUnitParamsRunner::class)
34 | class BluetoothHeadsetManagerTest : BaseTest() {
35 | private val headsetListener = mock()
36 | private val bluetoothDevices = listOf(expectedBluetoothDevice)
37 |
38 | @Before
39 | fun setUp() {
40 | initializeManagerWithMocks()
41 | }
42 |
43 | @Test
44 | fun `onServiceConnected should notify the deviceListener if there are connected devices`() {
45 | setupConnectedState()
46 |
47 | verify(headsetListener).onBluetoothHeadsetStateChanged(DEVICE_NAME)
48 | }
49 |
50 | @Test
51 | fun `onServiceConnected should set the headset state to Connected if there are connected devices`() {
52 | setupConnectedState()
53 |
54 | assertThat(headsetManager.headsetState is Connected, equalTo(true))
55 | }
56 |
57 | @Test
58 | fun `onServiceConnected should not notify the deviceListener if the deviceListener is null`() {
59 | headsetManager.headsetListener = null
60 | setupConnectedState()
61 |
62 | verifyNoInteractions(headsetListener)
63 | }
64 |
65 | @Test
66 | fun `onServiceConnected should not notify the deviceListener if there are no connected bluetooth headsets`() {
67 | val bluetoothProfile =
68 | mock {
69 | whenever(mock.connectedDevices).thenReturn(emptyList())
70 | }
71 |
72 | headsetManager.onServiceConnected(0, bluetoothProfile)
73 |
74 | verifyNoInteractions(headsetListener)
75 | }
76 |
77 | @Test
78 | fun `onServiceDisconnected should notify the deviceListener`() {
79 | headsetManager.onServiceDisconnected(0)
80 |
81 | verify(headsetListener).onBluetoothHeadsetStateChanged()
82 | }
83 |
84 | @Test
85 | fun `onServiceDisconnected should set the headset state to Disconnected`() {
86 | setupConnectedState()
87 | headsetManager.onServiceDisconnected(0)
88 |
89 | assertThat(headsetManager.headsetState is Disconnected, equalTo(true))
90 | }
91 |
92 | @Test
93 | fun `onServiceDisconnected should not notify the deviceListener if deviceListener is null`() {
94 | headsetManager.headsetListener = null
95 | headsetManager.onServiceDisconnected(0)
96 |
97 | verifyNoInteractions(headsetListener)
98 | }
99 |
100 | @Test
101 | fun `stop should close close all resources`() {
102 | val deviceListener = mock()
103 | headsetManager.start(deviceListener)
104 | headsetManager.stop()
105 |
106 | assertBluetoothHeadsetTeardown()
107 | }
108 |
109 | @Test
110 | fun `start should successfully setup headset manager`() {
111 | val deviceListener = mock()
112 | headsetManager.start(deviceListener)
113 |
114 | assertBluetoothHeadsetSetup()
115 | }
116 |
117 | @Test
118 | fun `activate should start bluetooth device audio routing if state is Connected`() {
119 | headsetManager.headsetState = Connected
120 | headsetManager.activate()
121 |
122 | verify(audioManager).startBluetoothSco()
123 | }
124 |
125 | @Test
126 | fun `activate should start bluetooth device audio routing if state is AudioActivationError`() {
127 | headsetManager.headsetState = AudioActivationError
128 |
129 | headsetManager.activate()
130 |
131 | verify(audioManager).startBluetoothSco()
132 | }
133 |
134 | @Test
135 | fun `activate should not start bluetooth device audio routing if state is Disconnected`() {
136 | headsetManager.headsetState = Disconnected
137 |
138 | headsetManager.activate()
139 |
140 | verifyNoInteractions(audioManager)
141 | }
142 |
143 | @Test
144 | fun `deactivate should stop bluetooth device audio routing`() {
145 | headsetManager.headsetState = AudioActivated
146 |
147 | headsetManager.deactivate()
148 |
149 | verify(audioManager).stopBluetoothSco()
150 | }
151 |
152 | @Test
153 | fun `deactivate should not stop bluetooth device audio routing if state is AudioActivating`() {
154 | headsetManager.headsetState = AudioActivating
155 |
156 | headsetManager.deactivate()
157 |
158 | verifyNoInteractions(audioManager)
159 | }
160 |
161 | fun parameters(): Array> {
162 | val handsFreeDevice =
163 | mock {
164 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE)
165 | }
166 | val audioVideoHeadsetDevice =
167 | mock {
168 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_WEARABLE_HEADSET)
169 | }
170 | val audioVideoCarDevice =
171 | mock {
172 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO)
173 | }
174 | val headphonesDevice =
175 | mock {
176 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_HEADPHONES)
177 | }
178 | val uncategorizedDevice =
179 | mock {
180 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.Major.UNCATEGORIZED)
181 | }
182 | val wrongDevice =
183 | mock {
184 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_VIDEO_MONITOR)
185 | }
186 | return arrayOf(
187 | arrayOf(handsFreeDevice, true),
188 | arrayOf(audioVideoHeadsetDevice, true),
189 | arrayOf(audioVideoCarDevice, true),
190 | arrayOf(headphonesDevice, true),
191 | arrayOf(uncategorizedDevice, true),
192 | arrayOf(wrongDevice, false),
193 | arrayOf(null, false),
194 | )
195 | }
196 |
197 | @Parameters(method = "parameters")
198 | @Test
199 | fun `onReceive should register a new device when a headset connection event is received`(
200 | deviceClass: BluetoothClass?,
201 | isNewDeviceConnected: Boolean,
202 | ) {
203 | whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(deviceClass)
204 | val intent =
205 | mock {
206 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
207 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
208 | .thenReturn(BluetoothHeadset.STATE_CONNECTED)
209 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
210 | .thenReturn(expectedBluetoothDevice)
211 | }
212 | headsetManager.onReceive(context, intent)
213 |
214 | val invocationCount = if (isNewDeviceConnected) 1 else 0
215 | verify(headsetListener, times(invocationCount)).onBluetoothHeadsetStateChanged(DEVICE_NAME, BluetoothHeadset.STATE_CONNECTED)
216 | }
217 |
218 | @Parameters(method = "parameters")
219 | @Test
220 | fun `onReceive should disconnect a device when a headset disconnection event is received`(
221 | deviceClass: BluetoothClass?,
222 | isDeviceDisconnected: Boolean,
223 | ) {
224 | whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(deviceClass)
225 | val intent =
226 | mock {
227 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
228 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
229 | .thenReturn(BluetoothHeadset.STATE_DISCONNECTED)
230 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
231 | .thenReturn(expectedBluetoothDevice)
232 | }
233 | headsetManager.onReceive(context, intent)
234 |
235 | val invocationCount = if (isDeviceDisconnected) 1 else 0
236 | verify(headsetListener, times(invocationCount)).onBluetoothHeadsetStateChanged(headsetName = "Bluetooth")
237 | }
238 |
239 | @Test
240 | fun `onReceive should trigger once for sco disconnect when an ACL connected event is received with a null bluetooth device`() {
241 | whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(null)
242 | simulateNewBluetoothHeadsetConnection()
243 |
244 | verify(headsetListener, times(1)).onBluetoothScoStateChanged(0)
245 | }
246 |
247 | @Test
248 | fun `onReceive should not register a new device when the deviceListener is null`() {
249 | headsetManager.headsetListener = null
250 | simulateNewBluetoothHeadsetConnection()
251 |
252 | verifyNoInteractions(headsetListener)
253 | }
254 |
255 | @Test
256 | fun `onReceive should not disconnect a device when an ACL disconnected event is received with a null bluetooth device`() {
257 | val intent =
258 | mock {
259 | whenever(mock.action).thenReturn(BluetoothDevice.ACTION_ACL_DISCONNECTED)
260 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
261 | .thenReturn(null)
262 | }
263 |
264 | headsetManager.onReceive(mock(), intent)
265 |
266 | verifyNoInteractions(headsetListener)
267 | }
268 |
269 | @Test
270 | fun `onReceive should not disconnect a device when the deviceListener is null`() {
271 | headsetManager.headsetListener = null
272 | val intent =
273 | mock {
274 | whenever(mock.action).thenReturn(BluetoothDevice.ACTION_ACL_DISCONNECTED)
275 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
276 | .thenReturn(expectedBluetoothDevice)
277 | }
278 |
279 | headsetManager.onReceive(mock(), intent)
280 |
281 | verifyNoInteractions(headsetListener)
282 | }
283 |
284 | @Test
285 | fun `onReceive should receive no headset listener callbacks if the intent action is null`() {
286 | headsetManager.onReceive(mock(), mock())
287 |
288 | verifyNoInteractions(headsetListener)
289 | }
290 |
291 | @Test
292 | fun `a headset audio connection should cancel a running enableBluetoothScoJob`() {
293 | setupConnectedState()
294 | headsetManager.activate()
295 | val intent =
296 | mock {
297 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
298 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
299 | .thenReturn(BluetoothHeadset.STATE_AUDIO_CONNECTED)
300 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
301 | .thenReturn(expectedBluetoothDevice)
302 | }
303 | headsetManager.onReceive(context, intent)
304 |
305 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob)
306 | }
307 |
308 | @Test
309 | fun `a bluetooth headset audio disconnection should cancel a running disableBluetoothScoJob`() {
310 | headsetManager.headsetState = AudioActivated
311 | headsetManager.deactivate()
312 | val intent =
313 | mock {
314 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
315 | whenever(
316 | mock.getIntExtra(
317 | BluetoothHeadset.EXTRA_STATE,
318 | BluetoothHeadset.STATE_DISCONNECTED,
319 | ),
320 | ).thenReturn(BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
321 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
322 | .thenReturn(expectedBluetoothDevice)
323 | }
324 | headsetManager.onReceive(mock(), intent)
325 |
326 | assertScoJobIsCanceled(handler, headsetManager.disableBluetoothScoJob)
327 | }
328 |
329 | @Test
330 | fun `EnableBluetoothScoJob scoTimeOutAction should set the state to AudioActivationError`() {
331 | systemClockWrapper =
332 | mock {
333 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT)
334 | }
335 | handler = setupHandlerMock()
336 | initializeManagerWithMocks()
337 | headsetManager.headsetState = Connected
338 | headsetManager.activate()
339 |
340 | assertThat(headsetManager.headsetState is AudioActivationError, equalTo(true))
341 | }
342 |
343 | @Test
344 | fun `EnableBluetoothScoJob scoTimeOutAction should invoke the headset listener`() {
345 | systemClockWrapper =
346 | mock {
347 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT)
348 | }
349 | handler = setupHandlerMock()
350 | initializeManagerWithMocks()
351 | headsetManager.headsetState = Connected
352 | headsetManager.activate()
353 |
354 | verify(headsetListener).onBluetoothHeadsetActivationError()
355 | }
356 |
357 | @Test
358 | fun `EnableBluetoothScoJob scoTimeOutAction should not invoke the headset listener if it is null`() {
359 | systemClockWrapper =
360 | mock {
361 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT)
362 | }
363 | handler = setupHandlerMock()
364 | headsetManager =
365 | BluetoothHeadsetManager(
366 | context,
367 | logger,
368 | bluetoothAdapter,
369 | audioDeviceManager,
370 | bluetoothScoHandler = handler,
371 | systemClockWrapper = systemClockWrapper,
372 | headsetProxy = headsetProxy,
373 | )
374 |
375 | headsetManager.headsetState = Connected
376 | headsetManager.activate()
377 |
378 | verifyNoInteractions(headsetListener)
379 | }
380 |
381 | @Test
382 | fun `BluetoothScoRunnable should execute enableBluetoothSco multiple times if not canceled`() {
383 | handler =
384 | mock {
385 | whenever(mock.post(any())).thenAnswer {
386 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run()
387 | true
388 | }
389 |
390 | var firstInvocation = true
391 | whenever(mock.postDelayed(isA(), isA())).thenAnswer {
392 | if (firstInvocation) {
393 | firstInvocation = false
394 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run()
395 | }
396 | true
397 | }
398 | }
399 | initializeManagerWithMocks()
400 | headsetManager.headsetState = Connected
401 | headsetManager.activate()
402 |
403 | verify(audioManager, times(2)).startBluetoothSco()
404 | }
405 |
406 | @Test
407 | fun `BluetoothScoRunnable should timeout if elapsedTime equals the time limit`() {
408 | systemClockWrapper =
409 | mock {
410 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT)
411 | }
412 | handler = setupHandlerMock()
413 | initializeManagerWithMocks()
414 | headsetManager.headsetState = Connected
415 | headsetManager.activate()
416 |
417 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob)
418 | }
419 |
420 | @Test
421 | fun `BluetoothScoRunnable should timeout if elapsedTime is greater than the time limit`() {
422 | systemClockWrapper =
423 | mock {
424 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT + 1000)
425 | }
426 | handler = setupHandlerMock()
427 | initializeManagerWithMocks()
428 | headsetManager.headsetState = Connected
429 | headsetManager.activate()
430 |
431 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob)
432 | }
433 |
434 | @Test
435 | fun `cancelBluetoothScoJob should not cancel sco runnable if it has not been initialized`() {
436 | headsetManager.enableBluetoothScoJob.cancelBluetoothScoJob()
437 |
438 | verifyNoInteractions(handler)
439 | }
440 |
441 | @Test
442 | fun `it should cancel the enable bluetooth sco job when setting the state to disconnected`() {
443 | val bluetoothProfile =
444 | mock {
445 | whenever(mock.connectedDevices).thenReturn(bluetoothDevices, bluetoothDevices, emptyList())
446 | }
447 | headsetManager.onServiceConnected(0, bluetoothProfile)
448 | headsetManager.activate()
449 |
450 | val intent =
451 | mock {
452 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
453 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
454 | .thenReturn(BluetoothHeadset.STATE_DISCONNECTED)
455 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
456 | .thenReturn(expectedBluetoothDevice)
457 | }
458 | headsetManager.onReceive(context, intent)
459 |
460 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob)
461 | }
462 |
463 | private fun setupHandlerMock() =
464 | mock {
465 | whenever(mock.post(any())).thenAnswer {
466 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run()
467 | true
468 | }
469 |
470 | whenever(mock.postDelayed(isA(), isA())).thenAnswer {
471 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run()
472 | true
473 | }
474 | }
475 |
476 | private fun setupConnectedState() {
477 | val bluetoothProfile =
478 | mock {
479 | whenever(mock.connectedDevices).thenReturn(bluetoothDevices)
480 | }
481 | headsetManager.onServiceConnected(0, bluetoothProfile)
482 | }
483 |
484 | private fun assertScoJobIsCanceled(
485 | handler: Handler,
486 | scoJob: BluetoothScoJob,
487 | ) {
488 | verify(handler).removeCallbacks(isA())
489 | assertThat(scoJob.bluetoothScoRunnable, `is`(nullValue()))
490 | }
491 |
492 | private fun initializeManagerWithMocks() {
493 | headsetManager =
494 | BluetoothHeadsetManager(
495 | context,
496 | logger,
497 | bluetoothAdapter,
498 | audioDeviceManager,
499 | headsetListener,
500 | handler,
501 | systemClockWrapper,
502 | headsetProxy = headsetProxy,
503 | )
504 | }
505 | }
506 |
--------------------------------------------------------------------------------
/audioswitch/src/main/java/com/twilio/audioswitch/AudioSwitch.kt:
--------------------------------------------------------------------------------
1 | package com.twilio.audioswitch
2 |
3 | import android.Manifest
4 | import android.annotation.SuppressLint
5 | import android.app.Activity
6 | import android.app.Application
7 | import android.bluetooth.BluetoothManager
8 | import android.content.Context
9 | import android.content.pm.PackageManager.PERMISSION_GRANTED
10 | import android.media.AudioManager
11 | import android.media.AudioManager.OnAudioFocusChangeListener
12 | import android.os.Bundle
13 | import androidx.annotation.VisibleForTesting
14 | import com.twilio.audioswitch.AudioDevice.BluetoothHeadset
15 | import com.twilio.audioswitch.AudioDevice.Earpiece
16 | import com.twilio.audioswitch.AudioDevice.Speakerphone
17 | import com.twilio.audioswitch.AudioDevice.WiredHeadset
18 | import com.twilio.audioswitch.AudioSwitch.State.ACTIVATED
19 | import com.twilio.audioswitch.AudioSwitch.State.STARTED
20 | import com.twilio.audioswitch.AudioSwitch.State.STOPPED
21 | import com.twilio.audioswitch.android.Logger
22 | import com.twilio.audioswitch.android.PermissionsCheckStrategy
23 | import com.twilio.audioswitch.android.ProductionLogger
24 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
25 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager
26 | import com.twilio.audioswitch.wired.WiredDeviceConnectionListener
27 | import com.twilio.audioswitch.wired.WiredHeadsetReceiver
28 |
29 | private const val TAG = "AudioSwitch"
30 | private const val PERMISSION_ERROR_MESSAGE = "Bluetooth unsupported, permissions not granted"
31 |
32 | /**
33 | * This class enables developers to enumerate available audio devices and select which device audio
34 | * should be routed to. It is strongly recommended that instances of this class are created and
35 | * accessed from a single application thread. Accessing an instance from multiple threads may cause
36 | * synchronization problems.
37 | *
38 | * @property bluetoothHeadsetConnectionListener Requires bluetooth permission. Listener to notify if Bluetooth device state has
39 | * changed (connect, disconnect, audio connect, audio disconnect) or failed to connect. Null by default.
40 | * @property loggingEnabled A property to configure AudioSwitch logging behavior. AudioSwitch logging is disabled by
41 | * default.
42 | * @property selectedAudioDevice Retrieves the selected [AudioDevice] from [AudioSwitch.selectDevice].
43 | * @property availableAudioDevices Retrieves the current list of available [AudioDevice]s.
44 | **/
45 | class AudioSwitch {
46 | private val context: Context
47 | private var logger: Logger = ProductionLogger()
48 | private val audioDeviceManager: AudioDeviceManager
49 | private val wiredHeadsetReceiver: WiredHeadsetReceiver
50 | internal var audioDeviceChangeListener: AudioDeviceChangeListener? = null
51 | private var selectedDevice: AudioDevice? = null
52 | private var userSelectedDevice: AudioDevice? = null
53 | private var wiredHeadsetAvailable = false
54 | private val mutableAudioDevices = ArrayList()
55 | private var bluetoothHeadsetManager: BluetoothHeadsetManager? = null
56 | private val preferredDeviceList: List>
57 | private var bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null
58 | private val permissionsRequestStrategy: PermissionsCheckStrategy
59 |
60 | internal var state: State = STOPPED
61 |
62 | internal enum class State {
63 | STARTED,
64 | ACTIVATED,
65 | STOPPED,
66 | }
67 |
68 | internal val bluetoothDeviceConnectionListener =
69 | object : BluetoothHeadsetConnectionListener {
70 | override fun onBluetoothHeadsetStateChanged(
71 | headsetName: String?,
72 | state: Int,
73 | ) {
74 | enumerateDevices(headsetName)
75 | bluetoothHeadsetConnectionListener?.onBluetoothHeadsetStateChanged(headsetName, state)
76 | }
77 |
78 | override fun onBluetoothScoStateChanged(state: Int) {
79 | bluetoothHeadsetConnectionListener?.onBluetoothScoStateChanged(state)
80 | }
81 |
82 | override fun onBluetoothHeadsetActivationError() {
83 | if (userSelectedDevice is BluetoothHeadset) userSelectedDevice = null
84 | enumerateDevices()
85 | bluetoothHeadsetConnectionListener?.onBluetoothHeadsetActivationError()
86 | }
87 | }
88 |
89 | internal val wiredDeviceConnectionListener =
90 | object : WiredDeviceConnectionListener {
91 | override fun onDeviceConnected() {
92 | wiredHeadsetAvailable = true
93 | enumerateDevices()
94 | }
95 |
96 | override fun onDeviceDisconnected() {
97 | wiredHeadsetAvailable = false
98 | enumerateDevices()
99 | }
100 | }
101 |
102 | internal val applicationLifecycleListener =
103 | object : Application.ActivityLifecycleCallbacks {
104 | override fun onActivityCreated(
105 | activity: Activity,
106 | savedInstanceState: Bundle?,
107 | ) { }
108 |
109 | override fun onActivityStarted(activity: Activity) { }
110 |
111 | override fun onActivityResumed(activity: Activity) {
112 | if (STOPPED != state && null == bluetoothHeadsetManager) {
113 | getBluetoothHeadsetManager()?.start(bluetoothDeviceConnectionListener)
114 | }
115 | }
116 |
117 | override fun onActivityPaused(activity: Activity) { }
118 |
119 | override fun onActivityStopped(activity: Activity) { }
120 |
121 | override fun onActivitySaveInstanceState(
122 | activity: Activity,
123 | outState: Bundle,
124 | ) { }
125 |
126 | override fun onActivityDestroyed(activity: Activity) { }
127 | }
128 |
129 | var loggingEnabled: Boolean
130 | get() = logger.loggingEnabled
131 |
132 | set(value) {
133 | logger.loggingEnabled = value
134 | }
135 | val selectedAudioDevice: AudioDevice? get() = selectedDevice
136 | val availableAudioDevices: List = mutableAudioDevices
137 |
138 | /**
139 | * Constructs a new AudioSwitch instance.
140 | * - [context] - An Android Context.
141 | * - [bluetoothHeadsetConnectionListener] - A listener to notify if Bluetooth device state has
142 | * changed (connect, disconnect, audio connect, audio disconnect) or failed to connect. Null by default
143 | * - [loggingEnabled] - Toggle whether logging is enabled. This argument is false by default.
144 | * - [audioFocusChangeListener] - A listener that is invoked when the system audio focus is updated.
145 | * Note that updates are only sent to the listener after [activate] has been called.
146 | * - [preferredDeviceList] - The order in which [AudioSwitch] automatically selects and activates
147 | * an [AudioDevice]. This parameter is ignored if the [selectedAudioDevice] is not `null`.
148 | * The default preferred [AudioDevice] order is the following:
149 | * [BluetoothHeadset], [WiredHeadset], [Earpiece], [Speakerphone]
150 | * . The [preferredDeviceList] is added to the front of the default list. For example, if [preferredDeviceList]
151 | * is [Speakerphone] and [BluetoothHeadset], then the new preferred audio
152 | * device list will be:
153 | * [Speakerphone], [BluetoothHeadset], [WiredHeadset], [Earpiece].
154 | * An [IllegalArgumentException] is thrown if the [preferredDeviceList] contains duplicate [AudioDevice] elements.
155 | */
156 | @JvmOverloads
157 | constructor(
158 | context: Context,
159 | bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null,
160 | loggingEnabled: Boolean = false,
161 | audioFocusChangeListener: OnAudioFocusChangeListener = OnAudioFocusChangeListener {},
162 | preferredDeviceList: List> = defaultPreferredDeviceList,
163 | ) : this(
164 | context.applicationContext,
165 | bluetoothHeadsetConnectionListener,
166 | ProductionLogger(loggingEnabled),
167 | audioFocusChangeListener,
168 | preferredDeviceList,
169 | )
170 |
171 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
172 | internal constructor(
173 | context: Context,
174 | bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener?,
175 | logger: Logger,
176 | audioFocusChangeListener: OnAudioFocusChangeListener,
177 | preferredDeviceList: List>,
178 | audioDeviceManager: AudioDeviceManager =
179 | AudioDeviceManager(
180 | context,
181 | logger,
182 | context.getSystemService(Context.AUDIO_SERVICE) as AudioManager,
183 | audioFocusChangeListener = audioFocusChangeListener,
184 | ),
185 | wiredHeadsetReceiver: WiredHeadsetReceiver = WiredHeadsetReceiver(context, logger),
186 | permissionsCheckStrategy: PermissionsCheckStrategy = DefaultPermissionsCheckStrategy(context),
187 | bluetoothManager: BluetoothManager? = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager,
188 | bluetoothHeadsetManager: BluetoothHeadsetManager? =
189 | BluetoothHeadsetManager.newInstance(
190 | context,
191 | logger,
192 | bluetoothManager?.adapter,
193 | audioDeviceManager,
194 | ),
195 | ) {
196 | this.context = context
197 | this.logger = logger
198 | this.bluetoothHeadsetConnectionListener = bluetoothHeadsetConnectionListener
199 | this.audioDeviceManager = audioDeviceManager
200 | this.wiredHeadsetReceiver = wiredHeadsetReceiver
201 | this.preferredDeviceList = getPreferredDeviceList(preferredDeviceList)
202 | this.permissionsRequestStrategy = permissionsCheckStrategy
203 | this.bluetoothHeadsetManager =
204 | if (hasPermissions()) {
205 | bluetoothHeadsetManager
206 | } else {
207 | logger.w(TAG, PERMISSION_ERROR_MESSAGE)
208 | bluetoothHeadsetManager
209 | null
210 | }
211 | logger.d(TAG, "AudioSwitch($VERSION)")
212 | logger.d(TAG, "Preferred device list = ${this.preferredDeviceList.map { it.simpleName }}")
213 | }
214 |
215 | private fun getPreferredDeviceList(preferredDeviceList: List>): List> {
216 | require(hasNoDuplicates(preferredDeviceList))
217 |
218 | return if (preferredDeviceList.isEmpty() || preferredDeviceList == defaultPreferredDeviceList) {
219 | defaultPreferredDeviceList
220 | } else {
221 | val result = defaultPreferredDeviceList.toMutableList()
222 | result.removeAll(preferredDeviceList)
223 | preferredDeviceList.forEachIndexed { index, device ->
224 | result.add(index, device)
225 | }
226 | result
227 | }
228 | }
229 |
230 | /**
231 | * Starts listening for audio device changes and calls the [listener] upon each change if listener provided.
232 | * **Note:** When audio device listening is no longer needed, [AudioSwitch.stop] should be
233 | * called in order to prevent a memory leak.
234 | */
235 | fun start(listener: AudioDeviceChangeListener? = null) {
236 | listener?.let {
237 | audioDeviceChangeListener = it
238 | }
239 | when (state) {
240 | STOPPED -> {
241 | state = STARTED
242 | enumerateDevices()
243 | getBluetoothHeadsetManager()?.start(bluetoothDeviceConnectionListener)
244 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener)
245 | (context.applicationContext as Application)
246 | .registerActivityLifecycleCallbacks(applicationLifecycleListener)
247 | }
248 | else -> {
249 | logger.d(TAG, "Redundant start() invocation while already in the started or activated state")
250 | }
251 | }
252 | }
253 |
254 | /**
255 | * Adds [AudioDeviceChangeListener] and starts listening for audio devices changes and calls,
256 | * or null to stop listening for audio device changes.
257 | */
258 | fun setAudioDeviceChangeListener(listener: AudioDeviceChangeListener?) {
259 | audioDeviceChangeListener = listener
260 | }
261 |
262 | /**
263 | * Stops listening for audio device changes if [AudioSwitch.start] has already been
264 | * invoked. [AudioSwitch.deactivate] will also get called if a device has been activated
265 | * with [AudioSwitch.activate].
266 | */
267 | fun stop() {
268 | when (state) {
269 | ACTIVATED -> {
270 | deactivate()
271 | closeListeners()
272 | }
273 | STARTED -> {
274 | closeListeners()
275 | }
276 | STOPPED -> {
277 | logger.d(TAG, "Redundant stop() invocation while already in the stopped state")
278 | }
279 | }
280 | }
281 |
282 | /**
283 | * Performs audio routing and unmuting on the selected device from
284 | * [AudioSwitch.selectDevice]. Audio focus is also acquired for the client application.
285 | * **Note:** [AudioSwitch.deactivate] should be invoked to restore the prior audio
286 | * state.
287 | */
288 | fun activate() {
289 | when (state) {
290 | STARTED -> {
291 | state = ACTIVATED
292 | audioDeviceManager.cacheAudioState()
293 |
294 | // Always set mute to false for WebRTC
295 | audioDeviceManager.mute(false)
296 | audioDeviceManager.setAudioFocus()
297 | selectedDevice?.let { activate(it) }
298 | }
299 | ACTIVATED -> selectedDevice?.let { activate(it) }
300 | STOPPED -> throw IllegalStateException()
301 | }
302 | }
303 |
304 | /**
305 | * Restores the audio state prior to calling [AudioSwitch.activate] and removes
306 | * audio focus from the client application.
307 | */
308 | fun deactivate() {
309 | when (state) {
310 | ACTIVATED -> {
311 | state = STARTED
312 | getBluetoothHeadsetManager()?.deactivate()
313 |
314 | // Restore stored audio state
315 | audioDeviceManager.restoreAudioState()
316 | }
317 | STARTED, STOPPED -> {
318 | }
319 | }
320 | }
321 |
322 | /**
323 | * Selects the desired [audioDevice]. If the provided [AudioDevice] is not
324 | * available, no changes are made. If the provided device is null, one is chosen based on the
325 | * specified preferred device list or the following default list:
326 | * [BluetoothHeadset], [WiredHeadset], [Earpiece], [Speakerphone].
327 | */
328 | fun selectDevice(audioDevice: AudioDevice?) {
329 | if (selectedDevice != audioDevice) {
330 | logger.d(TAG, "Selected AudioDevice = $audioDevice")
331 | userSelectedDevice = audioDevice
332 | enumerateDevices()
333 | }
334 | }
335 |
336 | private fun hasNoDuplicates(list: List>) =
337 | list
338 | .groupingBy { it }
339 | .eachCount()
340 | .filter { it.value > 1 }
341 | .isEmpty()
342 |
343 | private fun activate(audioDevice: AudioDevice) {
344 | when (audioDevice) {
345 | is BluetoothHeadset -> {
346 | audioDeviceManager.enableSpeakerphone(false)
347 | getBluetoothHeadsetManager()?.activate()
348 | }
349 | is Earpiece, is WiredHeadset -> {
350 | audioDeviceManager.enableSpeakerphone(false)
351 | getBluetoothHeadsetManager()?.deactivate()
352 | }
353 | is Speakerphone -> {
354 | audioDeviceManager.enableSpeakerphone(true)
355 | getBluetoothHeadsetManager()?.deactivate()
356 | }
357 | }
358 | }
359 |
360 | internal data class AudioDeviceState(
361 | val audioDeviceList: List,
362 | val selectedAudioDevice: AudioDevice?,
363 | )
364 |
365 | private fun enumerateDevices(bluetoothHeadsetName: String? = null) {
366 | // save off the old state and 'semi'-deep copy the list of audio devices
367 | val oldAudioDeviceState = AudioDeviceState(mutableAudioDevices.map { it }, selectedDevice)
368 | // update audio device list and selected device
369 | addAvailableAudioDevices(bluetoothHeadsetName)
370 |
371 | if (!userSelectedDevicePresent(mutableAudioDevices)) {
372 | userSelectedDevice = null
373 | }
374 |
375 | // Select the audio device
376 | logger.d(TAG, "Current user selected AudioDevice = $userSelectedDevice")
377 | selectedDevice =
378 | if (userSelectedDevice != null) {
379 | userSelectedDevice
380 | } else if (mutableAudioDevices.isNotEmpty()) {
381 | val firstAudioDevice = mutableAudioDevices[0]
382 | /*
383 | * If there was an error starting bluetooth sco, then the selected AudioDevice should
384 | * be the next valid device in the list.
385 | */
386 | if (firstAudioDevice is BluetoothHeadset &&
387 | getBluetoothHeadsetManager()?.hasActivationError() == true
388 | ) {
389 | mutableAudioDevices[1]
390 | } else {
391 | firstAudioDevice
392 | }
393 | } else {
394 | null
395 | }
396 |
397 | // Activate the device if in the active state
398 | if (state == ACTIVATED) {
399 | activate()
400 | }
401 | // trigger audio device change listener if there has been a change
402 | val newAudioDeviceState = AudioDeviceState(mutableAudioDevices, selectedDevice)
403 | if (newAudioDeviceState != oldAudioDeviceState) {
404 | audioDeviceChangeListener?.invoke(mutableAudioDevices, selectedDevice)
405 | }
406 | }
407 |
408 | private fun addAvailableAudioDevices(bluetoothHeadsetName: String?) {
409 | mutableAudioDevices.clear()
410 | preferredDeviceList.forEach { audioDevice ->
411 | when (audioDevice) {
412 | BluetoothHeadset::class.java -> {
413 | /*
414 | * Since the there is a delay between receiving the ACTION_ACL_CONNECTED event and receiving
415 | * the name of the connected device from querying the BluetoothHeadset proxy class, the
416 | * headset name received from the ACTION_ACL_CONNECTED intent needs to be passed into this
417 | * function.
418 | */
419 | getBluetoothHeadsetManager()?.getHeadset(bluetoothHeadsetName)?.let {
420 | mutableAudioDevices.add(it)
421 | }
422 | }
423 | WiredHeadset::class.java -> {
424 | if (wiredHeadsetAvailable) {
425 | mutableAudioDevices.add(WiredHeadset())
426 | }
427 | }
428 | Earpiece::class.java -> {
429 | if (audioDeviceManager.hasEarpiece() && !wiredHeadsetAvailable) {
430 | mutableAudioDevices.add(Earpiece())
431 | }
432 | }
433 | Speakerphone::class.java -> {
434 | if (audioDeviceManager.hasSpeakerphone()) {
435 | mutableAudioDevices.add(Speakerphone())
436 | }
437 | }
438 | }
439 | }
440 |
441 | logger.d(TAG, "Available AudioDevice list updated: $availableAudioDevices")
442 | }
443 |
444 | private fun userSelectedDevicePresent(audioDevices: List) =
445 | userSelectedDevice?.let { selectedDevice ->
446 | if (selectedDevice is BluetoothHeadset) {
447 | // Match any bluetooth headset as a new one may have been connected
448 | audioDevices.find { it is BluetoothHeadset }?.let { newHeadset ->
449 | userSelectedDevice = newHeadset
450 | true
451 | } ?: false
452 | } else {
453 | audioDevices.contains(selectedDevice)
454 | }
455 | } ?: false
456 |
457 | private fun closeListeners() {
458 | state = STOPPED
459 | getBluetoothHeadsetManager()?.stop()
460 | wiredHeadsetReceiver.stop()
461 | (context.applicationContext as Application)
462 | .unregisterActivityLifecycleCallbacks(applicationLifecycleListener)
463 | audioDeviceChangeListener = null
464 | }
465 |
466 | private fun getBluetoothHeadsetManager(): BluetoothHeadsetManager? {
467 | if (bluetoothHeadsetManager == null && hasPermissions()) {
468 | val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
469 | bluetoothHeadsetManager =
470 | BluetoothHeadsetManager.newInstance(
471 | context,
472 | logger,
473 | bluetoothManager?.adapter,
474 | audioDeviceManager,
475 | )
476 | }
477 | return bluetoothHeadsetManager
478 | }
479 |
480 | internal fun hasPermissions() = permissionsRequestStrategy.hasPermissions()
481 |
482 | internal class DefaultPermissionsCheckStrategy(
483 | private val context: Context,
484 | ) : PermissionsCheckStrategy {
485 | @SuppressLint("NewApi")
486 | override fun hasPermissions(): Boolean =
487 | if (context.applicationInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.R ||
488 | android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.R
489 | ) {
490 | PERMISSION_GRANTED ==
491 | context.checkPermission(
492 | Manifest.permission.BLUETOOTH,
493 | android.os.Process.myPid(),
494 | android.os.Process.myUid(),
495 | )
496 | } else {
497 | // for android 12/S or newer
498 | PERMISSION_GRANTED ==
499 | context.checkPermission(
500 | Manifest.permission.BLUETOOTH_CONNECT,
501 | android.os.Process.myPid(),
502 | android.os.Process.myUid(),
503 | )
504 | }
505 | }
506 |
507 | companion object {
508 | /**
509 | * The version of the AudioSwitch library.
510 | */
511 | const val VERSION = BuildConfig.VERSION_NAME
512 |
513 | private val defaultPreferredDeviceList by lazy {
514 | listOf(
515 | BluetoothHeadset::class.java,
516 | WiredHeadset::class.java,
517 | Earpiece::class.java,
518 | Speakerphone::class.java,
519 | )
520 | }
521 | }
522 | }
523 |
--------------------------------------------------------------------------------