├── 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 | [![CircleCI](https://circleci.com/gh/twilio/audioswitch.svg?style=svg)](https://circleci.com/gh/twilio/audioswitch) 4 | 5 | An Android audio management library for real-time communication apps. 6 | 7 | ![video-app-screenshots](images/audioswitch-logo.png) 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 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.twilio/audioswitch/badge.svg) ](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 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.twilio/audioswitch/badge.svg) ](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 | --------------------------------------------------------------------------------