├── .circleci └── config.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE.txt ├── README.md ├── audioswitch ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── ftl │ └── app-debug.apk ├── lint-baseline.xml ├── proguard-rules.pro └── src │ ├── androidTest │ ├── AndroidManifest.xml │ └── java │ │ ├── com.twilio.audioswitch │ │ ├── AndroidTestBase.kt │ │ ├── AudioFocusUtil.kt │ │ ├── AudioSwitchIntegrationTest.kt │ │ ├── AutomaticDeviceSelectionTest.kt │ │ ├── MultipleBluetoothHeadsetsTest.kt │ │ ├── TestUtil.kt │ │ ├── UserDeviceSelectionTest.kt │ │ └── android │ │ │ ├── FakeBluetoothIntentProcessor.kt │ │ │ └── FakeDevice.kt │ │ └── com │ │ └── twilio │ │ └── audioswitch │ │ └── manual │ │ └── ConnectedBluetoothHeadsetTest.kt │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── twilio │ │ └── audioswitch │ │ ├── AudioDevice.kt │ │ ├── AudioDeviceChangeListener.kt │ │ ├── AudioDeviceManager.kt │ │ ├── AudioFocusRequestWrapper.kt │ │ ├── AudioSwitch.kt │ │ ├── android │ │ ├── BluetoothDeviceWrapper.kt │ │ ├── BluetoothDeviceWrapperImpl.kt │ │ ├── BluetoothIntentProcessor.kt │ │ ├── BluetoothIntentProcessorImpl.kt │ │ ├── BuildWrapper.kt │ │ ├── Logger.kt │ │ ├── PermissionsCheckStrategy.kt │ │ ├── ProductionLogger.kt │ │ └── SystemClockWrapper.kt │ │ ├── bluetooth │ │ ├── BluetoothHeadsetConnectionListener.kt │ │ ├── BluetoothHeadsetManager.kt │ │ └── BluetoothScoJob.kt │ │ └── wired │ │ ├── WiredDeviceConnectionListener.kt │ │ └── WiredHeadsetReceiver.kt │ └── test │ ├── java │ └── com │ │ └── twilio │ │ └── audioswitch │ │ ├── AudioSwitchJavaTest.java │ │ ├── AudioSwitchTest.kt │ │ ├── AudioSwitchTestParams.kt │ │ ├── BaseTest.kt │ │ ├── TestUtil.kt │ │ ├── UnitTestLogger.kt │ │ ├── android │ │ └── BluetoothDeviceWrapperImplTest.kt │ │ ├── bluetooth │ │ └── BluetoothHeadsetManagerTest.kt │ │ └── wired │ │ └── WiredHeadsetReceiverTest.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images └── audioswitch-logo.png ├── settings.gradle └── ui-test-args.yaml /.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 | # this flag allows you to publish a snapshot from any branch, using a standalone workflow 11 | enable-publish-snapshot-workflow: 12 | description: "enables the standalone workflow to build and publish a snapshot from any branch" 13 | type: boolean 14 | default: false 15 | 16 | aliases: 17 | - &workspace 18 | ~/audioswitch 19 | 20 | - &gradle-cache-key 21 | jars-{{ checksum "build.gradle" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} 22 | 23 | - &release-filter 24 | filters: 25 | tags: 26 | only: 27 | - /^\d+\.\d+\.\d+$/ 28 | branches: 29 | ignore: /.*/ 30 | 31 | - &snapshot-filter 32 | filters: 33 | branches: 34 | only: 35 | - master 36 | 37 | commands: 38 | restore_gradle_cache: 39 | steps: 40 | - restore_cache: 41 | key: *gradle-cache-key 42 | name: Restore Gradle Cache 43 | 44 | save_gradle_cache: 45 | steps: 46 | - save_cache: 47 | key: *gradle-cache-key 48 | name: Save Gradle Cache 49 | paths: 50 | - ~/.gradle/caches 51 | - ~/.gradle/wrapper 52 | 53 | setup_git_user: 54 | description: Configure git user 55 | steps: 56 | - run: 57 | name: Configure git user name and email 58 | command: | 59 | git config --global user.email $GIT_USER_EMAIL 60 | git config --global user.name $GIT_USER_NAME 61 | 62 | setup_gcloud: 63 | description: Authenticate with Google Cloud 64 | steps: 65 | - run: 66 | name: Setup GCloud Auth 67 | command: | 68 | echo $GCP_KEY | base64 -d | gcloud auth activate-service-account --key-file=- 69 | 70 | install_signing_key: 71 | steps: 72 | - run: 73 | name: Install signing key 74 | command: | 75 | echo $SIGNING_KEY | base64 -d >> $SIGNING_SECRET_KEY_RING_FILE 76 | 77 | publish_to_sonatype: 78 | description: Publish to Sonatype Repository 79 | parameters: 80 | pre-release: 81 | description: If true, publish a pre-release, otherwise publish a release 82 | type: boolean 83 | steps: 84 | - run: 85 | name: Publish AudioSwitch <<# parameters.pre-release >> snapshot <><<^ parameters.pre-release >> release <> 86 | command: | 87 | ./gradlew -q sonatypeAudioSwitchReleaseUpload \ 88 | -PpreRelease=<< parameters.pre-release >> 89 | 90 | executors: 91 | build-executor: 92 | working_directory: *workspace 93 | docker: 94 | - image: cimg/android:2024.01.1-node 95 | resource_class: large 96 | environment: 97 | _JAVA_OPTIONS: "-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport" 98 | 99 | integration-test-executor: 100 | working_directory: *workspace 101 | docker: 102 | - image: google/cloud-sdk:latest 103 | resource_class: medium+ 104 | 105 | jobs: 106 | lint: 107 | executor: build-executor 108 | steps: 109 | - checkout 110 | - restore_gradle_cache 111 | - run: 112 | name: Lint 113 | command: ./gradlew -q lint 114 | - store_artifacts: 115 | path: audioswitch/build/reports/lint-results.html 116 | destination: audioswitch 117 | - save_gradle_cache 118 | 119 | check-format: 120 | executor: build-executor 121 | resource_class: medium+ 122 | steps: 123 | - checkout 124 | - restore_gradle_cache 125 | - run: 126 | name: Spotless Check 127 | command: ./gradlew -q spotlessCheck 128 | - save_gradle_cache 129 | 130 | build-audioswitch: 131 | executor: build-executor 132 | steps: 133 | - checkout 134 | - attach_workspace: 135 | at: *workspace 136 | - restore_gradle_cache 137 | - run: 138 | name: Build AudioSwitch and Tests 139 | command: ./gradlew -q audioswitch:assemble audioswitch:assembleAndroidTest 140 | - persist_to_workspace: 141 | root: . 142 | paths: 143 | - audioswitch/build 144 | - save_gradle_cache 145 | 146 | unit-tests: 147 | executor: build-executor 148 | steps: 149 | - checkout 150 | - attach_workspace: 151 | at: *workspace 152 | - restore_gradle_cache 153 | - run: 154 | name: Unit Tests 155 | command: ./gradlew audioswitch:testDebugUnitTest 156 | - save_gradle_cache 157 | 158 | integration-tests: 159 | executor: integration-test-executor 160 | steps: 161 | - checkout 162 | - attach_workspace: 163 | at: *workspace 164 | - setup_gcloud 165 | - run: 166 | name: Run Integration Tests 167 | command: > 168 | gcloud firebase test android run --use-orchestrator --environment-variables clearPackageData=true --no-record-video --project video-app-79418 169 | ui-test-args.yaml:integration-tests 170 | 171 | publish-pre-release: 172 | executor: build-executor 173 | steps: 174 | - checkout 175 | - attach_workspace: 176 | at: *workspace 177 | - restore_gradle_cache 178 | - install_signing_key 179 | - publish_to_sonatype: 180 | pre-release: true 181 | - save_gradle_cache 182 | 183 | publish-release: 184 | executor: build-executor 185 | steps: 186 | - checkout 187 | - attach_workspace: 188 | at: *workspace 189 | - restore_gradle_cache 190 | - install_signing_key 191 | - publish_to_sonatype: 192 | pre-release: false 193 | - save_gradle_cache 194 | 195 | bump-version: 196 | executor: build-executor 197 | steps: 198 | - checkout 199 | - attach_workspace: 200 | at: *workspace 201 | - restore_gradle_cache 202 | - setup_git_user 203 | - run: 204 | name: Bump Version 205 | command: ./gradlew incrementVersion 206 | - save_gradle_cache 207 | 208 | publish-docs: 209 | executor: build-executor 210 | steps: 211 | - checkout 212 | - attach_workspace: 213 | at: *workspace 214 | - restore_gradle_cache 215 | - setup_git_user 216 | - run: 217 | name: Publish Docs 218 | command: ./gradlew publishDocs 219 | - save_gradle_cache 220 | 221 | workflows: 222 | # Default workflow. Triggered by all commits. Runs checks and tests on all branches. For master, also publishes a snapshot. 223 | build-test-publish: 224 | when: << pipeline.parameters.enable-default-workflow >> 225 | jobs: 226 | - lint 227 | - check-format 228 | - build-audioswitch 229 | - unit-tests: 230 | requires: 231 | - build-audioswitch 232 | - lint 233 | - check-format 234 | - integration-tests: 235 | requires: 236 | - build-audioswitch 237 | - lint 238 | - check-format 239 | - publish-pre-release: 240 | <<: *snapshot-filter 241 | requires: 242 | - unit-tests 243 | - integration-tests 244 | 245 | # Workflow to publish a release. Triggered by new git tags that match a version number, e.g. '1.2.3'. 246 | release: 247 | jobs: 248 | - publish-release: 249 | <<: *release-filter 250 | - publish-docs: 251 | <<: *release-filter 252 | requires: 253 | - publish-release 254 | - bump-version: 255 | <<: *release-filter 256 | requires: 257 | - publish-docs 258 | 259 | # Workflow to explicitly build and publish a snapshot. Triggered manually by setting the parameter to true. 260 | publish-snapshot: 261 | when: << pipeline.parameters.enable-publish-snapshot-workflow >> 262 | jobs: 263 | - publish-pre-release 264 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ### 1.2.1 (In progress) 3 | 4 | Enhancements 5 | 6 | - AudioDeviceChangeListener is now optional parameter when calling `AudioSwitch.start(listener: AudioDeviceChangeListener? = null)` 7 | - Added `AudioSwitch.setAudioDeviceChangeListener(listener: AudioDeviceChangeListener?)` 8 | - BluetoothHeadsetManager now checks for permissions every time it is called, creates an instance if null and permissions granted 9 | 10 | ### 1.2.0 (June 3, 2024) 11 | 12 | Enhancements 13 | 14 | - Updated gradle version to 8.4 15 | - Updated gradle plugin to 8.3.1 16 | - BluetoothHeadsetConnectionListener now can be added to AudioSwitch to notify when bluetooth device has connected or failed to connect. 17 | - BLUETOOTH_CONNECT and/or BLUETOOTH permission have been removed and are optional now. For bluetooth support, permission have to be added to application using 18 | AudioSwitch library. If not provided bluetooth device will not appear in the list of available devices and no callbacks will be received for BluetoothHeadsetConnectionListener. 19 | 20 | ### 1.1.9 (July 13, 2023) 21 | 22 | Enhancements 23 | 24 | - Updated gradle version to 8.0.2 25 | - Updated gradle plugin to 8.0.2 26 | 27 | ### 1.1.8 (Mar 17, 2023) 28 | 29 | Bug Fixes 30 | 31 | - Fixed issue where some Samsung Galaxy devices (S9, S21) would not route audio through USB headset when MODE_IN_COMMUNICATION is set. 32 | - Fixed issue where IllegalStateException would be thrown when activating selected AudioDevice shortly after starting AudioSwitch. 33 | - Fixed issue where after stopping AudioSwitch while having an active Bluetooth device would result in permanent audio focus gain. 34 | 35 | ### 1.1.7 (Feb 21, 2023) 36 | 37 | Bug Fixes 38 | 39 | - Bluetooth permissions now checks for the device version in case the target version is newer 40 | - Documentation is now available again and integration tests now pass 41 | - Fixed issue where reported Bluetooth device list could be incorrect upon AudioSwitch restart 42 | 43 | ### 1.1.5 (June 17, 2022) 44 | 45 | Bug Fixes 46 | 47 | - Fixed issue with lingering EnableBluetoothSCOJob object causing spurious AudioDeviceChangeListener calls after routing switch. 48 | 49 | ### 1.1.4 (January 4, 2022) 50 | 51 | Enhancements 52 | 53 | - Dokka dependency upgraded such that documents can be generated successfully again. 54 | - Updated gradle version to 7.0.2 55 | - Updated gradle plugin to 7.0.3 56 | 57 | Bug Fixes 58 | 59 | - Fixed issue with spurious `AudioDeviceChangedListener` invocations. 60 | - Fixed issue where `InvalidStateException` would be triggered during `audioswitch.stop(..)` if bluetooth permissions were granted after 'AudioSwitch.start()`. 61 | 62 | ### 1.1.3 (November 5, 2021) 63 | 64 | Enhancements 65 | 66 | - Updated the library to support Android 12. 67 | - Updated internal dependencies related to Android 12 support. 68 | - Updated compile and target sdk to Android 12 (31). 69 | - Updated gradle to version 4.2.1. 70 | - Snapshots are now published to the Maven Central snapshots repository. 71 | 72 | ### 1.1.2 (February 24, 2021) 73 | 74 | Enhancements 75 | 76 | - Updated the library to use Android Gradle Plugin 4.1.1. 77 | - Now published to MavenCentral. 78 | 79 | ### 1.1.1 (October 20, 2020) 80 | 81 | Enhancements 82 | 83 | - Added public KDoc documentation for each release. The latest documentation release can be found at https://twilio.github.io/audioswitch/latest 84 | 85 | ### 1.1.0 (October 8, 2020) 86 | 87 | Enhancements 88 | 89 | - Added a constructor parameter named `preferredDeviceList` to configure the order in which audio devices are automatically selected and activated when `selectedAudioDevice` is null. 90 | ```kotlin 91 | val audioSwitch = AudioSwitch(application, preferredDeviceList = listOf(Speakerphone::class.java, BluetoothHeadset::class.java)) 92 | ``` 93 | - Updated `compileSdkVersion` and `targetSdkVersion` to Android API version `30`. 94 | 95 | 96 | ### 1.0.1 (September 11, 2020) 97 | 98 | Enhancements 99 | 100 | - Upgraded Kotlin to `1.4.0`. 101 | - 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. 102 | - The context provided when constructing `AudioSwitch` can now take any context. Previously the `ApplicationContext` was required. 103 | 104 | Bug Fixes 105 | 106 | - Added the internal access modifier to the `SystemClockWrapper` class since it is not meant to be exposed publicly. 107 | 108 | ### 1.0.0 (August 17, 2020) 109 | 110 | - Promotes 0.4.0 to the first stable release of this library. 111 | 112 | ### 0.4.0 (August 14, 2020) 113 | 114 | Enhancements 115 | 116 | - Added a constructor parameter to enable logging. This argument is disabled by default. 117 | 118 | ```kotlin 119 | val audioSwitch = AudioSwitch(context, loggingEnabled = true) 120 | 121 | audioSwitch.start { _, _ -> } 122 | ``` 123 | 124 | - Added another constructor parameter that allows developers to subscribe to system audio focus changes while the library is activated. 125 | 126 | ```kotlin 127 | val audioSwitch = AudioSwitch(context, audioFocusChangeListener = OnAudioFocusChangeListener { focusChange -> 128 | // Do something with audio focus change 129 | }) 130 | 131 | audioSwitch.start { _, _ -> } 132 | // Audio focus changes are received after activating 133 | audioSwitch.activate() 134 | ``` 135 | 136 | ### 0.3.0 (August 12, 2020) 137 | 138 | Enhancements 139 | 140 | - Changed the name of the `AudioDeviceSelector` class to `AudioSwitch`. 141 | - 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. 142 | - Added `AudioSwitch.VERSION` constant so developers can access the version of AudioSwitch at runtime. 143 | - 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: 144 | 145 | ```kotlin 146 | val audioSwitch = AudioSwitch(context) 147 | 148 | audioSwitch.loggingEnabled = true 149 | 150 | audioSwitch.start { _, _ -> } 151 | ``` 152 | 153 | ### 0.2.1 (July 29, 2020) 154 | 155 | Bug Fixes 156 | 157 | - Fixed a bug where the audio focus wasn't being returned to the previous audio focus owner on pre Oreo devices. 158 | 159 | ### 0.2.0 (July 28, 2020) 160 | 161 | Enhancements 162 | - Added support for multiple connected bluetooth headsets. 163 | - The library will now accurately display the up to date active bluetooth headset within the `AudiodDeviceSelector` `availableAudioDevices` and `selectedAudioDevice` functions. 164 | - Other connected headsets are not stored by the library at this moment. 165 | - 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). 166 | - 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. 167 | - The newly activated headset will be propagated to the `AudiodDeviceSelector` `availableAudioDevices` and `selectedAudioDevice` functions. 168 | 169 | Bug Fixes 170 | 171 | - Improved the accuracy of the `BluetoothHeadset` within the `availableAudioDevices` returned from the `AudioDeviceSelector` when multiple Bluetooth Headsets are connected. 172 | 173 | ### 0.1.5 (July 1, 2020) 174 | 175 | Bug Fixes 176 | 177 | - 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. 178 | 179 | ### 0.1.4 (June 15, 2020) 180 | 181 | Enhancements 182 | - AAR minification is now enabled for release artifacts. 183 | 184 | Bug Fixes 185 | 186 | - Fixed a bug where the audio output doesn't automatically route to a newly connected bluetooth headset. 187 | - 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. 188 | 189 | ### 0.1.3 (May 27, 2020) 190 | 191 | Bug Fixes 192 | 193 | - Fixed crash by adding a default bluetooth device name. 194 | 195 | ### 0.1.2 (May 22, 2020) 196 | 197 | Enhancements 198 | 199 | - Added the library source to release artifacts. The sources will now be available when jumping to a library class definition in Android Studio. 200 | 201 | Bug Fixes 202 | 203 | - 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). 204 | 205 | ### 0.1.1 (May 19, 2020) 206 | 207 | - Fixes bug that did not correctly abandon audio request after deactivate 208 | 209 | ### 0.1.0 (April 28, 2020) 210 | 211 | This release marks the first iteration of the AudioSwitch library: an Android audio management library for real-time communication apps. 212 | 213 | This initial release comes with the following features: 214 | 215 | - Manage [audio focus](https://developer.android.com/guide/topics/media-apps/audio-focus) for typical VoIP and Video conferencing use cases. 216 | - Manage audio input and output devices. 217 | - Detect changes in available audio devices 218 | - Enumerate audio devices 219 | - Select an audio device 220 | 221 | ## Getting Started 222 | 223 | To get started using this library, follow the steps below. 224 | 225 | ### Gradle Setup 226 | 227 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.twilio/audioswitch/badge.svg) ](https://maven-badges.herokuapp.com/maven-central/com.twilio/audioswitch) 228 | 229 | Ensure that you have `mavenCentral` listed in your project's buildscript repositories section: 230 | ```groovy 231 | buildscript { 232 | repositories { 233 | mavenCentral() 234 | // ... 235 | } 236 | } 237 | ``` 238 | 239 | Add this line as a new Gradle dependency: 240 | ```groovy 241 | implementation 'com.twilio:audioswitch:$version' 242 | ``` 243 | 244 | ### AudioDeviceSelector Setup 245 | Instantiate an instance of the [AudioDeviceSelector](audioswitch/src/main/java/com/twilio/audioswitch/selection/AudioDeviceSelector.kt) class, passing a reference to the application context. 246 | 247 | ```kotlin 248 | val audioDeviceSelector = AudioDeviceSelector(applicationContext) 249 | ``` 250 | 251 | ### Listen for Devices 252 | 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. 253 | 254 | ```kotlin 255 | audioDeviceSelector.start { audioDevices, selectedDevice -> 256 | // TODO update UI with audio devices 257 | } 258 | ``` 259 | You can also retrieve the available and selected audio devices manually at any time by calling the following properties: 260 | ```kotlin 261 | val devices: List = audioDeviceSelector.availableAudioDevices 262 | val selectedDevice: AudioDevice? = audioDeviceSelector.selectedAudioDevice 263 | ``` 264 | **Note:** Don't forget to stop listening for audio devices when no longer needed in order to prevent a memory leak. 265 | ```kotlin 266 | audioDeviceSelector.stop() 267 | ``` 268 | 269 | ### Select a Device 270 | Before activating an AudioDevice, it needs to be selected first. 271 | ```kotlin 272 | devices.find { it is AudioDevice.Speakerphone }?.let { audioDeviceSelector.selectDevice(it) } 273 | ``` 274 | If no device is selected, then the library will automatically select a device based on the following priority: `BluetoothHeadset -> WiredHeadset -> Earpiece -> Speakerphone`. 275 | 276 | ### Activate a Device 277 | 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. 278 | ```kotlin 279 | audioDeviceSelector.activate() 280 | ``` 281 | Make sure to revert back to the prior audio state when it makes sense to do so in your app. 282 | ```kotlin 283 | audioDeviceSelector.deactivate() 284 | ``` 285 | **Note:** The `stop()` function will call `deactivate()` before closing AudioDeviceSelector resources. 286 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /audioswitch/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /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.3.0" 72 | testImplementation 'junit:junit:4.13.2' 73 | testImplementation 'org.mockito.kotlin:mockito-kotlin:4.1.0' 74 | testImplementation 'pl.pragmatists:JUnitParams:1.1.1' 75 | def androidXTest = '1.4.1-alpha03' 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/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/audioswitch/ef1cee84e4a90c0ff790cb2147c3df0fd383337e/audioswitch/consumer-rules.pro -------------------------------------------------------------------------------- /audioswitch/ftl/app-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/audioswitch/ef1cee84e4a90c0ff790cb2147c3df0fd383337e/audioswitch/ftl/app-debug.apk -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/androidTest/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 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/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 | 13 | private lateinit var request: AudioFocusRequest 14 | 15 | fun requestFocus() { 16 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 17 | val playbackAttributes = AudioAttributes.Builder() 18 | .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) 19 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) 20 | .build() 21 | request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) 22 | .setAudioAttributes(playbackAttributes) 23 | .setAcceptsDelayedFocusGain(true) 24 | .setOnAudioFocusChangeListener(audioFocusChangeListener) 25 | .build() 26 | audioManager.requestAudioFocus(request) 27 | } else { 28 | audioManager.requestAudioFocus( 29 | audioFocusChangeListener, 30 | AudioManager.STREAM_VOICE_CALL, 31 | AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, 32 | ) 33 | } 34 | } 35 | 36 | fun abandonFocus() { 37 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 38 | audioManager.abandonAudioFocusRequest(request) 39 | } else { 40 | audioManager.abandonAudioFocus(audioFocusChangeListener) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /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 | 20 | @Test 21 | @UiThreadTest 22 | fun it_should_disable_logging_by_default() { 23 | val audioSwitch = AudioSwitch(getInstrumentationContext()) 24 | 25 | assertFalse(audioSwitch.loggingEnabled) 26 | } 27 | 28 | @Test 29 | @UiThreadTest 30 | fun it_should_allow_enabling_logging() { 31 | val audioSwitch = AudioSwitch(getInstrumentationContext()) 32 | 33 | audioSwitch.loggingEnabled = true 34 | 35 | assertTrue(audioSwitch.loggingEnabled) 36 | } 37 | 38 | @Test 39 | @UiThreadTest 40 | fun it_should_allow_enabling_logging_at_construction() { 41 | val audioSwitch = AudioSwitch(getInstrumentationContext(), loggingEnabled = true) 42 | 43 | assertTrue(audioSwitch.loggingEnabled) 44 | } 45 | 46 | @Test 47 | @UiThreadTest 48 | fun it_should_allow_toggling_logging_while_in_use() { 49 | val audioSwitch = AudioSwitch(getInstrumentationContext()) 50 | audioSwitch.loggingEnabled = true 51 | assertTrue(audioSwitch.loggingEnabled) 52 | audioSwitch.start { _, _ -> } 53 | val earpiece = 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 = Regex( 71 | "^([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-" + 72 | "Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+)?$", 73 | ) 74 | val version: String = AudioSwitch.VERSION 75 | assertNotNull(version) 76 | assertTrue(version.matches(semVerRegex)) 77 | } 78 | 79 | @Test 80 | fun it_should_receive_audio_focus_changes_if_configured() { 81 | val audioFocusLostLatch = CountDownLatch(1) 82 | val audioFocusGainedLatch = CountDownLatch(1) 83 | val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> 84 | when (focusChange) { 85 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> audioFocusLostLatch.countDown() 86 | AudioManager.AUDIOFOCUS_GAIN -> audioFocusGainedLatch.countDown() 87 | } 88 | } 89 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 90 | val audioSwitch = AudioSwitch(getTargetContext(), null, true, audioFocusChangeListener) 91 | audioSwitch.start { _, _ -> } 92 | audioSwitch.activate() 93 | } 94 | 95 | val audioManager = getInstrumentationContext() 96 | .getSystemService(Context.AUDIO_SERVICE) as AudioManager 97 | val audioFocusUtil = AudioFocusUtil(audioManager, audioFocusChangeListener) 98 | audioFocusUtil.requestFocus() 99 | 100 | assertTrue(audioFocusLostLatch.await(5, TimeUnit.SECONDS)) 101 | audioFocusUtil.abandonFocus() 102 | assertTrue(audioFocusGainedLatch.await(5, TimeUnit.SECONDS)) 103 | } 104 | 105 | @Test 106 | fun it_should_acquire_audio_focus_if_it_is_already_acquired_in_the_system() { 107 | val audioFocusLostLatch = CountDownLatch(1) 108 | val audioFocusGainedLatch = CountDownLatch(1) 109 | val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> 110 | when (focusChange) { 111 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> audioFocusLostLatch.countDown() 112 | AudioManager.AUDIOFOCUS_GAIN -> audioFocusGainedLatch.countDown() 113 | } 114 | } 115 | val audioManager = getInstrumentationContext() 116 | .getSystemService(Context.AUDIO_SERVICE) as AudioManager 117 | val audioFocusUtil = AudioFocusUtil(audioManager, audioFocusChangeListener) 118 | audioFocusUtil.requestFocus() 119 | 120 | val audioSwitch = AudioSwitch(getTargetContext(), null, true) 121 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 122 | audioSwitch.start { _, _ -> } 123 | audioSwitch.activate() 124 | } 125 | 126 | assertTrue(audioFocusLostLatch.await(5, TimeUnit.SECONDS)) 127 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 128 | audioSwitch.stop() 129 | } 130 | assertTrue(audioFocusGainedLatch.await(5, TimeUnit.SECONDS)) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /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 | 22 | @UiThreadTest 23 | @Test 24 | fun `it_should_select_the_bluetooth_audio_device_by_default`() { 25 | val context = getInstrumentationContext() 26 | val (audioSwitch, bluetoothHeadsetReceiver, wiredHeadsetReceiver) = setupFakeAudioSwitch(context) 27 | 28 | audioSwitch.start { _, _ -> } 29 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver) 30 | simulateWiredHeadsetSystemIntent(context, wiredHeadsetReceiver) 31 | 32 | assertThat(audioSwitch.selectedAudioDevice!! is AudioDevice.BluetoothHeadset, equalTo(true)) 33 | audioSwitch.stop() 34 | } 35 | 36 | @UiThreadTest 37 | @Test 38 | fun `it_should_notify_callback_when_bluetooth_connects`() { 39 | val context = getInstrumentationContext() 40 | val bluetoothConnectedLatch = CountDownLatch(1) 41 | val bluetoothListener = object : BluetoothHeadsetConnectionListener { 42 | override fun onBluetoothHeadsetStateChanged(headsetName: String?, state: Int) { 43 | bluetoothConnectedLatch.countDown() 44 | } 45 | 46 | override fun onBluetoothScoStateChanged(state: Int) {} 47 | 48 | override fun onBluetoothHeadsetActivationError() {} 49 | } 50 | val (audioSwitch, bluetoothHeadsetReceiver, wiredHeadsetReceiver) = setupFakeAudioSwitch(context, bluetoothListener = bluetoothListener) 51 | 52 | audioSwitch.start { _, _ -> } 53 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver) 54 | simulateWiredHeadsetSystemIntent(context, wiredHeadsetReceiver) 55 | 56 | assertThat(audioSwitch.selectedAudioDevice!! is AudioDevice.BluetoothHeadset, equalTo(true)) 57 | assertTrue(bluetoothConnectedLatch.await(5, TimeUnit.SECONDS)) 58 | audioSwitch.stop() 59 | } 60 | 61 | @UiThreadTest 62 | @Test 63 | fun `it_should_select_the_wired_headset_by_default`() { 64 | val context = getInstrumentationContext() 65 | val (audioSwitch, bluetoothHeadsetReceiver, wiredHeadsetReceiver) = 66 | setupFakeAudioSwitch(context, listOf(WiredHeadset::class.java)) 67 | 68 | audioSwitch.start { _, _ -> } 69 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver) 70 | simulateWiredHeadsetSystemIntent(context, wiredHeadsetReceiver) 71 | 72 | assertThat(audioSwitch.selectedAudioDevice!! is WiredHeadset, equalTo(true)) 73 | audioSwitch.stop() 74 | } 75 | 76 | @UiThreadTest 77 | @Test 78 | fun `it_should_select_the_earpiece_audio_device_by_default`() { 79 | val context = getInstrumentationContext() 80 | val (audioSwitch, bluetoothHeadsetReceiver) = 81 | setupFakeAudioSwitch(context, listOf(Earpiece::class.java)) 82 | audioSwitch.start { _, _ -> } 83 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver) 84 | 85 | assertThat(audioSwitch.selectedAudioDevice!! is Earpiece, equalTo(true)) 86 | audioSwitch.stop() 87 | } 88 | 89 | @UiThreadTest 90 | @Test 91 | fun `it_should_select_the_speakerphone_audio_device_by_default`() { 92 | val context = getInstrumentationContext() 93 | val (audioSwitch, bluetoothHeadsetReceiver) = 94 | setupFakeAudioSwitch(context, listOf(Speakerphone::class.java)) 95 | audioSwitch.start { _, _ -> } 96 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver) 97 | 98 | assertThat(audioSwitch.selectedAudioDevice!! is Speakerphone, equalTo(true)) 99 | audioSwitch.stop() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /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 | 19 | @UiThreadTest 20 | @Test 21 | fun `it_should_assert_the_second_bluetooth_headset_when_two_are_connected`() { 22 | val (audioSwitch, bluetoothHeadsetReceiver) = setupFakeAudioSwitch(getInstrumentationContext()) 23 | 24 | audioSwitch.start { _, _ -> } 25 | audioSwitch.activate() 26 | simulateBluetoothSystemIntent( 27 | getInstrumentationContext(), 28 | bluetoothHeadsetReceiver, 29 | ) 30 | simulateBluetoothSystemIntent( 31 | getInstrumentationContext(), 32 | bluetoothHeadsetReceiver, 33 | HEADSET_2_NAME, 34 | ) 35 | 36 | assertThat(audioSwitch.selectedAudioDevice!!.name, equalTo(HEADSET_2_NAME)) 37 | assertThat(audioSwitch.availableAudioDevices.first().name, equalTo(HEADSET_2_NAME)) 38 | assertThat( 39 | audioSwitch.availableAudioDevices.find { it.name == HEADSET_NAME }, 40 | `is`(nullValue()), 41 | ) 42 | assertThat(isSpeakerPhoneOn(), equalTo(false)) // Best we can do for asserting if a fake BT headset is activated 43 | audioSwitch.stop() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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 = BluetoothAdapter.getDefaultAdapter()?.let { bluetoothAdapter -> 48 | BluetoothHeadsetManager( 49 | context, 50 | logger, 51 | bluetoothAdapter, 52 | audioDeviceManager, 53 | bluetoothIntentProcessor = FakeBluetoothIntentProcessor(), 54 | ) 55 | } ?: run { 56 | null 57 | } 58 | return Triple( 59 | AudioSwitch( 60 | context, 61 | bluetoothListener, 62 | logger, 63 | {}, 64 | preferredDevicesList, 65 | audioDeviceManager, 66 | wiredHeadsetReceiver, 67 | bluetoothHeadsetManager = headsetManager, 68 | ), 69 | headsetManager!!, 70 | wiredHeadsetReceiver, 71 | ) 72 | } 73 | 74 | internal fun simulateBluetoothSystemIntent( 75 | context: Context, 76 | headsetManager: BluetoothHeadsetManager, 77 | deviceName: String = HEADSET_NAME, 78 | action: String = BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED, 79 | connectionState: Int = BluetoothHeadset.STATE_CONNECTED, 80 | ) { 81 | val intent = Intent(action).apply { 82 | putExtra(BluetoothHeadset.EXTRA_STATE, connectionState) 83 | putExtra(DEVICE_NAME, deviceName) 84 | } 85 | headsetManager.onReceive(context, intent) 86 | } 87 | 88 | internal fun simulateWiredHeadsetSystemIntent( 89 | context: Context, 90 | wiredHeadsetReceiver: WiredHeadsetReceiver, 91 | ) { 92 | val intent = Intent().apply { 93 | putExtra(INTENT_STATE, STATE_PLUGGED) 94 | } 95 | wiredHeadsetReceiver.onReceive(context, intent) 96 | } 97 | 98 | fun getTargetContext(): Context = getInstrumentation().targetContext 99 | 100 | fun getInstrumentationContext(): Context = getInstrumentation().context 101 | 102 | fun isSpeakerPhoneOn() = 103 | (getTargetContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager?)?.let { 104 | it.isSpeakerphoneOn 105 | } ?: false 106 | 107 | fun retryAssertion( 108 | timeoutInMilliseconds: Long = 10000L, 109 | assertionAction: () -> Unit, 110 | ) { 111 | val startTime = System.currentTimeMillis() 112 | var currentTime = 0L 113 | while (currentTime <= timeoutInMilliseconds) { 114 | try { 115 | assertionAction() 116 | return 117 | } catch (error: AssertionError) { 118 | currentTime = System.currentTimeMillis() - startTime 119 | Thread.sleep(10) 120 | } 121 | } 122 | throw TimeoutException("Assertion timeout occurred") 123 | } 124 | -------------------------------------------------------------------------------- /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 | 21 | private val context = InstrumentationRegistry.getInstrumentation().context 22 | 23 | @UiThreadTest 24 | @Test 25 | fun `it_should_select_the_earpiece_audio_device_when_the_user_selects_it`() { 26 | val audioSwitch = AudioSwitch(context) 27 | audioSwitch.start { _, _ -> } 28 | val earpiece = 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 = audioSwitch.availableAudioDevices 44 | .find { it is Speakerphone } 45 | assertThat(speakerphone, `is`(notNullValue())) 46 | 47 | audioSwitch.selectDevice(speakerphone!!) 48 | 49 | assertThat(audioSwitch.selectedAudioDevice, equalTo(speakerphone)) 50 | audioSwitch.stop() 51 | } 52 | 53 | @UiThreadTest 54 | @Test 55 | fun `it_should_select_the_bluetooth_audio_device_when_the_user_selects_it`() { 56 | val (audioSwitch, bluetoothHeadsetReceiver) = setupFakeAudioSwitch(context) 57 | audioSwitch.start { _, _ -> } 58 | simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver) 59 | val bluetoothDevice = audioSwitch.availableAudioDevices 60 | .find { it is BluetoothHeadset } 61 | assertThat(bluetoothDevice, `is`(notNullValue())) 62 | 63 | audioSwitch.selectDevice(bluetoothDevice!!) 64 | 65 | assertThat(audioSwitch.selectedAudioDevice, equalTo(bluetoothDevice)) 66 | audioSwitch.stop() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /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 | 9 | override fun getBluetoothDevice(intent: Intent): BluetoothDeviceWrapper? { 10 | return FakeBluetoothDevice(name = intent.getStringExtra(DEVICE_NAME)!!) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /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/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 androidx.test.ext.junit.runners.AndroidJUnit4 13 | import androidx.test.platform.app.InstrumentationRegistry 14 | import androidx.test.rule.GrantPermissionRule 15 | import com.twilio.audioswitch.AudioDevice 16 | import com.twilio.audioswitch.AudioSwitch 17 | import com.twilio.audioswitch.getInstrumentationContext 18 | import com.twilio.audioswitch.isSpeakerPhoneOn 19 | import com.twilio.audioswitch.retryAssertion 20 | import junit.framework.TestCase.assertEquals 21 | import junit.framework.TestCase.assertFalse 22 | import junit.framework.TestCase.assertNull 23 | import junit.framework.TestCase.assertTrue 24 | import org.junit.After 25 | import org.junit.Assume.assumeNotNull 26 | import org.junit.Assume.assumeTrue 27 | import org.junit.Before 28 | import org.junit.Rule 29 | import org.junit.Test 30 | import org.junit.runner.RunWith 31 | import java.util.concurrent.CountDownLatch 32 | import java.util.concurrent.TimeUnit 33 | 34 | @RunWith(AndroidJUnit4::class) 35 | class ConnectedBluetoothHeadsetTest { 36 | 37 | private val BLUETOOTH_TIMEOUT: Long = 7 38 | private val bluetoothAdapter by lazy { BluetoothAdapter.getDefaultAdapter() } 39 | private val previousBluetoothEnabled by lazy { bluetoothAdapter.isEnabled } 40 | private val context by lazy { getInstrumentationContext() } 41 | private val audioSwitch by lazy { AudioSwitch(context) } 42 | private lateinit var bluetoothHeadset: BluetoothHeadset 43 | private lateinit var expectedBluetoothDevice: AudioDevice.BluetoothHeadset 44 | private val bluetoothServiceConnected = CountDownLatch(1) 45 | private val bluetoothServiceListener = object : BluetoothProfile.ServiceListener { 46 | override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { 47 | bluetoothHeadset = proxy as BluetoothHeadset 48 | bluetoothServiceConnected.countDown() 49 | } 50 | 51 | override fun onServiceDisconnected(profile: Int) { 52 | } 53 | } 54 | private val bluetoothHeadsetFilter = IntentFilter().apply { 55 | addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) 56 | addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 57 | addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) 58 | } 59 | private val bluetoothStateConnected = CountDownLatch(1) 60 | private val bluetoothStateDisconnected = CountDownLatch(1) 61 | private val bluetoothAudioStateConnected = CountDownLatch(1) 62 | private val bluetoothReceiver = object : BroadcastReceiver() { 63 | override fun onReceive(context: Context?, intent: Intent?) { 64 | intent?.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED).let { state -> 65 | when (state) { 66 | BluetoothHeadset.STATE_CONNECTED -> { 67 | bluetoothStateConnected.countDown() 68 | } 69 | BluetoothHeadset.STATE_DISCONNECTED -> { 70 | bluetoothStateDisconnected.countDown() 71 | } 72 | BluetoothHeadset.STATE_AUDIO_CONNECTED -> { 73 | bluetoothAudioStateConnected.countDown() 74 | } 75 | } 76 | } 77 | intent?.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR).let { state -> 78 | when (state) { 79 | AudioManager.SCO_AUDIO_STATE_CONNECTED -> { 80 | bluetoothAudioStateConnected.countDown() 81 | } 82 | } 83 | } 84 | } 85 | } 86 | 87 | @get:Rule 88 | val bluetoothPermissionRules: GrantPermissionRule by lazy { 89 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { 90 | GrantPermissionRule.grant(Manifest.permission.BLUETOOTH_CONNECT) 91 | } else { 92 | GrantPermissionRule.grant(Manifest.permission.BLUETOOTH) 93 | } 94 | } 95 | 96 | @Before 97 | fun setup() { 98 | assumeNotNull(bluetoothAdapter) 99 | context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter) 100 | if (!previousBluetoothEnabled) { 101 | bluetoothAdapter.enable() 102 | } 103 | bluetoothAdapter.getProfileProxy(context, bluetoothServiceListener, BluetoothProfile.HEADSET) 104 | assumeTrue(bluetoothServiceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 105 | if (!previousBluetoothEnabled) { 106 | assumeTrue(bluetoothStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 107 | } 108 | assumeTrue(bluetoothHeadset.connectedDevices.size == 1) 109 | expectedBluetoothDevice = AudioDevice.BluetoothHeadset(bluetoothHeadset.connectedDevices.first().name) 110 | } 111 | 112 | @After 113 | fun teardown() { 114 | audioSwitch.deactivate() 115 | audioSwitch.stop() 116 | if (previousBluetoothEnabled) { 117 | bluetoothAdapter.enable() 118 | } else { 119 | bluetoothAdapter.disable() 120 | } 121 | if (bluetoothServiceConnected.count == 0L) { 122 | bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset) 123 | } 124 | context.unregisterReceiver(bluetoothReceiver) 125 | } 126 | 127 | @Test 128 | fun it_should_select_bluetooth_device_by_default() { 129 | val actualBluetoothDevice = startAndAwaitBluetoothDevice() 130 | assertEquals(expectedBluetoothDevice, actualBluetoothDevice) 131 | } 132 | 133 | @Test 134 | fun it_should_remove_bluetooth_device_after_disconnected() { 135 | val bluetoothDeviceConnected = CountDownLatch(1) 136 | lateinit var actualBluetoothDevice: AudioDevice 137 | var noBluetoothDeviceAvailable: CountDownLatch? = null 138 | 139 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 140 | audioSwitch.start { audioDevices, audioDevice -> 141 | audioDevices.find { it is AudioDevice.BluetoothHeadset } 142 | ?: noBluetoothDeviceAvailable?.countDown() 143 | 144 | if (audioDevice is AudioDevice.BluetoothHeadset) { 145 | actualBluetoothDevice = audioDevice 146 | bluetoothDeviceConnected.countDown() 147 | } 148 | } 149 | } 150 | 151 | assertTrue(bluetoothDeviceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 152 | assertEquals(expectedBluetoothDevice, actualBluetoothDevice) 153 | 154 | noBluetoothDeviceAvailable = CountDownLatch(1) 155 | bluetoothAdapter.disable() 156 | retryAssertion { assertFalse(bluetoothAdapter.isEnabled) } 157 | assertTrue(noBluetoothDeviceAvailable.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 158 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 159 | assertNull(audioSwitch.availableAudioDevices.find { it is AudioDevice.BluetoothHeadset }) 160 | assertFalse(audioSwitch.selectedAudioDevice is AudioDevice.BluetoothHeadset) 161 | } 162 | } 163 | 164 | @Test 165 | fun it_should_allow_selecting_a_bluetooth_device() { 166 | startAndAwaitBluetoothDevice() 167 | 168 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 169 | audioSwitch.selectDevice( 170 | audioSwitch.availableAudioDevices.find { 171 | it is AudioDevice.BluetoothHeadset 172 | }, 173 | ) 174 | assertEquals(expectedBluetoothDevice, audioSwitch.selectedAudioDevice) 175 | } 176 | } 177 | 178 | @Test 179 | fun it_should_select_another_audio_device_with_bluetooth_device_connected() { 180 | startAndAwaitBluetoothDevice() 181 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 182 | val expectedAudioDevice = audioSwitch.availableAudioDevices.find { 183 | it !is AudioDevice.BluetoothHeadset 184 | } 185 | audioSwitch.selectDevice(expectedAudioDevice) 186 | assertEquals(expectedAudioDevice, audioSwitch.selectedAudioDevice) 187 | } 188 | } 189 | 190 | @Test 191 | fun it_should_activate_a_bluetooth_device() { 192 | startAndAwaitBluetoothDevice() 193 | 194 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 195 | audioSwitch.selectDevice( 196 | audioSwitch.availableAudioDevices.find { 197 | it is AudioDevice.BluetoothHeadset 198 | }, 199 | ) 200 | assertEquals(expectedBluetoothDevice, audioSwitch.selectedAudioDevice) 201 | } 202 | 203 | assertTrue(bluetoothAudioStateConnected.count > 0) 204 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 205 | audioSwitch.activate() 206 | } 207 | assertFalse(isSpeakerPhoneOn()) 208 | assertTrue(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 209 | assertTrue(bluetoothHeadset.isAudioConnected(bluetoothHeadset.connectedDevices.first())) 210 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 211 | assertEquals(expectedBluetoothDevice, audioSwitch.selectedAudioDevice) 212 | } 213 | } 214 | 215 | @Test 216 | fun it_should_activate_another_audio_device_with_bluetooth_device_connected() { 217 | startAndAwaitBluetoothDevice() 218 | 219 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 220 | val expectedAudioDevice = audioSwitch.availableAudioDevices.find { it !is AudioDevice.BluetoothHeadset } 221 | audioSwitch.selectDevice(expectedAudioDevice) 222 | assertEquals(expectedAudioDevice, audioSwitch.selectedAudioDevice) 223 | } 224 | 225 | assertTrue(bluetoothAudioStateConnected.count > 0) 226 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 227 | audioSwitch.activate() 228 | } 229 | assertFalse(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 230 | assertFalse(bluetoothHeadset.isAudioConnected(bluetoothHeadset.connectedDevices.first())) 231 | 232 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 233 | assertTrue(audioSwitch.selectedAudioDevice !is AudioDevice.BluetoothHeadset) 234 | } 235 | } 236 | 237 | @Test 238 | fun it_should_automatically_activate_bluetooth_device_if_no_device_selected() { 239 | bluetoothAdapter.disable() 240 | retryAssertion { assertFalse(bluetoothAdapter.isEnabled) } 241 | val bluetoothDeviceConnected = CountDownLatch(1) 242 | lateinit var actualBluetoothDevice: AudioDevice 243 | 244 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 245 | audioSwitch.start { _, audioDevice -> 246 | if (audioDevice is AudioDevice.BluetoothHeadset) { 247 | actualBluetoothDevice = audioDevice 248 | bluetoothDeviceConnected.countDown() 249 | } 250 | } 251 | audioSwitch.activate() 252 | assertTrue(audioSwitch.selectedAudioDevice !is AudioDevice.BluetoothHeadset) 253 | } 254 | assertTrue(bluetoothAudioStateConnected.count > 0) 255 | assertFalse(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 256 | assertTrue(bluetoothHeadset.connectedDevices.isEmpty()) 257 | bluetoothAdapter.enable() 258 | assertTrue(bluetoothDeviceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 259 | assertEquals(expectedBluetoothDevice, actualBluetoothDevice) 260 | assertTrue(bluetoothAudioStateConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 261 | assertTrue(bluetoothHeadset.isAudioConnected(bluetoothHeadset.connectedDevices.first())) 262 | } 263 | 264 | private fun startAndAwaitBluetoothDevice(): AudioDevice { 265 | val bluetoothDeviceConnected = CountDownLatch(1) 266 | lateinit var actualBluetoothDevice: AudioDevice 267 | InstrumentationRegistry.getInstrumentation().runOnMainSync { 268 | audioSwitch.start { _, audioDevice -> 269 | if (audioDevice is AudioDevice.BluetoothHeadset) { 270 | actualBluetoothDevice = audioDevice 271 | bluetoothDeviceConnected.countDown() 272 | } 273 | } 274 | } 275 | 276 | assertTrue(bluetoothDeviceConnected.await(BLUETOOTH_TIMEOUT, TimeUnit.SECONDS)) 277 | 278 | return actualBluetoothDevice 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /audioswitch/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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 | 8 | /** The friendly name of the device.*/ 9 | abstract val name: String 10 | 11 | /** An [AudioDevice] representing a Bluetooth Headset.*/ 12 | data class BluetoothHeadset internal constructor(override val name: String = "Bluetooth") : AudioDevice() 13 | 14 | /** An [AudioDevice] representing a Wired Headset.*/ 15 | data class WiredHeadset internal constructor(override val name: String = "Wired Headset") : AudioDevice() 16 | 17 | /** An [AudioDevice] representing the Earpiece.*/ 18 | data class Earpiece internal constructor(override val name: String = "Earpiece") : AudioDevice() 19 | 20 | /** An [AudioDevice] representing the Speakerphone.*/ 21 | data class Speakerphone internal constructor(override val name: String = "Speakerphone") : AudioDevice() 22 | } 23 | -------------------------------------------------------------------------------- /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/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 | 25 | private var savedAudioMode = 0 26 | private var savedIsMicrophoneMuted = false 27 | private var savedSpeakerphoneEnabled = false 28 | private var audioRequest: AudioFocusRequest? = null 29 | 30 | fun hasEarpiece(): Boolean { 31 | val hasEarpiece = context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) 32 | if (hasEarpiece) { 33 | logger.d(TAG, "Earpiece available") 34 | } 35 | return hasEarpiece 36 | } 37 | 38 | @SuppressLint("NewApi") 39 | fun hasSpeakerphone(): Boolean { 40 | return if (build.getVersion() >= Build.VERSION_CODES.M && 41 | context.packageManager 42 | .hasSystemFeature(PackageManager.FEATURE_AUDIO_OUTPUT) 43 | ) { 44 | val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) 45 | for (device in devices) { 46 | if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { 47 | logger.d(TAG, "Speakerphone available") 48 | return true 49 | } 50 | } 51 | false 52 | } else { 53 | logger.d(TAG, "Speakerphone available") 54 | true 55 | } 56 | } 57 | 58 | @SuppressLint("NewApi") 59 | fun setAudioFocus() { 60 | // Request audio focus before making any device switch. 61 | if (build.getVersion() >= Build.VERSION_CODES.O) { 62 | audioRequest = audioFocusRequest.buildRequest(audioFocusChangeListener) 63 | audioRequest?.let { audioManager.requestAudioFocus(it) } 64 | } else { 65 | audioManager.requestAudioFocus( 66 | audioFocusChangeListener, 67 | AudioManager.STREAM_VOICE_CALL, 68 | AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, 69 | ) 70 | } 71 | /* 72 | * Start by setting MODE_IN_COMMUNICATION as default audio mode. It is 73 | * required to be in this mode when playout and/or recording starts for 74 | * best possible VoIP performance. Some devices have difficulties with speaker mode 75 | * if this is not set. 76 | */ 77 | audioManager.mode = AudioManager.MODE_IN_COMMUNICATION 78 | } 79 | 80 | fun enableBluetoothSco(enable: Boolean) { 81 | audioManager.run { if (enable) startBluetoothSco() else stopBluetoothSco() } 82 | } 83 | 84 | @SuppressLint("NewApi") 85 | fun enableSpeakerphone(enable: Boolean) { 86 | audioManager.isSpeakerphoneOn = enable 87 | 88 | /** 89 | * Some Samsung devices (reported Galaxy s9, s21) fail to route audio through USB headset 90 | * when in MODE_IN_COMMUNICATION 91 | */ 92 | if (!audioManager.isSpeakerphoneOn && "^SM-G(960|99)".toRegex().containsMatchIn(Build.MODEL)) { 93 | val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) 94 | for (device in devices) { 95 | if (device.type == AudioDeviceInfo.TYPE_USB_HEADSET) { 96 | audioManager.mode = AudioManager.MODE_NORMAL 97 | break 98 | } 99 | } 100 | } 101 | } 102 | 103 | fun mute(mute: Boolean) { 104 | audioManager.isMicrophoneMute = mute 105 | } 106 | 107 | // TODO Consider persisting audio state in the event of process death 108 | fun cacheAudioState() { 109 | savedAudioMode = audioManager.mode 110 | savedIsMicrophoneMuted = audioManager.isMicrophoneMute 111 | savedSpeakerphoneEnabled = audioManager.isSpeakerphoneOn 112 | } 113 | 114 | @SuppressLint("NewApi") 115 | fun restoreAudioState() { 116 | audioManager.mode = savedAudioMode 117 | mute(savedIsMicrophoneMuted) 118 | enableSpeakerphone(savedSpeakerphoneEnabled) 119 | if (build.getVersion() >= Build.VERSION_CODES.O) { 120 | audioRequest?.let { audioManager.abandonAudioFocusRequest(it) } 121 | } else { 122 | audioManager.abandonAudioFocus(audioFocusChangeListener) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /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 | 11 | @SuppressLint("NewApi") 12 | fun buildRequest(audioFocusChangeListener: OnAudioFocusChangeListener): AudioFocusRequest { 13 | val playbackAttributes = AudioAttributes.Builder() 14 | .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) 15 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) 16 | .build() 17 | return AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) 18 | .setAudioAttributes(playbackAttributes) 19 | .setAcceptsDelayedFocusGain(true) 20 | .setOnAudioFocusChangeListener(audioFocusChangeListener) 21 | .build() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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.bluetooth.BluetoothManager 6 | import android.content.Context 7 | import android.content.pm.PackageManager.PERMISSION_GRANTED 8 | import android.media.AudioManager 9 | import android.media.AudioManager.OnAudioFocusChangeListener 10 | import androidx.annotation.VisibleForTesting 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 com.twilio.audioswitch.AudioSwitch.State.ACTIVATED 16 | import com.twilio.audioswitch.AudioSwitch.State.STARTED 17 | import com.twilio.audioswitch.AudioSwitch.State.STOPPED 18 | import com.twilio.audioswitch.android.Logger 19 | import com.twilio.audioswitch.android.PermissionsCheckStrategy 20 | import com.twilio.audioswitch.android.ProductionLogger 21 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener 22 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager 23 | import com.twilio.audioswitch.wired.WiredDeviceConnectionListener 24 | import com.twilio.audioswitch.wired.WiredHeadsetReceiver 25 | 26 | private const val TAG = "AudioSwitch" 27 | private const val PERMISSION_ERROR_MESSAGE = "Bluetooth unsupported, permissions not granted" 28 | 29 | /** 30 | * This class enables developers to enumerate available audio devices and select which device audio 31 | * should be routed to. It is strongly recommended that instances of this class are created and 32 | * accessed from a single application thread. Accessing an instance from multiple threads may cause 33 | * synchronization problems. 34 | * 35 | * @property bluetoothHeadsetConnectionListener Requires bluetooth permission. Listener to notify if Bluetooth device state has 36 | * changed (connect, disconnect, audio connect, audio disconnect) or failed to connect. Null by default. 37 | * @property loggingEnabled A property to configure AudioSwitch logging behavior. AudioSwitch logging is disabled by 38 | * default. 39 | * @property selectedAudioDevice Retrieves the selected [AudioDevice] from [AudioSwitch.selectDevice]. 40 | * @property availableAudioDevices Retrieves the current list of available [AudioDevice]s. 41 | **/ 42 | class AudioSwitch { 43 | private val context: Context 44 | private var logger: Logger = ProductionLogger() 45 | private val audioDeviceManager: AudioDeviceManager 46 | private val wiredHeadsetReceiver: WiredHeadsetReceiver 47 | internal var audioDeviceChangeListener: AudioDeviceChangeListener? = null 48 | private var selectedDevice: AudioDevice? = null 49 | private var userSelectedDevice: AudioDevice? = null 50 | private var wiredHeadsetAvailable = false 51 | private val mutableAudioDevices = ArrayList() 52 | private var bluetoothHeadsetManager: BluetoothHeadsetManager? = null 53 | private val preferredDeviceList: List> 54 | private var bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null 55 | private val permissionsRequestStrategy: PermissionsCheckStrategy 56 | 57 | internal var state: State = STOPPED 58 | internal enum class State { 59 | STARTED, ACTIVATED, STOPPED 60 | } 61 | 62 | internal val bluetoothDeviceConnectionListener = object : BluetoothHeadsetConnectionListener { 63 | override fun onBluetoothHeadsetStateChanged(headsetName: String?, state: Int) { 64 | enumerateDevices(headsetName) 65 | bluetoothHeadsetConnectionListener?.onBluetoothHeadsetStateChanged(headsetName, state) 66 | } 67 | 68 | override fun onBluetoothScoStateChanged(state: Int) { 69 | bluetoothHeadsetConnectionListener?.onBluetoothScoStateChanged(state) 70 | } 71 | 72 | override fun onBluetoothHeadsetActivationError() { 73 | if (userSelectedDevice is BluetoothHeadset) userSelectedDevice = null 74 | enumerateDevices() 75 | bluetoothHeadsetConnectionListener?.onBluetoothHeadsetActivationError() 76 | } 77 | } 78 | 79 | internal val wiredDeviceConnectionListener = object : WiredDeviceConnectionListener { 80 | override fun onDeviceConnected() { 81 | wiredHeadsetAvailable = true 82 | enumerateDevices() 83 | } 84 | 85 | override fun onDeviceDisconnected() { 86 | wiredHeadsetAvailable = false 87 | enumerateDevices() 88 | } 89 | } 90 | 91 | var loggingEnabled: Boolean 92 | get() = logger.loggingEnabled 93 | 94 | set(value) { 95 | logger.loggingEnabled = value 96 | } 97 | val selectedAudioDevice: AudioDevice? get() = selectedDevice 98 | val availableAudioDevices: List = mutableAudioDevices 99 | 100 | /** 101 | * Constructs a new AudioSwitch instance. 102 | * - [context] - An Android Context. 103 | * - [bluetoothHeadsetConnectionListener] - A listener to notify if Bluetooth device state has 104 | * changed (connect, disconnect, audio connect, audio disconnect) or failed to connect. Null by default 105 | * - [loggingEnabled] - Toggle whether logging is enabled. This argument is false by default. 106 | * - [audioFocusChangeListener] - A listener that is invoked when the system audio focus is updated. 107 | * Note that updates are only sent to the listener after [activate] has been called. 108 | * - [preferredDeviceList] - The order in which [AudioSwitch] automatically selects and activates 109 | * an [AudioDevice]. This parameter is ignored if the [selectedAudioDevice] is not `null`. 110 | * The default preferred [AudioDevice] order is the following: 111 | * [BluetoothHeadset], [WiredHeadset], [Earpiece], [Speakerphone] 112 | * . The [preferredDeviceList] is added to the front of the default list. For example, if [preferredDeviceList] 113 | * is [Speakerphone] and [BluetoothHeadset], then the new preferred audio 114 | * device list will be: 115 | * [Speakerphone], [BluetoothHeadset], [WiredHeadset], [Earpiece]. 116 | * An [IllegalArgumentException] is thrown if the [preferredDeviceList] contains duplicate [AudioDevice] elements. 117 | */ 118 | @JvmOverloads 119 | constructor( 120 | context: Context, 121 | bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null, 122 | loggingEnabled: Boolean = false, 123 | audioFocusChangeListener: OnAudioFocusChangeListener = OnAudioFocusChangeListener {}, 124 | preferredDeviceList: List> = defaultPreferredDeviceList, 125 | ) : this( 126 | context.applicationContext, 127 | bluetoothHeadsetConnectionListener, 128 | ProductionLogger(loggingEnabled), 129 | audioFocusChangeListener, 130 | preferredDeviceList, 131 | ) 132 | 133 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) 134 | internal constructor( 135 | context: Context, 136 | bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener?, 137 | logger: Logger, 138 | audioFocusChangeListener: OnAudioFocusChangeListener, 139 | preferredDeviceList: List>, 140 | audioDeviceManager: AudioDeviceManager = AudioDeviceManager( 141 | context, 142 | logger, 143 | context.getSystemService(Context.AUDIO_SERVICE) as AudioManager, 144 | audioFocusChangeListener = audioFocusChangeListener, 145 | ), 146 | wiredHeadsetReceiver: WiredHeadsetReceiver = WiredHeadsetReceiver(context, logger), 147 | permissionsCheckStrategy: PermissionsCheckStrategy = DefaultPermissionsCheckStrategy(context), 148 | bluetoothManager: BluetoothManager? = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager, 149 | bluetoothHeadsetManager: BluetoothHeadsetManager? = BluetoothHeadsetManager.newInstance( 150 | context, 151 | logger, 152 | bluetoothManager?.adapter, 153 | audioDeviceManager, 154 | ), 155 | ) { 156 | this.context = context 157 | this.logger = logger 158 | this.bluetoothHeadsetConnectionListener = bluetoothHeadsetConnectionListener 159 | this.audioDeviceManager = audioDeviceManager 160 | this.wiredHeadsetReceiver = wiredHeadsetReceiver 161 | this.preferredDeviceList = getPreferredDeviceList(preferredDeviceList) 162 | this.permissionsRequestStrategy = permissionsCheckStrategy 163 | this.bluetoothHeadsetManager = if (hasPermissions()) { 164 | bluetoothHeadsetManager 165 | } else { 166 | logger.w(TAG, PERMISSION_ERROR_MESSAGE) 167 | null 168 | } 169 | logger.d(TAG, "AudioSwitch($VERSION)") 170 | logger.d(TAG, "Preferred device list = ${this.preferredDeviceList.map { it.simpleName }}") 171 | } 172 | 173 | private fun getPreferredDeviceList(preferredDeviceList: List>): List> { 174 | require(hasNoDuplicates(preferredDeviceList)) 175 | 176 | return if (preferredDeviceList.isEmpty() || preferredDeviceList == defaultPreferredDeviceList) { 177 | defaultPreferredDeviceList 178 | } else { 179 | val result = defaultPreferredDeviceList.toMutableList() 180 | result.removeAll(preferredDeviceList) 181 | preferredDeviceList.forEachIndexed { index, device -> 182 | result.add(index, device) 183 | } 184 | result 185 | } 186 | } 187 | 188 | /** 189 | * Starts listening for audio device changes and calls the [listener] upon each change if listener provided. 190 | * **Note:** When audio device listening is no longer needed, [AudioSwitch.stop] should be 191 | * called in order to prevent a memory leak. 192 | */ 193 | fun start(listener: AudioDeviceChangeListener? = null) { 194 | listener?.let { 195 | audioDeviceChangeListener = it 196 | } 197 | when (state) { 198 | STOPPED -> { 199 | state = STARTED 200 | enumerateDevices() 201 | bluetoothHeadsetManager.instance()?.start(bluetoothDeviceConnectionListener) 202 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener) 203 | } 204 | else -> { 205 | logger.d(TAG, "Redundant start() invocation while already in the started or activated state") 206 | } 207 | } 208 | } 209 | 210 | /** 211 | * Adds [AudioDeviceChangeListener] and starts listening for audio devices changes and calls, 212 | * or null to stop listening for audio device changes. 213 | */ 214 | fun setAudioDeviceChangeListener(listener: AudioDeviceChangeListener?) { 215 | audioDeviceChangeListener = listener 216 | } 217 | 218 | /** 219 | * Stops listening for audio device changes if [AudioSwitch.start] has already been 220 | * invoked. [AudioSwitch.deactivate] will also get called if a device has been activated 221 | * with [AudioSwitch.activate]. 222 | */ 223 | fun stop() { 224 | when (state) { 225 | ACTIVATED -> { 226 | deactivate() 227 | closeListeners() 228 | } 229 | STARTED -> { 230 | closeListeners() 231 | } 232 | STOPPED -> { 233 | logger.d(TAG, "Redundant stop() invocation while already in the stopped state") 234 | } 235 | } 236 | } 237 | 238 | /** 239 | * Performs audio routing and unmuting on the selected device from 240 | * [AudioSwitch.selectDevice]. Audio focus is also acquired for the client application. 241 | * **Note:** [AudioSwitch.deactivate] should be invoked to restore the prior audio 242 | * state. 243 | */ 244 | fun activate() { 245 | when (state) { 246 | STARTED -> { 247 | state = ACTIVATED 248 | audioDeviceManager.cacheAudioState() 249 | 250 | // Always set mute to false for WebRTC 251 | audioDeviceManager.mute(false) 252 | audioDeviceManager.setAudioFocus() 253 | selectedDevice?.let { activate(it) } 254 | } 255 | ACTIVATED -> selectedDevice?.let { activate(it) } 256 | STOPPED -> throw IllegalStateException() 257 | } 258 | } 259 | 260 | /** 261 | * Restores the audio state prior to calling [AudioSwitch.activate] and removes 262 | * audio focus from the client application. 263 | */ 264 | fun deactivate() { 265 | when (state) { 266 | ACTIVATED -> { 267 | state = STARTED 268 | bluetoothHeadsetManager.instance()?.deactivate() 269 | 270 | // Restore stored audio state 271 | audioDeviceManager.restoreAudioState() 272 | } 273 | STARTED, STOPPED -> { 274 | } 275 | } 276 | } 277 | 278 | /** 279 | * Selects the desired [audioDevice]. If the provided [AudioDevice] is not 280 | * available, no changes are made. If the provided device is null, one is chosen based on the 281 | * specified preferred device list or the following default list: 282 | * [BluetoothHeadset], [WiredHeadset], [Earpiece], [Speakerphone]. 283 | */ 284 | fun selectDevice(audioDevice: AudioDevice?) { 285 | if (selectedDevice != audioDevice) { 286 | logger.d(TAG, "Selected AudioDevice = $audioDevice") 287 | userSelectedDevice = audioDevice 288 | enumerateDevices() 289 | } 290 | } 291 | 292 | private fun hasNoDuplicates(list: List>) = 293 | list.groupingBy { it }.eachCount().filter { it.value > 1 }.isEmpty() 294 | 295 | private fun activate(audioDevice: AudioDevice) { 296 | when (audioDevice) { 297 | is BluetoothHeadset -> { 298 | audioDeviceManager.enableSpeakerphone(false) 299 | bluetoothHeadsetManager.instance()?.activate() 300 | } 301 | is Earpiece, is WiredHeadset -> { 302 | audioDeviceManager.enableSpeakerphone(false) 303 | bluetoothHeadsetManager.instance()?.deactivate() 304 | } 305 | is Speakerphone -> { 306 | audioDeviceManager.enableSpeakerphone(true) 307 | bluetoothHeadsetManager.instance()?.deactivate() 308 | } 309 | } 310 | } 311 | 312 | internal data class AudioDeviceState( 313 | val audioDeviceList: List, 314 | val selectedAudioDevice: AudioDevice?, 315 | ) 316 | 317 | private fun enumerateDevices(bluetoothHeadsetName: String? = null) { 318 | // save off the old state and 'semi'-deep copy the list of audio devices 319 | val oldAudioDeviceState = AudioDeviceState(mutableAudioDevices.map { it }, selectedDevice) 320 | // update audio device list and selected device 321 | addAvailableAudioDevices(bluetoothHeadsetName) 322 | 323 | if (!userSelectedDevicePresent(mutableAudioDevices)) { 324 | userSelectedDevice = null 325 | } 326 | 327 | // Select the audio device 328 | logger.d(TAG, "Current user selected AudioDevice = $userSelectedDevice") 329 | selectedDevice = if (userSelectedDevice != null) { 330 | userSelectedDevice 331 | } else if (mutableAudioDevices.size > 0) { 332 | val firstAudioDevice = mutableAudioDevices[0] 333 | /* 334 | * If there was an error starting bluetooth sco, then the selected AudioDevice should 335 | * be the next valid device in the list. 336 | */ 337 | if (firstAudioDevice is BluetoothHeadset && 338 | bluetoothHeadsetManager.instance()?.hasActivationError() == true 339 | ) { 340 | mutableAudioDevices[1] 341 | } else { 342 | firstAudioDevice 343 | } 344 | } else { 345 | null 346 | } 347 | 348 | // Activate the device if in the active state 349 | if (state == ACTIVATED) { 350 | activate() 351 | } 352 | // trigger audio device change listener if there has been a change 353 | val newAudioDeviceState = AudioDeviceState(mutableAudioDevices, selectedDevice) 354 | if (newAudioDeviceState != oldAudioDeviceState) { 355 | audioDeviceChangeListener?.invoke(mutableAudioDevices, selectedDevice) 356 | } 357 | } 358 | 359 | private fun addAvailableAudioDevices(bluetoothHeadsetName: String?) { 360 | mutableAudioDevices.clear() 361 | preferredDeviceList.forEach { audioDevice -> 362 | when (audioDevice) { 363 | BluetoothHeadset::class.java -> { 364 | /* 365 | * Since the there is a delay between receiving the ACTION_ACL_CONNECTED event and receiving 366 | * the name of the connected device from querying the BluetoothHeadset proxy class, the 367 | * headset name received from the ACTION_ACL_CONNECTED intent needs to be passed into this 368 | * function. 369 | */ 370 | bluetoothHeadsetManager.instance()?.getHeadset(bluetoothHeadsetName)?.let { 371 | mutableAudioDevices.add(it) 372 | } 373 | } 374 | WiredHeadset::class.java -> { 375 | if (wiredHeadsetAvailable) { 376 | mutableAudioDevices.add(WiredHeadset()) 377 | } 378 | } 379 | Earpiece::class.java -> { 380 | if (audioDeviceManager.hasEarpiece() && !wiredHeadsetAvailable) { 381 | mutableAudioDevices.add(Earpiece()) 382 | } 383 | } 384 | Speakerphone::class.java -> { 385 | if (audioDeviceManager.hasSpeakerphone()) { 386 | mutableAudioDevices.add(Speakerphone()) 387 | } 388 | } 389 | } 390 | } 391 | 392 | logger.d(TAG, "Available AudioDevice list updated: $availableAudioDevices") 393 | } 394 | 395 | private fun userSelectedDevicePresent(audioDevices: List) = 396 | userSelectedDevice?.let { selectedDevice -> 397 | if (selectedDevice is BluetoothHeadset) { 398 | // Match any bluetooth headset as a new one may have been connected 399 | audioDevices.find { it is BluetoothHeadset }?.let { newHeadset -> 400 | userSelectedDevice = newHeadset 401 | true 402 | } ?: false 403 | } else { 404 | audioDevices.contains(selectedDevice) 405 | } 406 | } ?: false 407 | 408 | private fun closeListeners() { 409 | state = STOPPED 410 | bluetoothHeadsetManager.instance()?.stop() 411 | wiredHeadsetReceiver.stop() 412 | audioDeviceChangeListener = null 413 | } 414 | 415 | private fun BluetoothHeadsetManager?.instance(): BluetoothHeadsetManager? { 416 | if (this == null && hasPermissions()) { 417 | val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager 418 | return BluetoothHeadsetManager.newInstance( 419 | context, 420 | logger, 421 | bluetoothManager?.adapter, 422 | audioDeviceManager, 423 | ) 424 | } 425 | return this 426 | } 427 | 428 | internal fun hasPermissions() = permissionsRequestStrategy.hasPermissions() 429 | 430 | internal class DefaultPermissionsCheckStrategy(private val context: Context) : PermissionsCheckStrategy { 431 | 432 | @SuppressLint("NewApi") 433 | override fun hasPermissions(): Boolean { 434 | return if (context.applicationInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.R || 435 | android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.R 436 | ) { 437 | PERMISSION_GRANTED == context.checkPermission( 438 | Manifest.permission.BLUETOOTH, 439 | android.os.Process.myPid(), 440 | android.os.Process.myUid(), 441 | ) 442 | } else { 443 | // for android 12/S or newer 444 | PERMISSION_GRANTED == context.checkPermission( 445 | Manifest.permission.BLUETOOTH_CONNECT, 446 | android.os.Process.myPid(), 447 | android.os.Process.myUid(), 448 | ) 449 | } 450 | } 451 | } 452 | 453 | companion object { 454 | /** 455 | * The version of the AudioSwitch library. 456 | */ 457 | const val VERSION = BuildConfig.VERSION_NAME 458 | 459 | private val defaultPreferredDeviceList by lazy { 460 | listOf( 461 | BluetoothHeadset::class.java, 462 | WiredHeadset::class.java, 463 | Earpiece::class.java, 464 | Speakerphone::class.java, 465 | ) 466 | } 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /audioswitch/src/main/java/com/twilio/audioswitch/android/BluetoothDeviceWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.twilio.audioswitch.android 2 | 3 | internal interface BluetoothDeviceWrapper { 4 | 5 | val name: String 6 | val deviceClass: Int? 7 | } 8 | -------------------------------------------------------------------------------- /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/android/BluetoothIntentProcessor.kt: -------------------------------------------------------------------------------- 1 | package com.twilio.audioswitch.android 2 | 3 | import android.content.Intent 4 | 5 | internal interface BluetoothIntentProcessor { 6 | 7 | fun getBluetoothDevice(intent: Intent): BluetoothDeviceWrapper? 8 | } 9 | -------------------------------------------------------------------------------- /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 | 7 | internal class BluetoothIntentProcessorImpl : BluetoothIntentProcessor { 8 | 9 | override fun getBluetoothDevice(intent: Intent): BluetoothDeviceWrapper? = 10 | intent.getParcelableExtra(EXTRA_DEVICE) 11 | ?.let { device -> BluetoothDeviceWrapperImpl(device) } 12 | } 13 | -------------------------------------------------------------------------------- /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 | 7 | fun getVersion(): Int = Build.VERSION.SDK_INT 8 | } 9 | -------------------------------------------------------------------------------- /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 | fun d(tag: String, message: String) 6 | fun w(tag: String, message: String) 7 | fun e(tag: String, message: String) 8 | fun e(tag: String, message: String, throwable: Throwable) 9 | } 10 | -------------------------------------------------------------------------------- /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/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(override var loggingEnabled: Boolean = false) : Logger { 8 | 9 | override fun d(tag: String, message: String) { 10 | if (loggingEnabled) { 11 | Log.d(createTag(tag), message) 12 | } 13 | } 14 | 15 | override fun w(tag: String, message: String) { 16 | if (loggingEnabled) { 17 | Log.w(createTag(tag), message) 18 | } 19 | } 20 | 21 | override fun e(tag: String, message: String) { 22 | if (loggingEnabled) { 23 | Log.e(createTag(tag), message) 24 | } 25 | } 26 | 27 | override fun e(tag: String, message: String, throwable: Throwable) { 28 | if (loggingEnabled) { 29 | Log.e(createTag(tag), message, throwable) 30 | } 31 | } 32 | 33 | private fun createTag(tag: String): String { 34 | return "$TAG_PREFIX$tag" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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 | 7 | fun elapsedRealtime() = SystemClock.elapsedRealtime() 8 | } 9 | -------------------------------------------------------------------------------- /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 | /** 12 | * @param headsetName name of the headset 13 | * @param state provided by [BluetoothHeadset] 14 | */ 15 | fun onBluetoothHeadsetStateChanged(headsetName: String? = null, state: Int = 0) 16 | 17 | /** 18 | * @param state provided by [AudioManager] 19 | */ 20 | fun onBluetoothScoStateChanged(state: Int = 0) 21 | 22 | /** 23 | * Triggered when Bluetooth SCO job has timed out. 24 | */ 25 | fun onBluetoothHeadsetActivationError() 26 | } 27 | -------------------------------------------------------------------------------- /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 | ) : BluetoothProfile.ServiceListener, BroadcastReceiver() { 55 | 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 = EnableBluetoothScoJob( 68 | logger, 69 | audioDeviceManager, 70 | bluetoothScoHandler, 71 | systemClockWrapper, 72 | ) 73 | 74 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) 75 | internal val disableBluetoothScoJob: DisableBluetoothScoJob = DisableBluetoothScoJob( 76 | logger, 77 | audioDeviceManager, 78 | bluetoothScoHandler, 79 | systemClockWrapper, 80 | ) 81 | 82 | companion object { 83 | internal fun newInstance( 84 | context: Context, 85 | logger: Logger, 86 | bluetoothAdapter: BluetoothAdapter?, 87 | audioDeviceManager: AudioDeviceManager, 88 | ): BluetoothHeadsetManager? { 89 | return bluetoothAdapter?.let { adapter -> 90 | BluetoothHeadsetManager(context, logger, adapter, audioDeviceManager) 91 | } ?: run { 92 | logger.d(TAG, "Bluetooth is not supported on this device") 93 | null 94 | } 95 | } 96 | } 97 | 98 | @SuppressLint("MissingPermission") 99 | override fun onServiceConnected(profile: Int, bluetoothProfile: BluetoothProfile) { 100 | headsetProxy = bluetoothProfile as BluetoothHeadset 101 | bluetoothProfile.connectedDevices.forEach { device -> 102 | logger.d(TAG, "Bluetooth " + device.name + " connected") 103 | } 104 | if (hasConnectedDevice()) { 105 | connect() 106 | headsetListener?.onBluetoothHeadsetStateChanged(getHeadsetName()) 107 | } 108 | } 109 | 110 | override fun onServiceDisconnected(profile: Int) { 111 | logger.d(TAG, "Bluetooth disconnected") 112 | headsetState = Disconnected 113 | headsetListener?.onBluetoothHeadsetStateChanged() 114 | } 115 | 116 | override fun onReceive(context: Context, intent: Intent) { 117 | if (isCorrectIntentAction(intent.action)) { 118 | intent.getHeadsetDevice()?.let { bluetoothDevice -> 119 | intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, STATE_DISCONNECTED).let { state -> 120 | when (state) { 121 | STATE_CONNECTED -> { 122 | logger.d( 123 | TAG, 124 | "Bluetooth headset $bluetoothDevice connected", 125 | ) 126 | connect() 127 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_CONNECTED) 128 | } 129 | STATE_DISCONNECTED -> { 130 | logger.d( 131 | TAG, 132 | "Bluetooth headset $bluetoothDevice disconnected", 133 | ) 134 | disconnect() 135 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_DISCONNECTED) 136 | } 137 | STATE_AUDIO_CONNECTED -> { 138 | logger.d(TAG, "Bluetooth audio connected on device $bluetoothDevice") 139 | enableBluetoothScoJob.cancelBluetoothScoJob() 140 | headsetState = AudioActivated 141 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_AUDIO_CONNECTED) 142 | } 143 | STATE_AUDIO_DISCONNECTED -> { 144 | logger.d(TAG, "Bluetooth audio disconnected on device $bluetoothDevice") 145 | disableBluetoothScoJob.cancelBluetoothScoJob() 146 | /* 147 | * This block is needed to restart bluetooth SCO in the event that 148 | * the active bluetooth headset has changed. 149 | */ 150 | if (hasActiveHeadsetChanged()) { 151 | enableBluetoothScoJob.executeBluetoothScoJob() 152 | } 153 | 154 | headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_AUDIO_DISCONNECTED) 155 | } 156 | else -> {} 157 | } 158 | } 159 | } 160 | intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, SCO_AUDIO_STATE_DISCONNECTED).let { state -> 161 | when (state) { 162 | SCO_AUDIO_STATE_CONNECTING -> { 163 | logger.d( 164 | TAG, 165 | "Bluetooth SCO connecting", 166 | ) 167 | 168 | headsetListener?.onBluetoothScoStateChanged( 169 | SCO_AUDIO_STATE_CONNECTING, 170 | ) 171 | } 172 | SCO_AUDIO_STATE_CONNECTED -> { 173 | logger.d( 174 | TAG, 175 | "Bluetooth SCO connected", 176 | ) 177 | 178 | headsetListener?.onBluetoothScoStateChanged( 179 | SCO_AUDIO_STATE_CONNECTED, 180 | ) 181 | } 182 | SCO_AUDIO_STATE_DISCONNECTED -> { 183 | logger.d( 184 | TAG, 185 | "Bluetooth SCO disconnected", 186 | ) 187 | 188 | headsetListener?.onBluetoothScoStateChanged( 189 | SCO_AUDIO_STATE_DISCONNECTED, 190 | ) 191 | } 192 | else -> {} 193 | } 194 | } 195 | } 196 | } 197 | 198 | fun start(headsetListener: BluetoothHeadsetConnectionListener) { 199 | this.headsetListener = headsetListener 200 | 201 | bluetoothAdapter.getProfileProxy( 202 | context, 203 | this, 204 | BluetoothProfile.HEADSET, 205 | ) 206 | if (!hasRegisteredReceivers) { 207 | context.registerReceiver( 208 | this, 209 | IntentFilter(ACTION_CONNECTION_STATE_CHANGED), 210 | ) 211 | context.registerReceiver( 212 | this, 213 | IntentFilter(ACTION_AUDIO_STATE_CHANGED), 214 | ) 215 | context.registerReceiver( 216 | this, 217 | IntentFilter(ACTION_SCO_AUDIO_STATE_UPDATED), 218 | ) 219 | hasRegisteredReceivers = true 220 | } 221 | } 222 | 223 | fun stop() { 224 | headsetListener = null 225 | bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy) 226 | if (hasRegisteredReceivers) { 227 | context.unregisterReceiver(this) 228 | hasRegisteredReceivers = false 229 | } 230 | } 231 | 232 | fun activate() { 233 | if (headsetState == Connected || headsetState == AudioActivationError) { 234 | enableBluetoothScoJob.executeBluetoothScoJob() 235 | } else { 236 | logger.w(TAG, "Cannot activate when in the ${headsetState::class.simpleName} state") 237 | } 238 | } 239 | 240 | fun deactivate() { 241 | if (headsetState == AudioActivated) { 242 | disableBluetoothScoJob.executeBluetoothScoJob() 243 | } else { 244 | logger.w(TAG, "Cannot deactivate when in the ${headsetState::class.simpleName} state") 245 | } 246 | } 247 | 248 | fun hasActivationError(): Boolean { 249 | return headsetState == AudioActivationError 250 | } 251 | 252 | // TODO Remove bluetoothHeadsetName param 253 | fun getHeadset(bluetoothHeadsetName: String?): AudioDevice.BluetoothHeadset? { 254 | return if (headsetState != Disconnected) { 255 | val headsetName = bluetoothHeadsetName ?: getHeadsetName() 256 | headsetName?.let { AudioDevice.BluetoothHeadset(it) } 257 | ?: AudioDevice.BluetoothHeadset() 258 | } else { 259 | null 260 | } 261 | } 262 | 263 | private fun isCorrectIntentAction(intentAction: String?) = 264 | intentAction == ACTION_CONNECTION_STATE_CHANGED || intentAction == ACTION_AUDIO_STATE_CHANGED || intentAction == ACTION_SCO_AUDIO_STATE_UPDATED 265 | 266 | private fun connect() { 267 | if (!hasActiveHeadset()) headsetState = Connected 268 | } 269 | 270 | private fun disconnect() { 271 | headsetState = when { 272 | hasActiveHeadset() -> { 273 | AudioActivated 274 | } 275 | hasConnectedDevice() -> { 276 | Connected 277 | } 278 | else -> { 279 | Disconnected 280 | } 281 | } 282 | } 283 | 284 | private fun hasActiveHeadsetChanged() = headsetState == AudioActivated && hasConnectedDevice() && !hasActiveHeadset() 285 | 286 | @SuppressLint("MissingPermission") 287 | private fun getHeadsetName(): String? = 288 | headsetProxy?.let { proxy -> 289 | proxy.connectedDevices?.let { devices -> 290 | when { 291 | devices.size > 1 && hasActiveHeadset() -> { 292 | val device = devices.find { proxy.isAudioConnected(it) }?.name 293 | logger.d(TAG, "Device size > 1 with device name: $device") 294 | device 295 | } 296 | devices.size == 1 -> { 297 | val device = devices.first().name 298 | logger.d(TAG, "Device size 1 with device name: $device") 299 | device 300 | } 301 | else -> { 302 | logger.d(TAG, "Device size 0") 303 | null 304 | } 305 | } 306 | } 307 | } 308 | 309 | @SuppressLint("MissingPermission") 310 | private fun hasActiveHeadset() = 311 | headsetProxy?.let { proxy -> 312 | proxy.connectedDevices?.let { devices -> 313 | devices.any { proxy.isAudioConnected(it) } 314 | } 315 | } ?: false 316 | 317 | @SuppressLint("MissingPermission") 318 | private fun hasConnectedDevice() = 319 | headsetProxy?.let { proxy -> 320 | proxy.connectedDevices?.let { devices -> 321 | devices.isNotEmpty() 322 | } 323 | } ?: false 324 | 325 | private fun Intent.getHeadsetDevice(): BluetoothDeviceWrapper? = 326 | bluetoothIntentProcessor.getBluetoothDevice(this)?.let { device -> 327 | if (isHeadsetDevice(device)) device else null 328 | } 329 | 330 | private fun isHeadsetDevice(deviceWrapper: BluetoothDeviceWrapper): Boolean = 331 | deviceWrapper.deviceClass?.let { deviceClass -> 332 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE || 333 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_WEARABLE_HEADSET || 334 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO || 335 | deviceClass == BluetoothClass.Device.AUDIO_VIDEO_HEADPHONES || 336 | deviceClass == BluetoothClass.Device.Major.UNCATEGORIZED 337 | } ?: false 338 | 339 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) 340 | internal sealed class HeadsetState { 341 | object Disconnected : HeadsetState() 342 | object Connected : HeadsetState() 343 | object AudioActivating : HeadsetState() 344 | object AudioActivationError : HeadsetState() 345 | object AudioActivated : HeadsetState() 346 | } 347 | 348 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) 349 | internal inner class EnableBluetoothScoJob( 350 | private val logger: Logger, 351 | private val audioDeviceManager: AudioDeviceManager, 352 | bluetoothScoHandler: Handler, 353 | systemClockWrapper: SystemClockWrapper, 354 | ) : BluetoothScoJob(logger, bluetoothScoHandler, systemClockWrapper) { 355 | 356 | override fun scoAction() { 357 | logger.d(TAG, "Attempting to enable bluetooth SCO") 358 | audioDeviceManager.enableBluetoothSco(true) 359 | headsetState = AudioActivating 360 | } 361 | 362 | override fun scoTimeOutAction() { 363 | headsetState = AudioActivationError 364 | headsetListener?.onBluetoothHeadsetActivationError() 365 | } 366 | } 367 | 368 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) 369 | internal inner class DisableBluetoothScoJob( 370 | private val logger: Logger, 371 | private val audioDeviceManager: AudioDeviceManager, 372 | bluetoothScoHandler: Handler, 373 | systemClockWrapper: SystemClockWrapper, 374 | ) : BluetoothScoJob(logger, bluetoothScoHandler, systemClockWrapper) { 375 | 376 | override fun scoAction() { 377 | logger.d(TAG, "Attempting to disable bluetooth SCO") 378 | audioDeviceManager.enableBluetoothSco(false) 379 | headsetState = Connected 380 | } 381 | 382 | override fun scoTimeOutAction() { 383 | headsetState = AudioActivationError 384 | } 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /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 | 18 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) 19 | var bluetoothScoRunnable: BluetoothScoRunnable? = null 20 | 21 | protected abstract fun scoAction() 22 | 23 | open fun scoTimeOutAction() {} 24 | 25 | fun executeBluetoothScoJob() { 26 | // cancel existing runnable 27 | bluetoothScoRunnable?.let { bluetoothScoHandler.removeCallbacks(it) } 28 | 29 | BluetoothScoRunnable().apply { 30 | bluetoothScoRunnable = this 31 | bluetoothScoHandler.post(this) 32 | } 33 | logger.d(TAG, "Scheduled bluetooth sco job") 34 | } 35 | 36 | fun cancelBluetoothScoJob() { 37 | bluetoothScoRunnable?.let { 38 | bluetoothScoHandler.removeCallbacks(it) 39 | bluetoothScoRunnable = null 40 | logger.d(TAG, "Canceled bluetooth sco job") 41 | } 42 | } 43 | 44 | inner class BluetoothScoRunnable : Runnable { 45 | 46 | private val startTime = systemClockWrapper.elapsedRealtime() 47 | private var elapsedTime = 0L 48 | 49 | override fun run() { 50 | if (elapsedTime < TIMEOUT) { 51 | scoAction() 52 | elapsedTime = systemClockWrapper.elapsedRealtime() - startTime 53 | bluetoothScoHandler.postDelayed(this, 500) 54 | } else { 55 | logger.e(TAG, "Bluetooth sco job timed out", TimeoutException()) 56 | scoTimeOutAction() 57 | cancelBluetoothScoJob() 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /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 | fun onDeviceDisconnected() 6 | } 7 | -------------------------------------------------------------------------------- /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 | 20 | internal var deviceListener: WiredDeviceConnectionListener? = null 21 | 22 | override fun onReceive(context: Context, intent: Intent) { 23 | intent.getIntExtra(INTENT_STATE, STATE_UNPLUGGED).let { state -> 24 | if (state == STATE_PLUGGED) { 25 | intent.getStringExtra(INTENT_NAME).let { name -> 26 | logger.d(TAG, "Wired headset device ${name ?: ""} connected") 27 | } 28 | deviceListener?.onDeviceConnected() 29 | } else { 30 | intent.getStringExtra(INTENT_NAME).let { name -> 31 | logger.d(TAG, "Wired headset device ${name ?: ""} disconnected") 32 | } 33 | deviceListener?.onDeviceDisconnected() 34 | } 35 | } 36 | } 37 | 38 | fun start(deviceListener: WiredDeviceConnectionListener) { 39 | this.deviceListener = deviceListener 40 | context.registerReceiver(this, IntentFilter(Intent.ACTION_HEADSET_PLUG)) 41 | } 42 | 43 | fun stop() { 44 | deviceListener = null 45 | context.unregisterReceiver(this) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /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$audioswitch_debug().getPackageManager()).thenReturn(packageManager); 34 | javaAudioSwitch = 35 | new AudioSwitch( 36 | getContext$audioswitch_debug(), 37 | null, 38 | new UnitTestLogger(false), 39 | getDefaultAudioFocusChangeListener$audioswitch_debug(), 40 | getPreferredDeviceList$audioswitch_debug(), 41 | getAudioDeviceManager$audioswitch_debug(), 42 | getWiredHeadsetReceiver$audioswitch_debug(), 43 | getPermissionsStrategyProxy$audioswitch_debug(), 44 | getBluetoothManager$audioswitch_debug(), 45 | getHeadsetManager$audioswitch_debug()); 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$audioswitch_debug(), 136 | getBluetoothListener$audioswitch_debug(), 137 | getLogger$audioswitch_debug(), 138 | getDefaultAudioFocusChangeListener$audioswitch_debug(), 139 | preferredDeviceList, 140 | getAudioDeviceManager$audioswitch_debug(), 141 | getWiredHeadsetReceiver$audioswitch_debug(), 142 | getPermissionsStrategyProxy$audioswitch_debug(), 143 | getBluetoothManager$audioswitch_debug(), 144 | getHeadsetManager$audioswitch_debug()); 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 | -------------------------------------------------------------------------------- /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 | object WiredHeadsetTest : TestType() 14 | object BluetoothHeadsetTest : TestType() 15 | } 16 | 17 | private val commonTestCases = listOf( 18 | listOf( 19 | BluetoothHeadset::class.java, 20 | WiredHeadset::class.java, 21 | Earpiece::class.java, 22 | Speakerphone::class.java, 23 | ), 24 | listOf( 25 | WiredHeadset::class.java, 26 | BluetoothHeadset::class.java, 27 | Earpiece::class.java, 28 | Speakerphone::class.java, 29 | ), 30 | listOf( 31 | WiredHeadset::class.java, 32 | Earpiece::class.java, 33 | BluetoothHeadset::class.java, 34 | Speakerphone::class.java, 35 | ), 36 | listOf( 37 | WiredHeadset::class.java, 38 | Earpiece::class.java, 39 | Speakerphone::class.java, 40 | BluetoothHeadset::class.java, 41 | ), 42 | listOf( 43 | BluetoothHeadset::class.java, 44 | Earpiece::class.java, 45 | WiredHeadset::class.java, 46 | Speakerphone::class.java, 47 | ), 48 | listOf( 49 | BluetoothHeadset::class.java, 50 | Earpiece::class.java, 51 | Speakerphone::class.java, 52 | WiredHeadset::class.java, 53 | ), 54 | listOf( 55 | Earpiece::class.java, 56 | BluetoothHeadset::class.java, 57 | WiredHeadset::class.java, 58 | Speakerphone::class.java, 59 | ), 60 | listOf( 61 | BluetoothHeadset::class.java, 62 | WiredHeadset::class.java, 63 | Speakerphone::class.java, 64 | Earpiece::class.java, 65 | ), 66 | listOf( 67 | Speakerphone::class.java, 68 | BluetoothHeadset::class.java, 69 | WiredHeadset::class.java, 70 | Earpiece::class.java, 71 | ), 72 | listOf( 73 | BluetoothHeadset::class.java, 74 | Speakerphone::class.java, 75 | WiredHeadset::class.java, 76 | Earpiece::class.java, 77 | ), 78 | listOf( 79 | BluetoothHeadset::class.java, 80 | Speakerphone::class.java, 81 | WiredHeadset::class.java, 82 | ), 83 | listOf( 84 | Earpiece::class.java, 85 | BluetoothHeadset::class.java, 86 | ), 87 | listOf(Speakerphone::class.java), 88 | listOf(), 89 | ) 90 | 91 | private fun getTestInput(testType: TestType): Array { 92 | return mutableListOf>().apply { 93 | commonTestCases.forEachIndexed { index, devices -> 94 | add(arrayOf(devices, getExpectedDevice(testType, devices))) 95 | } 96 | }.toTypedArray() 97 | } 98 | 99 | private fun getExpectedDevice( 100 | testType: TestType, 101 | preferredDeviceList: List>, 102 | ): AudioDevice { 103 | return when (testType) { 104 | EarpieceAndSpeakerTest -> { 105 | preferredDeviceList.find { 106 | it == Earpiece::class.java || it == Speakerphone::class.java 107 | }?.newInstance() ?: Earpiece() 108 | } 109 | WiredHeadsetTest -> { 110 | preferredDeviceList.find { 111 | it == WiredHeadset::class.java || it == Speakerphone::class.java 112 | }?.newInstance() ?: WiredHeadset() 113 | } 114 | BluetoothHeadsetTest -> { 115 | preferredDeviceList.find { 116 | it == BluetoothHeadset::class.java || it == Earpiece::class.java || it == Speakerphone::class.java 117 | }?.newInstance() ?: BluetoothHeadset() 118 | } 119 | } 120 | } 121 | 122 | class EarpieceAndSpeakerParams { 123 | companion object { 124 | @JvmStatic 125 | fun provideParams(): Array { 126 | return getTestInput(EarpieceAndSpeakerTest) 127 | } 128 | } 129 | } 130 | 131 | class WiredHeadsetParams { 132 | companion object { 133 | @JvmStatic 134 | fun provideParams(): Array { 135 | return getTestInput(WiredHeadsetTest) 136 | } 137 | } 138 | } 139 | 140 | class BluetoothHeadsetParams { 141 | companion object { 142 | @JvmStatic 143 | fun provideParams(): Array { 144 | return getTestInput(BluetoothHeadsetTest) 145 | } 146 | } 147 | } 148 | 149 | class DefaultDeviceParams { 150 | companion object { 151 | @JvmStatic 152 | fun provideParams() = commonTestCases 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /audioswitch/src/test/java/com/twilio/audioswitch/BaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.twilio.audioswitch 2 | 3 | import android.bluetooth.BluetoothAdapter 4 | import android.bluetooth.BluetoothClass 5 | import android.bluetooth.BluetoothDevice 6 | import android.bluetooth.BluetoothHeadset 7 | import android.bluetooth.BluetoothManager 8 | import android.bluetooth.BluetoothProfile 9 | import android.content.Context 10 | import android.content.Intent 11 | import android.media.AudioManager.OnAudioFocusChangeListener 12 | import com.twilio.audioswitch.AudioDevice.Earpiece 13 | import com.twilio.audioswitch.AudioDevice.Speakerphone 14 | import com.twilio.audioswitch.AudioDevice.WiredHeadset 15 | import com.twilio.audioswitch.android.BuildWrapper 16 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener 17 | import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager 18 | import com.twilio.audioswitch.wired.WiredHeadsetReceiver 19 | import org.hamcrest.CoreMatchers 20 | import org.hamcrest.MatcherAssert.assertThat 21 | import org.mockito.kotlin.mock 22 | import org.mockito.kotlin.verify 23 | import org.mockito.kotlin.whenever 24 | 25 | open class BaseTest { 26 | internal val bluetoothClass = mock { 27 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE) 28 | } 29 | internal val expectedBluetoothDevice = mock { 30 | whenever(mock.name).thenReturn(DEVICE_NAME) 31 | whenever(mock.bluetoothClass).thenReturn(bluetoothClass) 32 | } 33 | internal val context = mock() 34 | internal val bluetoothListener = mock() 35 | internal val logger = UnitTestLogger() 36 | internal val audioManager = setupAudioManagerMock() 37 | internal val bluetoothManager = mock() 38 | internal val bluetoothAdapter = mock() 39 | 40 | internal val audioDeviceChangeListener = mock() 41 | internal val buildWrapper = mock() 42 | internal val audioFocusRequest = mock() 43 | internal val defaultAudioFocusChangeListener = mock() 44 | internal val audioDeviceManager = AudioDeviceManager( 45 | context, 46 | logger, 47 | audioManager, 48 | buildWrapper, 49 | audioFocusRequest, 50 | defaultAudioFocusChangeListener, 51 | ) 52 | internal val wiredHeadsetReceiver = WiredHeadsetReceiver(context, logger) 53 | internal var handler = setupScoHandlerMock() 54 | internal var systemClockWrapper = setupSystemClockMock() 55 | internal val headsetProxy = mock() 56 | internal val preferredDeviceList = listOf( 57 | AudioDevice.BluetoothHeadset::class.java, 58 | WiredHeadset::class.java, 59 | Earpiece::class.java, 60 | Speakerphone::class.java, 61 | ) 62 | internal val permissionsStrategyProxy = setupPermissionsCheckStrategy() 63 | internal var headsetManager: BluetoothHeadsetManager = BluetoothHeadsetManager( 64 | context, 65 | logger, 66 | bluetoothAdapter, 67 | audioDeviceManager, 68 | bluetoothScoHandler = handler, 69 | systemClockWrapper = systemClockWrapper, 70 | headsetProxy = headsetProxy, 71 | ) 72 | 73 | internal var audioSwitch = AudioSwitch( 74 | context = context, 75 | bluetoothHeadsetConnectionListener = bluetoothListener, 76 | logger = logger, 77 | audioDeviceManager = audioDeviceManager, 78 | wiredHeadsetReceiver = wiredHeadsetReceiver, 79 | audioFocusChangeListener = defaultAudioFocusChangeListener, 80 | preferredDeviceList = preferredDeviceList, 81 | permissionsCheckStrategy = permissionsStrategyProxy, 82 | bluetoothManager = bluetoothManager, 83 | bluetoothHeadsetManager = headsetManager, 84 | ) 85 | 86 | internal fun assertBluetoothHeadsetTeardown() { 87 | assertThat(headsetManager.headsetListener, CoreMatchers.`is`(CoreMatchers.nullValue())) 88 | verify(bluetoothAdapter).closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy) 89 | verify(context).unregisterReceiver(headsetManager) 90 | } 91 | 92 | internal fun simulateNewBluetoothHeadsetConnection( 93 | bluetoothDevice: BluetoothDevice = expectedBluetoothDevice, 94 | ) { 95 | val intent = mock { 96 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 97 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)) 98 | .thenReturn(BluetoothHeadset.STATE_CONNECTED) 99 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 100 | .thenReturn(bluetoothDevice) 101 | } 102 | headsetManager.onReceive(context, intent) 103 | } 104 | 105 | internal fun simulateDisconnectedBluetoothHeadsetConnection( 106 | bluetoothDevice: BluetoothDevice = expectedBluetoothDevice, 107 | ) { 108 | val intent = mock { 109 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 110 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)) 111 | .thenReturn(BluetoothHeadset.STATE_DISCONNECTED) 112 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 113 | .thenReturn(bluetoothDevice) 114 | } 115 | headsetManager.onReceive(context, intent) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /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/test/java/com/twilio/audioswitch/UnitTestLogger.kt: -------------------------------------------------------------------------------- 1 | package com.twilio.audioswitch 2 | 3 | import com.twilio.audioswitch.android.Logger 4 | 5 | class UnitTestLogger(override var loggingEnabled: Boolean = true) : Logger { 6 | 7 | override fun d(tag: String, message: String) { 8 | printMessage(message) 9 | } 10 | 11 | override fun w(tag: String, message: String) { 12 | printMessage(message) 13 | } 14 | 15 | override fun e(tag: String, message: String) { 16 | printMessage(message) 17 | } 18 | 19 | override fun e(tag: String, message: String, throwable: Throwable) { 20 | printMessage(message) 21 | throwable.printStackTrace() 22 | } 23 | 24 | private fun printMessage(message: String) { 25 | if (loggingEnabled) { 26 | println(message) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /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 | 16 | @Test 17 | fun `name should return a generic bluetooth device name if none was returned from the BluetoothDevice class`() { 18 | val device = BluetoothDeviceWrapperImpl(mock()) 19 | 20 | assertThat(device.name, equalTo(DEFAULT_DEVICE_NAME)) 21 | } 22 | 23 | @Test 24 | fun `name should return a the BluetoothDevice name`() { 25 | val bluetoothDevice = 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 = mock { 44 | whenever(mock.deviceClass).thenReturn(AUDIO_VIDEO_HEADPHONES) 45 | } 46 | val bluetoothDevice = mock { 47 | whenever(mock.bluetoothClass).thenReturn(deviceClass) 48 | } 49 | val deviceWrapper = BluetoothDeviceWrapperImpl(bluetoothDevice) 50 | 51 | assertThat(deviceWrapper.deviceClass, equalTo(AUDIO_VIDEO_HEADPHONES)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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 | 36 | private val headsetListener = mock() 37 | private val bluetoothDevices = listOf(expectedBluetoothDevice) 38 | 39 | @Before 40 | fun setUp() { 41 | initializeManagerWithMocks() 42 | } 43 | 44 | @Test 45 | fun `onServiceConnected should notify the deviceListener if there are connected devices`() { 46 | setupConnectedState() 47 | 48 | verify(headsetListener).onBluetoothHeadsetStateChanged(DEVICE_NAME) 49 | } 50 | 51 | @Test 52 | fun `onServiceConnected should set the headset state to Connected if there are connected devices`() { 53 | setupConnectedState() 54 | 55 | assertThat(headsetManager.headsetState is Connected, equalTo(true)) 56 | } 57 | 58 | @Test 59 | fun `onServiceConnected should not notify the deviceListener if the deviceListener is null`() { 60 | headsetManager.headsetListener = null 61 | setupConnectedState() 62 | 63 | verifyNoInteractions(headsetListener) 64 | } 65 | 66 | @Test 67 | fun `onServiceConnected should not notify the deviceListener if there are no connected bluetooth headsets`() { 68 | val bluetoothProfile = 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 = mock { 163 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE) 164 | } 165 | val audioVideoHeadsetDevice = mock { 166 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_WEARABLE_HEADSET) 167 | } 168 | val audioVideoCarDevice = mock { 169 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO) 170 | } 171 | val headphonesDevice = mock { 172 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_HEADPHONES) 173 | } 174 | val uncategorizedDevice = mock { 175 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.Major.UNCATEGORIZED) 176 | } 177 | val wrongDevice = mock { 178 | whenever(mock.deviceClass).thenReturn(BluetoothClass.Device.AUDIO_VIDEO_VIDEO_MONITOR) 179 | } 180 | return arrayOf( 181 | arrayOf(handsFreeDevice, true), 182 | arrayOf(audioVideoHeadsetDevice, true), 183 | arrayOf(audioVideoCarDevice, true), 184 | arrayOf(headphonesDevice, true), 185 | arrayOf(uncategorizedDevice, true), 186 | arrayOf(wrongDevice, false), 187 | arrayOf(null, false), 188 | ) 189 | } 190 | 191 | @Parameters(method = "parameters") 192 | @Test 193 | fun `onReceive should register a new device when a headset connection event is received`( 194 | deviceClass: BluetoothClass?, 195 | isNewDeviceConnected: Boolean, 196 | ) { 197 | whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(deviceClass) 198 | val intent = mock { 199 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 200 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)) 201 | .thenReturn(BluetoothHeadset.STATE_CONNECTED) 202 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 203 | .thenReturn(expectedBluetoothDevice) 204 | } 205 | headsetManager.onReceive(context, intent) 206 | 207 | val invocationCount = if (isNewDeviceConnected) 1 else 0 208 | verify(headsetListener, times(invocationCount)).onBluetoothHeadsetStateChanged(DEVICE_NAME, BluetoothHeadset.STATE_CONNECTED) 209 | } 210 | 211 | @Parameters(method = "parameters") 212 | @Test 213 | fun `onReceive should disconnect a device when a headset disconnection event is received`( 214 | deviceClass: BluetoothClass?, 215 | isDeviceDisconnected: Boolean, 216 | ) { 217 | whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(deviceClass) 218 | val intent = mock { 219 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 220 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)) 221 | .thenReturn(BluetoothHeadset.STATE_DISCONNECTED) 222 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 223 | .thenReturn(expectedBluetoothDevice) 224 | } 225 | headsetManager.onReceive(context, intent) 226 | 227 | val invocationCount = if (isDeviceDisconnected) 1 else 0 228 | verify(headsetListener, times(invocationCount)).onBluetoothHeadsetStateChanged(headsetName = "Bluetooth") 229 | } 230 | 231 | @Test 232 | fun `onReceive should trigger once for sco disconnect when an ACL connected event is received with a null bluetooth device`() { 233 | whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(null) 234 | simulateNewBluetoothHeadsetConnection() 235 | 236 | verify(headsetListener, times(1)).onBluetoothScoStateChanged(0) 237 | } 238 | 239 | @Test 240 | fun `onReceive should not register a new device when the deviceListener is null`() { 241 | headsetManager.headsetListener = null 242 | simulateNewBluetoothHeadsetConnection() 243 | 244 | verifyNoInteractions(headsetListener) 245 | } 246 | 247 | @Test 248 | fun `onReceive should not disconnect a device when an ACL disconnected event is received with a null bluetooth device`() { 249 | val intent = mock { 250 | whenever(mock.action).thenReturn(BluetoothDevice.ACTION_ACL_DISCONNECTED) 251 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 252 | .thenReturn(null) 253 | } 254 | 255 | headsetManager.onReceive(mock(), intent) 256 | 257 | verifyNoInteractions(headsetListener) 258 | } 259 | 260 | @Test 261 | fun `onReceive should not disconnect a device when the deviceListener is null`() { 262 | headsetManager.headsetListener = null 263 | val intent = mock { 264 | whenever(mock.action).thenReturn(BluetoothDevice.ACTION_ACL_DISCONNECTED) 265 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 266 | .thenReturn(expectedBluetoothDevice) 267 | } 268 | 269 | headsetManager.onReceive(mock(), intent) 270 | 271 | verifyNoInteractions(headsetListener) 272 | } 273 | 274 | @Test 275 | fun `onReceive should receive no headset listener callbacks if the intent action is null`() { 276 | headsetManager.onReceive(mock(), mock()) 277 | 278 | verifyNoInteractions(headsetListener) 279 | } 280 | 281 | @Test 282 | fun `a headset audio connection should cancel a running enableBluetoothScoJob`() { 283 | setupConnectedState() 284 | headsetManager.activate() 285 | val intent = mock { 286 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 287 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)) 288 | .thenReturn(BluetoothHeadset.STATE_AUDIO_CONNECTED) 289 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 290 | .thenReturn(expectedBluetoothDevice) 291 | } 292 | headsetManager.onReceive(context, intent) 293 | 294 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob) 295 | } 296 | 297 | @Test 298 | fun `a bluetooth headset audio disconnection should cancel a running disableBluetoothScoJob`() { 299 | headsetManager.headsetState = AudioActivated 300 | headsetManager.deactivate() 301 | val intent = mock { 302 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 303 | whenever( 304 | mock.getIntExtra( 305 | BluetoothHeadset.EXTRA_STATE, 306 | BluetoothHeadset.STATE_DISCONNECTED, 307 | ), 308 | ) 309 | .thenReturn(BluetoothHeadset.STATE_AUDIO_DISCONNECTED) 310 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 311 | .thenReturn(expectedBluetoothDevice) 312 | } 313 | headsetManager.onReceive(mock(), intent) 314 | 315 | assertScoJobIsCanceled(handler, headsetManager.disableBluetoothScoJob) 316 | } 317 | 318 | @Test 319 | fun `EnableBluetoothScoJob scoTimeOutAction should set the state to AudioActivationError`() { 320 | systemClockWrapper = mock { 321 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT) 322 | } 323 | handler = setupHandlerMock() 324 | initializeManagerWithMocks() 325 | headsetManager.headsetState = Connected 326 | headsetManager.activate() 327 | 328 | assertThat(headsetManager.headsetState is AudioActivationError, equalTo(true)) 329 | } 330 | 331 | @Test 332 | fun `EnableBluetoothScoJob scoTimeOutAction should invoke the headset listener`() { 333 | systemClockWrapper = mock { 334 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT) 335 | } 336 | handler = setupHandlerMock() 337 | initializeManagerWithMocks() 338 | headsetManager.headsetState = Connected 339 | headsetManager.activate() 340 | 341 | verify(headsetListener).onBluetoothHeadsetActivationError() 342 | } 343 | 344 | @Test 345 | fun `EnableBluetoothScoJob scoTimeOutAction should not invoke the headset listener if it is null`() { 346 | systemClockWrapper = mock { 347 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT) 348 | } 349 | handler = setupHandlerMock() 350 | headsetManager = BluetoothHeadsetManager( 351 | context, 352 | logger, 353 | bluetoothAdapter, 354 | audioDeviceManager, 355 | bluetoothScoHandler = handler, 356 | systemClockWrapper = systemClockWrapper, 357 | headsetProxy = headsetProxy, 358 | ) 359 | 360 | headsetManager.headsetState = Connected 361 | headsetManager.activate() 362 | 363 | verifyNoInteractions(headsetListener) 364 | } 365 | 366 | @Test 367 | fun `BluetoothScoRunnable should execute enableBluetoothSco multiple times if not canceled`() { 368 | handler = mock { 369 | whenever(mock.post(any())).thenAnswer { 370 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run() 371 | true 372 | } 373 | 374 | var firstInvocation = true 375 | whenever(mock.postDelayed(isA(), isA())).thenAnswer { 376 | if (firstInvocation) { 377 | firstInvocation = false 378 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run() 379 | } 380 | true 381 | } 382 | } 383 | initializeManagerWithMocks() 384 | headsetManager.headsetState = Connected 385 | headsetManager.activate() 386 | 387 | verify(audioManager, times(2)).startBluetoothSco() 388 | } 389 | 390 | @Test 391 | fun `BluetoothScoRunnable should timeout if elapsedTime equals the time limit`() { 392 | systemClockWrapper = mock { 393 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT) 394 | } 395 | handler = setupHandlerMock() 396 | initializeManagerWithMocks() 397 | headsetManager.headsetState = Connected 398 | headsetManager.activate() 399 | 400 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob) 401 | } 402 | 403 | @Test 404 | fun `BluetoothScoRunnable should timeout if elapsedTime is greater than the time limit`() { 405 | systemClockWrapper = mock { 406 | whenever(mock.elapsedRealtime()).thenReturn(0L, TIMEOUT + 1000) 407 | } 408 | handler = setupHandlerMock() 409 | initializeManagerWithMocks() 410 | headsetManager.headsetState = Connected 411 | headsetManager.activate() 412 | 413 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob) 414 | } 415 | 416 | @Test 417 | fun `cancelBluetoothScoJob should not cancel sco runnable if it has not been initialized`() { 418 | headsetManager.enableBluetoothScoJob.cancelBluetoothScoJob() 419 | 420 | verifyNoInteractions(handler) 421 | } 422 | 423 | @Test 424 | fun `it should cancel the enable bluetooth sco job when setting the state to disconnected`() { 425 | val bluetoothProfile = mock { 426 | whenever(mock.connectedDevices).thenReturn(bluetoothDevices, bluetoothDevices, emptyList()) 427 | } 428 | headsetManager.onServiceConnected(0, bluetoothProfile) 429 | headsetManager.activate() 430 | 431 | val intent = mock { 432 | whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 433 | whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)) 434 | .thenReturn(BluetoothHeadset.STATE_DISCONNECTED) 435 | whenever(mock.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)) 436 | .thenReturn(expectedBluetoothDevice) 437 | } 438 | headsetManager.onReceive(context, intent) 439 | 440 | assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob) 441 | } 442 | 443 | private fun setupHandlerMock() = 444 | mock { 445 | whenever(mock.post(any())).thenAnswer { 446 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run() 447 | true 448 | } 449 | 450 | whenever(mock.postDelayed(isA(), isA())).thenAnswer { 451 | (it.arguments[0] as BluetoothScoJob.BluetoothScoRunnable).run() 452 | true 453 | } 454 | } 455 | 456 | private fun setupConnectedState() { 457 | val bluetoothProfile = mock { 458 | whenever(mock.connectedDevices).thenReturn(bluetoothDevices) 459 | } 460 | headsetManager.onServiceConnected(0, bluetoothProfile) 461 | } 462 | 463 | private fun assertScoJobIsCanceled(handler: Handler, scoJob: BluetoothScoJob) { 464 | verify(handler).removeCallbacks(isA()) 465 | assertThat(scoJob.bluetoothScoRunnable, `is`(nullValue())) 466 | } 467 | 468 | private fun initializeManagerWithMocks() { 469 | headsetManager = BluetoothHeadsetManager( 470 | context, 471 | logger, 472 | bluetoothAdapter, 473 | audioDeviceManager, 474 | headsetListener, 475 | handler, 476 | systemClockWrapper, 477 | headsetProxy = headsetProxy, 478 | ) 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /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 | 20 | private val context = mock() 21 | private val logger = mock() 22 | private val wiredDeviceConnectionListener = mock() 23 | private val wiredHeadsetReceiver = 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 = mock { 31 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED)) 32 | .thenReturn(STATE_PLUGGED) 33 | } 34 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener) 35 | 36 | wiredHeadsetReceiver.onReceive(context, intent) 37 | 38 | verify(wiredDeviceConnectionListener).onDeviceConnected() 39 | } 40 | 41 | @Test 42 | fun `onReceive should not notify listener when a wired headset has been plugged in but the listener is null`() { 43 | wiredHeadsetReceiver.deviceListener = null 44 | val intent = mock { 45 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED)) 46 | .thenReturn(STATE_PLUGGED) 47 | } 48 | 49 | try { 50 | wiredHeadsetReceiver.onReceive(context, intent) 51 | } catch (e: NullPointerException) { 52 | fail("NullPointerException should not have been thrown") 53 | } 54 | } 55 | 56 | @Test 57 | fun `onReceive should notify listener when a wired headset has been unplugged`() { 58 | val intent = mock { 59 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED)) 60 | .thenReturn(STATE_UNPLUGGED) 61 | } 62 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener) 63 | 64 | wiredHeadsetReceiver.onReceive(context, intent) 65 | 66 | verify(wiredDeviceConnectionListener).onDeviceDisconnected() 67 | } 68 | 69 | @Test 70 | fun `onReceive should not notify listener when a wired headset has been unplugged but the listener is null`() { 71 | wiredHeadsetReceiver.deviceListener = null 72 | val intent = mock { 73 | whenever(mock.getIntExtra("state", STATE_UNPLUGGED)) 74 | .thenReturn(STATE_UNPLUGGED) 75 | } 76 | 77 | try { 78 | wiredHeadsetReceiver.onReceive(context, intent) 79 | } catch (e: NullPointerException) { 80 | fail("NullPointerException should not have been thrown") 81 | } 82 | } 83 | 84 | @Test 85 | fun `start should register the device listener`() { 86 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener) 87 | 88 | assertThat(wiredHeadsetReceiver.deviceListener, equalTo(wiredDeviceConnectionListener)) 89 | } 90 | 91 | @Test 92 | fun `start should register the broadcast receiver`() { 93 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener) 94 | 95 | verify(context).registerReceiver(eq(wiredHeadsetReceiver), isA()) 96 | } 97 | 98 | @Test 99 | fun `stop should close resources successfully`() { 100 | wiredHeadsetReceiver.start(wiredDeviceConnectionListener) 101 | 102 | wiredHeadsetReceiver.stop() 103 | 104 | assertThat(wiredHeadsetReceiver.deviceListener, `is`(nullValue())) 105 | verify(context).unregisterReceiver(wiredHeadsetReceiver) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /audioswitch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.6.21' 3 | ext.dokka_version = '1.4.32' 4 | 5 | /** 6 | * Properties and environment variables needed to publish. 7 | */ 8 | ext.jfrogUsername = (project.hasProperty('jfrog.username') ? 9 | project.property("jfrog.username") : '') 10 | ext.jfrogPassword = (project.hasProperty('jfrog.password') ? 11 | project.property("jfrog.password") : '') 12 | ext["signing.keyId"] = (project.hasProperty('signing.keyId') ? 13 | project.property("signing.keyId") : '') 14 | ext["signing.password"] = (project.hasProperty('signing.password') ? 15 | project.property("signing.password") : '') 16 | ext["signing.secretKeyRingFile"] = (project.hasProperty('signing.secretKeyRingFile') ? 17 | project.property("signing.secretKeyRingFile") : '') 18 | ext["ossrhUsername"] = (project.hasProperty('ossrhUsername') ? 19 | project.property("ossrhUsername") : '') 20 | ext["ossrhPassword"] = (project.hasProperty('ossrhPassword') ? 21 | project.property("ossrhPassword") : '') 22 | ext["sonatypeStagingProfileId"] = (project.hasProperty('sonatypeStagingProfileId') ? 23 | project.property("sonatypeStagingProfileId") : '') 24 | 25 | ext.getPropertyValue = { propertyKey -> 26 | def property = System.getenv(propertyKey) 27 | 28 | if (property == null) { 29 | logger.log(LogLevel.INFO, "Could not locate $propertyKey as environment variable. " + 30 | "Trying local.properties") 31 | Properties properties = new Properties() 32 | if (project.rootProject.file('local.properties').exists()) { 33 | properties.load(project.rootProject.file('local.properties').newDataInputStream()) 34 | property = properties.getProperty(propertyKey) 35 | } 36 | } 37 | 38 | if (property == null) { 39 | logger.log(LogLevel.WARN, "$propertyKey unavailable.") 40 | } 41 | 42 | return property 43 | } 44 | 45 | ext.getShortCommitSha = { 46 | def gitSha = System.getenv("CIRCLE_SHA1") 47 | 48 | if(gitSha != null) return gitSha.substring(0, 7) else return "" 49 | } 50 | 51 | ext.isPreRelease = (project.hasProperty("preRelease") && project.property("preRelease").toBoolean() == true) 52 | ext.audioSwitchVersion = "${versionMajor}.${versionMinor}.${versionPatch}" + 53 | (isPreRelease ? "-SNAPSHOT" : '') 54 | 55 | repositories { 56 | google() 57 | mavenCentral() 58 | } 59 | dependencies { 60 | classpath 'com.android.tools.build:gradle:8.3.1' 61 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 62 | classpath "org.jetbrains.dokka:dokka-gradle-plugin:$dokka_version" 63 | } 64 | } 65 | 66 | plugins { 67 | id "com.diffplug.spotless" version '6.19.0' 68 | id "org.jetbrains.dokka" version "$dokka_version" 69 | id "io.github.gradle-nexus.publish-plugin" version "1.0.0" 70 | id "maven-publish" 71 | } 72 | apply plugin: "com.diffplug.spotless" 73 | spotless { 74 | format 'misc', { 75 | target '**/*.gradle', '**/*.md', '**/.gitignore' 76 | targetExclude 'docs/**' 77 | 78 | trimTrailingWhitespace() 79 | indentWithSpaces() 80 | endWithNewline() 81 | } 82 | java { 83 | target '**/*.java' 84 | googleJavaFormat().aosp() 85 | } 86 | kotlin { 87 | target '**/*.kt' 88 | ktlint() 89 | } 90 | } 91 | 92 | allprojects { 93 | repositories { 94 | google() 95 | mavenCentral() 96 | } 97 | } 98 | 99 | nexusPublishing { 100 | repositories { 101 | sonatype { 102 | username = ossrhUsername 103 | password = ossrhPassword 104 | stagingProfileId = sonatypeStagingProfileId 105 | useStaging = !isPreRelease 106 | } 107 | } 108 | 109 | clientTimeout = Duration.ofSeconds(300) 110 | connectTimeout = Duration.ofSeconds(60) 111 | } 112 | 113 | /* 114 | * Utility GradleBuild task that enables defining custom tasks derived from gradle modules in a 115 | * root level gradle file. 116 | * 117 | * TODO: Replace this approach in favor of pushing tasks down into their respective modules. 118 | */ 119 | class RootGradleBuild extends GradleBuild { 120 | private static final String ROOT_PROJECT_NAME = "audioswitch-root" 121 | 122 | RootGradleBuild() { 123 | super() 124 | buildName = ROOT_PROJECT_NAME 125 | } 126 | } 127 | 128 | /* 129 | * Checks if release tag matches version and current commit 130 | */ 131 | def matchesVersion(versionTag) { 132 | def properties = new Properties() 133 | file("${rootDir}/gradle.properties").withInputStream { properties.load(it) } 134 | def releaseTag = "${properties.getProperty("versionMajor")}." + 135 | "${properties.getProperty("versionMinor")}." + 136 | "${properties.getProperty("versionPatch")}" 137 | 138 | return releaseTag == versionTag 139 | } 140 | 141 | task validateReleaseTag { 142 | description = 'Validate the release tag matches the release version ' + 143 | 'present on commit' 144 | group = 'Git' 145 | 146 | doLast { 147 | def circleTag = System.getenv("CIRCLE_TAG") 148 | def tagsMatch = (matchesVersion(circleTag) || isPreRelease) ? ("true") : ("false") 149 | 150 | exec { 151 | workingDir "${rootDir}" 152 | commandLine tagsMatch 153 | } 154 | } 155 | } 156 | 157 | task incrementVersion(type: RootGradleBuild) { 158 | description = 'Increment the SDK version after a release' 159 | group = 'Git' 160 | 161 | doLast { 162 | def stdOut = new ByteArrayOutputStream() 163 | 164 | exec { 165 | commandLine "bash", "-c", "git remote show origin | grep HEAD | cut -d: -f2-" 166 | standardOutput stdOut 167 | } 168 | 169 | def gitBranch = stdOut.toString().replaceAll("\\s","") 170 | def circleTag = System.getenv("CIRCLE_TAG") 171 | def githubToken = System.getenv("GITHUB_TOKEN") 172 | def repoSlug = "${System.env.CIRCLE_PROJECT_USERNAME}/${System.env.CIRCLE_PROJECT_REPONAME}" 173 | def gitRef = "https://${githubToken}@github.com/${repoSlug}.git" 174 | def nextVersionPatch = versionPatch.toInteger() + 1 175 | def remote = "upstream" 176 | 177 | if (!buildDir.exists()) { 178 | buildDir.mkdir() 179 | } 180 | 181 | exec { 182 | workingDir "${rootDir}" 183 | commandLine "git", "remote", "add", "${remote}", "${gitRef}" 184 | // Ignore exit value because remote may have been added in previous task 185 | ignoreExitValue true 186 | } 187 | 188 | exec { 189 | workingDir "${rootDir}" 190 | commandLine "git", "checkout", "${gitBranch}" 191 | } 192 | 193 | /* 194 | * Only update the version on upstream branch if the version matches tag. It is possible 195 | * these values do not match if a job is performed on an earlier commit and a PR 196 | * with a version update occurs later in history. 197 | */ 198 | if (matchesVersion(circleTag)) { 199 | exec { 200 | workingDir "${rootDir}" 201 | commandLine "echo", "Incrementing from versionPatch ${versionPatch} to " + 202 | "${nextVersionPatch}" 203 | } 204 | 205 | exec { 206 | workingDir "${rootDir}" 207 | commandLine "sed", 208 | "s@versionPatch=.*@versionPatch=${nextVersionPatch}@", 209 | "gradle.properties" 210 | standardOutput new FileOutputStream("${buildDir}/gradle.properties") 211 | } 212 | 213 | exec { 214 | workingDir "${rootDir}" 215 | commandLine "mv", "${buildDir}/gradle.properties", "gradle.properties" 216 | } 217 | 218 | exec { 219 | workingDir "${rootDir}" 220 | commandLine "git", "commit", "gradle.properties", "-m", "\"Bump patch version [skip ci]\"" 221 | } 222 | 223 | exec { 224 | workingDir "${rootDir}" 225 | commandLine "git", "push", "${remote}", "${gitBranch}" 226 | } 227 | } 228 | } 229 | } 230 | 231 | task sonatypeAudioSwitchReleaseUpload(type: RootGradleBuild) { 232 | description = 'Publish an AudioSwitch release or pre-release' 233 | group = 'Publishing' 234 | dependsOn validateReleaseTag 235 | buildFile = file('build.gradle') 236 | tasks = ['assembleRelease', 'publishAudioSwitchReleasePublicationToSonatypeRepository', 'closeAndReleaseSonatypeStagingRepository'] 237 | startParameter.projectProperties += gradle.startParameter.projectProperties + [ 238 | 'signing.keyId': "${getPropertyValue("SIGNING_KEY_ID")}", 239 | 'signing.password' : "${getPropertyValue("SIGNING_PASSWORD")}", 240 | 'signing.secretKeyRingFile' : "${getPropertyValue("SIGNING_SECRET_KEY_RING_FILE")}", 241 | 'ossrhUsername' : "${getPropertyValue("OSSRH_USERNAME")}", 242 | 'ossrhPassword' : "${getPropertyValue("OSSRH_PASSWORD")}", 243 | 'sonatypeStagingProfileId' : "${getPropertyValue("SONATYPE_STAGING_PROFILE_ID")}" 244 | ] 245 | } 246 | 247 | task publishDocs { 248 | description = 'Publish AudioSwitch KDocs to gh-pages branch' 249 | group = 'Publishing' 250 | dependsOn 'audioswitch:dokkaHtml' 251 | dependsOn validateReleaseTag 252 | def releaseVersion = System.getenv("CIRCLE_TAG") == null ? 253 | ("") : 254 | (System.getenv("CIRCLE_TAG")) 255 | def pinLatestDocsCommand = ["ln", "-sfn", "${releaseVersion}", "docs/latest"] 256 | def githubToken = System.getenv("GITHUB_TOKEN") 257 | def repoSlug = "${System.env.CIRCLE_PROJECT_USERNAME}/${System.env.CIRCLE_PROJECT_REPONAME}" 258 | def gitRef = "https://${githubToken}@github.com/${repoSlug}.git" 259 | def remote = "upstream" 260 | def pushNullFile = new FileOutputStream("/dev/null") 261 | 262 | doLast { 263 | exec { 264 | workingDir "${rootDir}" 265 | commandLine "git", "remote", "add", "${remote}", "${gitRef}" 266 | // Ignore exit value because remote may have been added in previous task 267 | ignoreExitValue true 268 | } 269 | 270 | exec { 271 | workingDir "${rootDir}" 272 | commandLine "git", "fetch", "${remote}" 273 | } 274 | 275 | exec { 276 | workingDir "${rootDir}" 277 | commandLine "git", "checkout", "-b", "gh-pages", "remotes/${remote}/gh-pages" 278 | } 279 | 280 | exec { 281 | workingDir "${rootDir}" 282 | commandLine "mkdir", "docs" 283 | ignoreExitValue true 284 | } 285 | 286 | exec { 287 | workingDir "${rootDir}" 288 | commandLine "cp", "-r", "audioswitch/build/dokka/html/.", "docs/${releaseVersion}" 289 | } 290 | 291 | exec { 292 | workingDir "${rootDir}" 293 | commandLine pinLatestDocsCommand 294 | } 295 | 296 | exec { 297 | workingDir "${rootDir}" 298 | commandLine "git", "add", "docs/${releaseVersion}", "docs/latest" 299 | } 300 | 301 | exec { 302 | workingDir "${rootDir}" 303 | commandLine "git", "commit", "-m", "\"${releaseVersion} release docs [skip ci]\"" 304 | } 305 | 306 | exec { 307 | workingDir "${rootDir}" 308 | commandLine "git", "push", "--quiet", "${remote}", "gh-pages" 309 | standardOutput pushNullFile 310 | } 311 | 312 | exec { 313 | workingDir "${rootDir}" 314 | commandLine "git", "checkout", "${releaseVersion}" 315 | } 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536m 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | versionMajor=1 5 | versionMinor=2 6 | versionPatch=1 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/audioswitch/ef1cee84e4a90c0ff790cb2147c3df0fd383337e/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /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.4-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /images/audioswitch-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/audioswitch/ef1cee84e4a90c0ff790cb2147c3df0fd383337e/images/audioswitch-logo.png -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':audioswitch' 2 | -------------------------------------------------------------------------------- /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: griffin, version: 24} 8 | - {model: starqlteue, version: 26} 9 | - {model: crownqlteue, version: 29} 10 | - {model: cactus, version: 27} 11 | - {model: q2q, version: 31} 12 | - {model: redfin, version: 30} --------------------------------------------------------------------------------