├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .idea ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── copyright │ ├── Apache_2_0.xml │ └── profiles_settings.xml ├── deploymentTargetDropDown.xml ├── kotlinCodeInsightSettings.xml ├── kotlinc.xml ├── migrations.xml └── misc.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── punchthrough │ │ └── blestarterappandroid │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── punchthrough │ │ │ └── blestarterappandroid │ │ │ ├── BleOperationsActivity.kt │ │ │ ├── CharacteristicAdapter.kt │ │ │ ├── MainActivity.kt │ │ │ ├── PermissionsHelper.kt │ │ │ ├── ScanResultAdapter.kt │ │ │ └── ble │ │ │ ├── BleExtensions.kt │ │ │ ├── BleOperationType.kt │ │ │ ├── ConnectionEventListener.kt │ │ │ └── ConnectionManager.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_ble_operations.xml │ │ ├── activity_main.xml │ │ ├── edittext_hex_payload.xml │ │ ├── row_characteristic.xml │ │ └── row_scan_result.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── punchthrough │ └── blestarterappandroid │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # CircleCI configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-android/ for more details 4 | # 5 | version: 2.1 6 | jobs: 7 | build: 8 | working_directory: ~/ble-starter-android 9 | docker: 10 | - image: cimg/android:2024.01 11 | environment: 12 | JVM_OPTS: -Xmx3200m 13 | steps: 14 | - checkout 15 | - restore_cache: 16 | key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} 17 | - run: 18 | name: Download Dependencies 19 | command: ./gradlew androidDependencies 20 | - save_cache: 21 | paths: 22 | - ~/.gradle 23 | key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} 24 | - run: 25 | name: Run ktlint 26 | command: ./gradlew ktlint 27 | - run: 28 | name: Run Tests 29 | command: ./gradlew lint test 30 | - store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/ 31 | path: app/build/reports 32 | destination: reports 33 | - store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/ 34 | path: app/build/test-results 35 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | ktlint_code_style = android_studio 3 | ktlint_standard_max-line-length = disabled 4 | ktlint_standard_discouraged-comment-location = disabled 5 | ktlint_standard_function-signature = disabled 6 | ktlint_standard_argument-list-wrapping = disabled 7 | ktlint_standard_parameter-list-wrapping = disabled 8 | ktlint_standard_wrapping = disabled 9 | ktlint_standard_property-wrapping = disabled 10 | ktlint_standard_trailing-comma-on-call-site = disabled 11 | ktlint_standard_trailing-comma-on-declaration-site = disabled 12 | ktlint_standard_statement-wrapping = disabled 13 | ktlint_standard_annotation = disabled 14 | ktlint_standard_indent = disabled 15 | ktlint_standard_enum-wrapping = disabled 16 | ktlint_standard_import-ordering = disabled 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # .gitignore from https://raw.githubusercontent.com/github/gitignore/master/Android.gitignore 2 | 3 | # Built application files 4 | *.apk 5 | *.ap_ 6 | *.aab 7 | 8 | # Files for the ART/Dalvik VM 9 | *.dex 10 | 11 | # Java class files 12 | *.class 13 | 14 | # Generated files 15 | bin/ 16 | gen/ 17 | out/ 18 | release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | # Android Studio 3 in .gitignore file. 48 | .idea/caches 49 | .idea/modules.xml 50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 51 | .idea/navEditor.xml 52 | 53 | # Keystore files 54 | # Uncomment the following lines if you do not want to check your keystore files in. 55 | #*.jks 56 | #*.keystore 57 | 58 | # External native build folder generated in Android Studio 2.2 and later 59 | .externalNativeBuild 60 | 61 | # Google Services (e.g. APIs or Firebase) 62 | # google-services.json 63 | 64 | # Freeline 65 | freeline.py 66 | freeline/ 67 | freeline_project_description.json 68 | 69 | # fastlane 70 | fastlane/report.xml 71 | fastlane/Preview.html 72 | fastlane/screenshots 73 | fastlane/test_output 74 | fastlane/readme.md 75 | 76 | # Version control 77 | vcs.xml 78 | 79 | # lint 80 | lint/intermediates/ 81 | lint/generated/ 82 | lint/outputs/ 83 | lint/tmp/ 84 | # lint/reports/ 85 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | BLE Starter -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 187 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/copyright/Apache_2_0.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/kotlinCodeInsightSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Android 15 | 16 | 17 | Code style issuesJava 18 | 19 | 20 | CorrectnessLintAndroid 21 | 22 | 23 | Declaration redundancyJava 24 | 25 | 26 | HTML 27 | 28 | 29 | J2ME issuesJava 30 | 31 | 32 | Java 33 | 34 | 35 | Kotlin 36 | 37 | 38 | LintAndroid 39 | 40 | 41 | MigrationKotlin 42 | 43 | 44 | Resource managementJava 45 | 46 | 47 | SecurityJava 48 | 49 | 50 | Serialization issuesJava 51 | 52 | 53 | Style issuesKotlin 54 | 55 | 56 | Threading issuesJava 57 | 58 | 59 | UsabilityLintAndroid 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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 [yyyy] [name of copyright owner] 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 | # Punch Through Android BLE Starter App 2 | 3 | > [!NOTE] 4 | > This project is currently up-to-date as of `compileSdkVersion` and `targetSdkVersion` of 34 (Android 14). 5 | 6 | --- 7 | 8 | [![CircleCI build status](https://circleci.com/gh/PunchThrough/ble-starter-android/tree/master.svg?style=svg)](https://circleci.com/gh/PunchThrough/ble-starter-android/tree/master) 9 | 10 | Companion Android app project for [Punch Through](https://punchthrough.com)'s "Ultimate Guide to Android BLE Development" blog post for beginners, with examples of how to perform basic BLE operations and some Android BLE tips and tricks on the following: 11 | 12 | - Scanning for nearby BLE devices 13 | - Connecting to BLE devices 14 | - Discovering services and characteristics 15 | - Requesting an ATT MTU update 16 | - Reading and writing data on characteristics and descriptors 17 | - Enabling and disabling notifications and indications on characteristics 18 | - Bonding with a BLE device 19 | - Implementing your own BLE operations serial queuing mechanism 20 | 21 | ## Setup 22 | 23 | 1. Clone the project to your directory of choice. 24 | 25 | ``` 26 | git clone https://github.com/PunchThrough/ble-starter-android.git 27 | ``` 28 | 29 | 2. Launch Android Studio and select "Open an existing Android Studio project". 30 | 3. Navigate to the directory where you cloned the project to, and double click on it. 31 | 4. Wait for Gradle sync to complete. 32 | 33 | ## Requirements 34 | 35 | This project targets Android 14 and has a min SDK requirement of 21 (Android 5.0), in line with our recommendation in [4 Tips to Make Android BLE Actually Work](https://punchthrough.com/android-ble-development-tips/). 36 | 37 | ## Contributing 38 | 39 | ### Reporting bugs 40 | 41 | Please [open an issue](https://github.com/PunchThrough/ble-starter-android/issues/new) to report a bug if the app isn't behaving as expected. 42 | 43 | ### Opening a Pull Request 44 | 45 | Please fork the repository and create a feature branch before opening a Pull Request against the `master` branch. 46 | 47 | ### Linting and code style 48 | 49 | The project uses Kotlin's default [coding conventions](https://kotlinlang.org/docs/reference/coding-conventions.html) and includes the `.idea/codeStyle` directory in source control. The project also runs [`ktlint`](https://ktlint.github.io) as part of the CI process to ensure code consistency. 50 | 51 | You may run `ktlint` locally using the following command: 52 | 53 | ``` 54 | ./gradlew ktlint 55 | ``` 56 | 57 | Some simpler violations can be automatically formatted by `ktlint` using the following command: 58 | 59 | ``` 60 | ./gradlew ktlintFormat 61 | ``` 62 | 63 | ## Licensing 64 | 65 | This project is licensed under the Apache 2.0. For more details, please see [LICENSE](https://github.com/PunchThrough/ble-starter-android/blob/master/LICENSE). 66 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | apply plugin: 'com.android.application' 18 | apply plugin: 'kotlin-android' 19 | 20 | android { 21 | compileSdk 34 22 | defaultConfig { 23 | applicationId "com.punchthrough.blestarterappandroid" 24 | minSdk 21 25 | targetSdk 34 26 | versionCode 2 27 | versionName "2.0" 28 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 29 | } 30 | buildFeatures { 31 | viewBinding true 32 | buildConfig true 33 | } 34 | buildTypes { 35 | release { 36 | minifyEnabled false 37 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 38 | } 39 | } 40 | compileOptions { 41 | sourceCompatibility JavaVersion.VERSION_17 42 | targetCompatibility JavaVersion.VERSION_17 43 | } 44 | namespace 'com.punchthrough.blestarterappandroid' 45 | } 46 | 47 | configurations { 48 | ktlint 49 | } 50 | 51 | dependencies { 52 | implementation fileTree(dir: 'libs', include: ['*.jar']) 53 | implementation 'androidx.appcompat:appcompat:1.6.1' 54 | implementation 'androidx.core:core-ktx:1.12.0' 55 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 56 | implementation 'androidx.recyclerview:recyclerview:1.3.2' 57 | implementation 'com.jakewharton.timber:timber:4.7.1' 58 | 59 | ktlint "com.pinterest.ktlint:ktlint-cli:1.0.1" 60 | 61 | testImplementation 'junit:junit:4.13.2' 62 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 63 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 64 | } 65 | 66 | task ktlint(type: JavaExec, group: "verification") { 67 | description = "Check Kotlin code style." 68 | classpath = configurations.ktlint 69 | main = "com.pinterest.ktlint.Main" 70 | args "src/**/*.kt" 71 | } 72 | check.dependsOn ktlint 73 | 74 | task ktlintFormat(type: JavaExec, group: "formatting") { 75 | description = "Fix Kotlin code style deviations." 76 | classpath = configurations.ktlint 77 | main = "com.pinterest.ktlint.Main" 78 | args "-F", "src/**/*.kt" 79 | } 80 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/punchthrough/blestarterappandroid/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid 18 | 19 | import androidx.test.ext.junit.runners.AndroidJUnit4 20 | import androidx.test.platform.app.InstrumentationRegistry 21 | import org.junit.Assert.assertEquals 22 | import org.junit.Test 23 | import org.junit.runner.RunWith 24 | 25 | /** 26 | * Instrumented test, which will execute on an Android device. 27 | * 28 | * See [testing documentation](http://d.android.com/tools/testing). 29 | */ 30 | @RunWith(AndroidJUnit4::class) 31 | class ExampleInstrumentedTest { 32 | @Test 33 | fun useAppContext() { 34 | // Context of the app under test. 35 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 36 | assertEquals("com.punchthrough.blestarterappandroid", appContext.packageName) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 21 | 22 | 23 | 25 | 27 | 29 | 31 | 32 | 35 | 36 | 37 | 40 | 41 | 48 | 52 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/BleOperationsActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid 18 | 19 | import android.annotation.SuppressLint 20 | import android.app.Activity 21 | import android.app.AlertDialog 22 | import android.bluetooth.BluetoothDevice 23 | import android.bluetooth.BluetoothGattCharacteristic 24 | import android.content.Context 25 | import android.os.Bundle 26 | import android.view.MenuItem 27 | import android.view.View 28 | import android.view.WindowManager 29 | import android.view.inputmethod.InputMethodManager 30 | import android.widget.EditText 31 | import androidx.appcompat.app.AppCompatActivity 32 | import androidx.recyclerview.widget.LinearLayoutManager 33 | import androidx.recyclerview.widget.RecyclerView 34 | import androidx.recyclerview.widget.SimpleItemAnimator 35 | import com.punchthrough.blestarterappandroid.ble.ConnectionEventListener 36 | import com.punchthrough.blestarterappandroid.ble.ConnectionManager 37 | import com.punchthrough.blestarterappandroid.ble.ConnectionManager.parcelableExtraCompat 38 | import com.punchthrough.blestarterappandroid.ble.isIndicatable 39 | import com.punchthrough.blestarterappandroid.ble.isNotifiable 40 | import com.punchthrough.blestarterappandroid.ble.isReadable 41 | import com.punchthrough.blestarterappandroid.ble.isWritable 42 | import com.punchthrough.blestarterappandroid.ble.isWritableWithoutResponse 43 | import com.punchthrough.blestarterappandroid.ble.toHexString 44 | import com.punchthrough.blestarterappandroid.databinding.ActivityBleOperationsBinding 45 | import java.text.SimpleDateFormat 46 | import java.util.Date 47 | import java.util.Locale 48 | import java.util.UUID 49 | 50 | class BleOperationsActivity : AppCompatActivity() { 51 | 52 | private lateinit var binding: ActivityBleOperationsBinding 53 | private val device: BluetoothDevice by lazy { 54 | intent.parcelableExtraCompat(BluetoothDevice.EXTRA_DEVICE) 55 | ?: error("Missing BluetoothDevice from MainActivity!") 56 | } 57 | private val dateFormatter = SimpleDateFormat("MMM d, HH:mm:ss", Locale.US) 58 | private val characteristics by lazy { 59 | ConnectionManager.servicesOnDevice(device)?.flatMap { service -> 60 | service.characteristics ?: listOf() 61 | } ?: listOf() 62 | } 63 | private val characteristicProperties by lazy { 64 | characteristics.associateWith { characteristic -> 65 | mutableListOf().apply { 66 | if (characteristic.isNotifiable()) add(CharacteristicProperty.Notifiable) 67 | if (characteristic.isIndicatable()) add(CharacteristicProperty.Indicatable) 68 | if (characteristic.isReadable()) add(CharacteristicProperty.Readable) 69 | if (characteristic.isWritable()) add(CharacteristicProperty.Writable) 70 | if (characteristic.isWritableWithoutResponse()) { 71 | add(CharacteristicProperty.WritableWithoutResponse) 72 | } 73 | }.toList() 74 | } 75 | } 76 | private val characteristicAdapter: CharacteristicAdapter by lazy { 77 | CharacteristicAdapter(characteristics) { characteristic -> 78 | showCharacteristicOptions(characteristic) 79 | } 80 | } 81 | private val notifyingCharacteristics = mutableListOf() 82 | 83 | override fun onCreate(savedInstanceState: Bundle?) { 84 | super.onCreate(savedInstanceState) 85 | ConnectionManager.registerListener(connectionEventListener) 86 | 87 | binding = ActivityBleOperationsBinding.inflate(layoutInflater) 88 | 89 | setContentView(binding.root) 90 | supportActionBar?.apply { 91 | setDisplayHomeAsUpEnabled(true) 92 | setDisplayShowTitleEnabled(true) 93 | title = getString(R.string.ble_playground) 94 | } 95 | setupRecyclerView() 96 | binding.requestMtuButton.setOnClickListener { 97 | val userInput = binding.mtuField.text 98 | if (userInput.isNotEmpty() && userInput.isNotBlank()) { 99 | userInput.toString().toIntOrNull()?.let { mtu -> 100 | log("Requesting for MTU value of $mtu") 101 | ConnectionManager.requestMtu(device, mtu) 102 | } ?: log("Invalid MTU value: $userInput") 103 | } else { 104 | log("Please specify a numeric value for desired ATT MTU (23-517)") 105 | } 106 | hideKeyboard() 107 | } 108 | } 109 | 110 | override fun onDestroy() { 111 | ConnectionManager.unregisterListener(connectionEventListener) 112 | ConnectionManager.teardownConnection(device) 113 | super.onDestroy() 114 | } 115 | 116 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 117 | when (item.itemId) { 118 | android.R.id.home -> { 119 | onBackPressed() 120 | return true 121 | } 122 | } 123 | return super.onOptionsItemSelected(item) 124 | } 125 | 126 | private fun setupRecyclerView() { 127 | binding.characteristicsRecyclerView.apply { 128 | adapter = characteristicAdapter 129 | layoutManager = LinearLayoutManager( 130 | this@BleOperationsActivity, 131 | RecyclerView.VERTICAL, 132 | false 133 | ) 134 | isNestedScrollingEnabled = false 135 | 136 | itemAnimator.let { 137 | if (it is SimpleItemAnimator) { 138 | it.supportsChangeAnimations = false 139 | } 140 | } 141 | } 142 | } 143 | 144 | @SuppressLint("SetTextI18n") 145 | private fun log(message: String) { 146 | val formattedMessage = "${dateFormatter.format(Date())}: $message" 147 | runOnUiThread { 148 | val uiText = binding.logTextView.text 149 | val currentLogText = uiText.ifEmpty { "Beginning of log." } 150 | binding.logTextView.text = "$currentLogText\n$formattedMessage" 151 | binding.logScrollView.post { binding.logScrollView.fullScroll(View.FOCUS_DOWN) } 152 | } 153 | } 154 | 155 | private fun showCharacteristicOptions( 156 | characteristic: BluetoothGattCharacteristic 157 | ) = runOnUiThread { 158 | characteristicProperties[characteristic]?.let { properties -> 159 | AlertDialog.Builder(this) 160 | .setTitle("Select an action to perform") 161 | .setItems(properties.map { it.action }.toTypedArray()) { _, i -> 162 | when (properties[i]) { 163 | CharacteristicProperty.Readable -> { 164 | log("Reading from ${characteristic.uuid}") 165 | ConnectionManager.readCharacteristic(device, characteristic) 166 | } 167 | CharacteristicProperty.Writable, CharacteristicProperty.WritableWithoutResponse -> { 168 | showWritePayloadDialog(characteristic) 169 | } 170 | CharacteristicProperty.Notifiable, CharacteristicProperty.Indicatable -> { 171 | if (notifyingCharacteristics.contains(characteristic.uuid)) { 172 | log("Disabling notifications on ${characteristic.uuid}") 173 | ConnectionManager.disableNotifications(device, characteristic) 174 | } else { 175 | log("Enabling notifications on ${characteristic.uuid}") 176 | ConnectionManager.enableNotifications(device, characteristic) 177 | } 178 | } 179 | } 180 | } 181 | .show() 182 | } 183 | } 184 | 185 | @SuppressLint("InflateParams") 186 | private fun showWritePayloadDialog(characteristic: BluetoothGattCharacteristic) { 187 | val hexField = layoutInflater.inflate(R.layout.edittext_hex_payload, null) as EditText 188 | AlertDialog.Builder(this) 189 | .setView(hexField) 190 | .setPositiveButton("Write") { _, _ -> 191 | with(hexField.text.toString()) { 192 | if (isNotBlank() && isNotEmpty()) { 193 | val bytes = hexToBytes() 194 | log("Writing to ${characteristic.uuid}: ${bytes.toHexString()}") 195 | ConnectionManager.writeCharacteristic(device, characteristic, bytes) 196 | } else { 197 | log("Please enter a hex payload to write to ${characteristic.uuid}") 198 | } 199 | } 200 | } 201 | .setNegativeButton("Cancel", null) 202 | .create() 203 | .apply { 204 | window?.setSoftInputMode( 205 | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE 206 | ) 207 | hexField.showKeyboard() 208 | show() 209 | } 210 | } 211 | 212 | private val connectionEventListener by lazy { 213 | ConnectionEventListener().apply { 214 | onDisconnect = { 215 | runOnUiThread { 216 | AlertDialog.Builder(this@BleOperationsActivity) 217 | .setTitle("Disconnected") 218 | .setMessage("Disconnected from device.") 219 | .setPositiveButton("OK") { _, _ -> onBackPressed() } 220 | .show() 221 | } 222 | } 223 | 224 | onCharacteristicRead = { _, characteristic, value -> 225 | log("Read from ${characteristic.uuid}: ${value.toHexString()}") 226 | } 227 | 228 | onCharacteristicWrite = { _, characteristic -> 229 | log("Wrote to ${characteristic.uuid}") 230 | } 231 | 232 | onMtuChanged = { _, mtu -> 233 | log("MTU updated to $mtu") 234 | } 235 | 236 | onCharacteristicChanged = { _, characteristic, value -> 237 | log("Value changed on ${characteristic.uuid}: ${value.toHexString()}") 238 | } 239 | 240 | onNotificationsEnabled = { _, characteristic -> 241 | log("Enabled notifications on ${characteristic.uuid}") 242 | notifyingCharacteristics.add(characteristic.uuid) 243 | } 244 | 245 | onNotificationsDisabled = { _, characteristic -> 246 | log("Disabled notifications on ${characteristic.uuid}") 247 | notifyingCharacteristics.remove(characteristic.uuid) 248 | } 249 | } 250 | } 251 | 252 | private enum class CharacteristicProperty { 253 | Readable, 254 | Writable, 255 | WritableWithoutResponse, 256 | Notifiable, 257 | Indicatable; 258 | 259 | val action 260 | get() = when (this) { 261 | Readable -> "Read" 262 | Writable -> "Write" 263 | WritableWithoutResponse -> "Write Without Response" 264 | Notifiable -> "Toggle Notifications" 265 | Indicatable -> "Toggle Indications" 266 | } 267 | } 268 | 269 | private fun Activity.hideKeyboard() { 270 | hideKeyboard(currentFocus ?: View(this)) 271 | } 272 | 273 | private fun Context.hideKeyboard(view: View) { 274 | val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager 275 | inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) 276 | } 277 | 278 | private fun EditText.showKeyboard() { 279 | val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 280 | requestFocus() 281 | inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) 282 | } 283 | 284 | private fun String.hexToBytes() = 285 | this.chunked(2).map { it.uppercase(Locale.US).toInt(16).toByte() }.toByteArray() 286 | } 287 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/CharacteristicAdapter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid 18 | 19 | import android.bluetooth.BluetoothGattCharacteristic 20 | import android.view.LayoutInflater 21 | import android.view.View 22 | import android.view.ViewGroup 23 | import android.widget.TextView 24 | import androidx.recyclerview.widget.RecyclerView 25 | import com.punchthrough.blestarterappandroid.ble.printProperties 26 | 27 | class CharacteristicAdapter( 28 | private val items: List, 29 | private val onClickListener: ((characteristic: BluetoothGattCharacteristic) -> Unit) 30 | ) : RecyclerView.Adapter() { 31 | 32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 33 | val view = LayoutInflater.from(parent.context).inflate( 34 | R.layout.row_characteristic, 35 | parent, 36 | false 37 | ) 38 | return ViewHolder(view, onClickListener) 39 | } 40 | 41 | override fun getItemCount() = items.size 42 | 43 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 44 | val item = items[position] 45 | holder.bind(item) 46 | } 47 | 48 | class ViewHolder( 49 | private val view: View, 50 | private val onClickListener: ((characteristic: BluetoothGattCharacteristic) -> Unit) 51 | ) : RecyclerView.ViewHolder(view) { 52 | 53 | fun bind(characteristic: BluetoothGattCharacteristic) { 54 | view.findViewById(R.id.characteristic_uuid).text = 55 | characteristic.uuid.toString() 56 | view.findViewById(R.id.characteristic_properties).text = 57 | characteristic.printProperties() 58 | view.setOnClickListener { onClickListener.invoke(characteristic) } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid 18 | 19 | import android.Manifest 20 | import android.annotation.SuppressLint 21 | import android.app.Activity 22 | import android.app.AlertDialog 23 | import android.bluetooth.BluetoothAdapter 24 | import android.bluetooth.BluetoothDevice 25 | import android.bluetooth.BluetoothManager 26 | import android.bluetooth.le.ScanCallback 27 | import android.bluetooth.le.ScanResult 28 | import android.bluetooth.le.ScanSettings 29 | import android.content.ActivityNotFoundException 30 | import android.content.Context 31 | import android.content.Intent 32 | import android.content.pm.PackageManager 33 | import android.net.Uri 34 | import android.os.Bundle 35 | import android.provider.Settings 36 | import android.widget.Toast 37 | import androidx.activity.result.contract.ActivityResultContracts 38 | import androidx.annotation.UiThread 39 | import androidx.appcompat.app.AppCompatActivity 40 | import androidx.core.app.ActivityCompat 41 | import androidx.recyclerview.widget.LinearLayoutManager 42 | import androidx.recyclerview.widget.RecyclerView 43 | import androidx.recyclerview.widget.SimpleItemAnimator 44 | import com.punchthrough.blestarterappandroid.ble.ConnectionEventListener 45 | import com.punchthrough.blestarterappandroid.ble.ConnectionManager 46 | import com.punchthrough.blestarterappandroid.databinding.ActivityMainBinding 47 | import timber.log.Timber 48 | 49 | private const val PERMISSION_REQUEST_CODE = 1 50 | 51 | class MainActivity : AppCompatActivity() { 52 | 53 | /******************************************* 54 | * Properties 55 | *******************************************/ 56 | 57 | private lateinit var binding: ActivityMainBinding 58 | 59 | private val bluetoothAdapter: BluetoothAdapter by lazy { 60 | val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager 61 | bluetoothManager.adapter 62 | } 63 | 64 | private val bleScanner by lazy { 65 | bluetoothAdapter.bluetoothLeScanner 66 | } 67 | 68 | private val scanSettings = ScanSettings.Builder() 69 | .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) 70 | .build() 71 | 72 | private var isScanning = false 73 | set(value) { 74 | field = value 75 | runOnUiThread { binding.scanButton.text = if (value) "Stop Scan" else "Start Scan" } 76 | } 77 | 78 | private val scanResults = mutableListOf() 79 | private val scanResultAdapter: ScanResultAdapter by lazy { 80 | ScanResultAdapter(scanResults) { result -> 81 | if (isScanning) { 82 | stopBleScan() 83 | } 84 | with(result.device) { 85 | Timber.w("Connecting to $address") 86 | ConnectionManager.connect(this, this@MainActivity) 87 | } 88 | } 89 | } 90 | 91 | private val bluetoothEnablingResult = registerForActivityResult( 92 | ActivityResultContracts.StartActivityForResult() 93 | ) { result -> 94 | if (result.resultCode == Activity.RESULT_OK) { 95 | Timber.i("Bluetooth is enabled, good to go") 96 | } else { 97 | Timber.e("User dismissed or denied Bluetooth prompt") 98 | promptEnableBluetooth() 99 | } 100 | } 101 | 102 | /******************************************* 103 | * Activity function overrides 104 | *******************************************/ 105 | 106 | override fun onCreate(savedInstanceState: Bundle?) { 107 | super.onCreate(savedInstanceState) 108 | binding = ActivityMainBinding.inflate(layoutInflater) 109 | setContentView(binding.root) 110 | if (BuildConfig.DEBUG) { 111 | Timber.plant(Timber.DebugTree()) 112 | } 113 | binding.scanButton.setOnClickListener { if (isScanning) stopBleScan() else startBleScan() } 114 | setupRecyclerView() 115 | } 116 | 117 | override fun onResume() { 118 | super.onResume() 119 | ConnectionManager.registerListener(connectionEventListener) 120 | if (!bluetoothAdapter.isEnabled) { 121 | promptEnableBluetooth() 122 | } 123 | } 124 | 125 | override fun onPause() { 126 | super.onPause() 127 | if (isScanning) { 128 | stopBleScan() 129 | } 130 | ConnectionManager.unregisterListener(connectionEventListener) 131 | } 132 | 133 | override fun onRequestPermissionsResult( 134 | requestCode: Int, 135 | permissions: Array, 136 | grantResults: IntArray 137 | ) { 138 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 139 | if (requestCode != PERMISSION_REQUEST_CODE) { 140 | return 141 | } 142 | if (permissions.isEmpty() && grantResults.isEmpty()) { 143 | Timber.e("Empty permissions and grantResults array in onRequestPermissionsResult") 144 | Timber.w("This is likely a cancellation due to user interaction interrupted") 145 | return 146 | } 147 | 148 | // Log permission request outcomes 149 | val resultsDescriptions = grantResults.map { 150 | when (it) { 151 | PackageManager.PERMISSION_DENIED -> "Denied" 152 | PackageManager.PERMISSION_GRANTED -> "Granted" 153 | else -> "Unknown" 154 | } 155 | } 156 | Timber.w("Permissions: ${permissions.toList()}, grant results: $resultsDescriptions") 157 | 158 | // A denied permission is permanently denied if shouldShowRequestPermissionRationale is false 159 | val containsPermanentDenial = permissions.zip(grantResults.toTypedArray()).any { 160 | it.second == PackageManager.PERMISSION_DENIED && 161 | !ActivityCompat.shouldShowRequestPermissionRationale(this, it.first) 162 | } 163 | val containsDenial = grantResults.any { it == PackageManager.PERMISSION_DENIED } 164 | val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } 165 | when { 166 | containsPermanentDenial -> { 167 | Timber.e("User permanently denied granting of permissions") 168 | Timber.e("Requesting for manual granting of permissions from App Settings") 169 | promptManualPermissionGranting() 170 | } 171 | containsDenial -> { 172 | // It's still possible to re-request permissions 173 | requestRelevantBluetoothPermissions(PERMISSION_REQUEST_CODE) 174 | } 175 | allGranted && hasRequiredBluetoothPermissions() -> { 176 | startBleScan() 177 | } 178 | else -> { 179 | Timber.e("Unexpected scenario encountered when handling permissions") 180 | recreate() 181 | } 182 | } 183 | } 184 | 185 | /******************************************* 186 | * Private functions 187 | *******************************************/ 188 | 189 | /** 190 | * Prompts the user to enable Bluetooth via a system dialog. 191 | * 192 | * For Android 12+, [Manifest.permission.BLUETOOTH_CONNECT] is required to use 193 | * the [BluetoothAdapter.ACTION_REQUEST_ENABLE] intent. 194 | */ 195 | private fun promptEnableBluetooth() { 196 | if (hasRequiredBluetoothPermissions() && !bluetoothAdapter.isEnabled) { 197 | Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE).apply { 198 | bluetoothEnablingResult.launch(this) 199 | } 200 | } 201 | } 202 | 203 | @SuppressLint("MissingPermission, NotifyDataSetChanged") // Check performed inside extension fun 204 | private fun startBleScan() { 205 | if (!hasRequiredBluetoothPermissions()) { 206 | requestRelevantBluetoothPermissions(PERMISSION_REQUEST_CODE) 207 | } else { 208 | scanResults.clear() 209 | scanResultAdapter.notifyDataSetChanged() 210 | bleScanner.startScan(null, scanSettings, scanCallback) 211 | isScanning = true 212 | } 213 | } 214 | 215 | @SuppressLint("MissingPermission") // Check performed inside extension fun 216 | private fun stopBleScan() { 217 | if (hasRequiredBluetoothPermissions()) { 218 | bleScanner.stopScan(scanCallback) 219 | isScanning = false 220 | } 221 | } 222 | 223 | @UiThread 224 | private fun setupRecyclerView() { 225 | binding.scanResultsRecyclerView.apply { 226 | adapter = scanResultAdapter 227 | layoutManager = LinearLayoutManager( 228 | this@MainActivity, 229 | RecyclerView.VERTICAL, 230 | false 231 | ) 232 | isNestedScrollingEnabled = false 233 | itemAnimator.let { 234 | if (it is SimpleItemAnimator) { 235 | it.supportsChangeAnimations = false 236 | } 237 | } 238 | } 239 | } 240 | 241 | @UiThread 242 | private fun promptManualPermissionGranting() { 243 | AlertDialog.Builder(this) 244 | .setTitle(R.string.please_grant_relevant_permissions) 245 | .setMessage(R.string.app_settings_rationale) 246 | .setPositiveButton(R.string.app_settings) { _, _ -> 247 | try { 248 | startActivity( 249 | Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { 250 | data = Uri.parse("package:$packageName") 251 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 252 | } 253 | ) 254 | } catch (e: ActivityNotFoundException) { 255 | if (!isFinishing) { 256 | Toast.makeText( 257 | this, 258 | R.string.cannot_launch_app_settings, 259 | Toast.LENGTH_LONG 260 | ).show() 261 | } 262 | } 263 | finish() 264 | } 265 | .setNegativeButton(R.string.quit) { _, _ -> finishAndRemoveTask() } 266 | .setCancelable(false) 267 | .show() 268 | } 269 | 270 | /******************************************* 271 | * Callback bodies 272 | *******************************************/ 273 | 274 | // If we're getting a scan result, we already have the relevant permission(s) 275 | @SuppressLint("MissingPermission") 276 | private val scanCallback = object : ScanCallback() { 277 | override fun onScanResult(callbackType: Int, result: ScanResult) { 278 | val indexQuery = scanResults.indexOfFirst { it.device.address == result.device.address } 279 | if (indexQuery != -1) { // A scan result already exists with the same address 280 | scanResults[indexQuery] = result 281 | scanResultAdapter.notifyItemChanged(indexQuery) 282 | } else { 283 | with(result.device) { 284 | Timber.i("Found BLE device! Name: ${name ?: "Unnamed"}, address: $address") 285 | } 286 | scanResults.add(result) 287 | scanResultAdapter.notifyItemInserted(scanResults.size - 1) 288 | } 289 | } 290 | 291 | override fun onScanFailed(errorCode: Int) { 292 | Timber.e("onScanFailed: code $errorCode") 293 | } 294 | } 295 | 296 | private val connectionEventListener by lazy { 297 | ConnectionEventListener().apply { 298 | onConnectionSetupComplete = { gatt -> 299 | Intent(this@MainActivity, BleOperationsActivity::class.java).also { 300 | it.putExtra(BluetoothDevice.EXTRA_DEVICE, gatt.device) 301 | startActivity(it) 302 | } 303 | } 304 | @SuppressLint("MissingPermission") 305 | onDisconnect = { 306 | val deviceName = if (hasRequiredBluetoothPermissions()) { 307 | it.name 308 | } else { 309 | "device" 310 | } 311 | runOnUiThread { 312 | AlertDialog.Builder(this@MainActivity) 313 | .setTitle(R.string.disconnected) 314 | .setMessage( 315 | getString(R.string.disconnected_or_unable_to_connect_to_device, deviceName) 316 | ) 317 | .setPositiveButton(R.string.ok, null) 318 | .show() 319 | } 320 | } 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/PermissionsHelper.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid 18 | 19 | import android.Manifest 20 | import android.app.Activity 21 | import android.app.AlertDialog 22 | import android.content.Context 23 | import android.content.pm.PackageManager 24 | import android.os.Build 25 | import androidx.annotation.RequiresApi 26 | import androidx.core.app.ActivityCompat 27 | import androidx.core.content.ContextCompat 28 | import timber.log.Timber 29 | 30 | /** 31 | * Determine whether the current [Context] has been granted the relevant [Manifest.permission]. 32 | */ 33 | fun Context.hasPermission(permissionType: String): Boolean { 34 | return ContextCompat.checkSelfPermission(this, permissionType) == 35 | PackageManager.PERMISSION_GRANTED 36 | } 37 | 38 | /** 39 | * Determine whether the current [Context] has been granted the relevant permissions to perform 40 | * Bluetooth operations depending on the mobile device's Android version. 41 | */ 42 | fun Context.hasRequiredBluetoothPermissions(): Boolean { 43 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 44 | hasPermission(Manifest.permission.BLUETOOTH_SCAN) && 45 | hasPermission(Manifest.permission.BLUETOOTH_CONNECT) 46 | } else { 47 | hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) 48 | } 49 | } 50 | 51 | /** 52 | * Request for the necessary permissions for Bluetooth operations to work. 53 | */ 54 | fun Activity.requestRelevantBluetoothPermissions(requestCode: Int) { 55 | if (hasRequiredBluetoothPermissions()) { 56 | Timber.w("Required permission(s) for Bluetooth already granted") 57 | return 58 | } 59 | when { 60 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 61 | if (bluetoothPermissionRationaleRequired()) { 62 | displayNearbyDevicesPermissionRationale(requestCode) 63 | } else { 64 | requestNearbyDevicesPermissions(requestCode) 65 | } 66 | } 67 | Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> { 68 | if (locationPermissionRationaleRequired()) { 69 | displayLocationPermissionRationale(requestCode) 70 | } else { 71 | requestLocationPermission(requestCode) 72 | } 73 | } 74 | } 75 | } 76 | 77 | //region Location permission 78 | private fun Activity.locationPermissionRationaleRequired(): Boolean { 79 | return ActivityCompat.shouldShowRequestPermissionRationale( 80 | this, 81 | Manifest.permission.ACCESS_FINE_LOCATION 82 | ) 83 | } 84 | 85 | private fun Activity.displayLocationPermissionRationale(requestCode: Int) { 86 | runOnUiThread { 87 | AlertDialog.Builder(this) 88 | .setTitle(R.string.location_permission_required) 89 | .setMessage(R.string.location_permission_rationale) 90 | .setPositiveButton(android.R.string.ok) { _, _ -> 91 | requestLocationPermission(requestCode) 92 | } 93 | .setNegativeButton(R.string.quit) { _, _ -> finishAndRemoveTask() } 94 | .setCancelable(false) 95 | .show() 96 | } 97 | } 98 | 99 | private fun Activity.requestLocationPermission(requestCode: Int) { 100 | ActivityCompat.requestPermissions( 101 | this, 102 | arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 103 | requestCode 104 | ) 105 | } 106 | //endregion 107 | 108 | //region Nearby Devices permissions 109 | private fun Activity.bluetoothPermissionRationaleRequired(): Boolean { 110 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 111 | ActivityCompat.shouldShowRequestPermissionRationale( 112 | this, Manifest.permission.BLUETOOTH_SCAN 113 | ) || ActivityCompat.shouldShowRequestPermissionRationale( 114 | this, Manifest.permission.BLUETOOTH_CONNECT 115 | ) 116 | } else { 117 | false 118 | } 119 | } 120 | 121 | @RequiresApi(Build.VERSION_CODES.S) 122 | private fun Activity.displayNearbyDevicesPermissionRationale(requestCode: Int) { 123 | runOnUiThread { 124 | AlertDialog.Builder(this) 125 | .setTitle(R.string.bluetooth_permission_required) 126 | .setMessage(R.string.bluetooth_permission_rationale) 127 | .setPositiveButton(android.R.string.ok) { _, _ -> 128 | requestNearbyDevicesPermissions(requestCode) 129 | } 130 | .setNegativeButton(R.string.quit) { _, _ -> finishAndRemoveTask() } 131 | .setCancelable(false) 132 | .show() 133 | } 134 | } 135 | 136 | @RequiresApi(Build.VERSION_CODES.S) 137 | private fun Activity.requestNearbyDevicesPermissions(requestCode: Int) { 138 | ActivityCompat.requestPermissions( 139 | this, 140 | arrayOf( 141 | Manifest.permission.BLUETOOTH_SCAN, 142 | Manifest.permission.BLUETOOTH_CONNECT 143 | ), 144 | requestCode 145 | ) 146 | } 147 | //endregion 148 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/ScanResultAdapter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid 18 | 19 | import android.annotation.SuppressLint 20 | import android.bluetooth.le.ScanResult 21 | import android.view.LayoutInflater 22 | import android.view.View 23 | import android.view.ViewGroup 24 | import android.widget.TextView 25 | import androidx.recyclerview.widget.RecyclerView 26 | 27 | class ScanResultAdapter( 28 | private val items: List, 29 | private val onClickListener: ((device: ScanResult) -> Unit) 30 | ) : RecyclerView.Adapter() { 31 | 32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 33 | val view = LayoutInflater.from(parent.context).inflate( 34 | R.layout.row_scan_result, 35 | parent, 36 | false 37 | ) 38 | return ViewHolder(view, onClickListener) 39 | } 40 | 41 | override fun getItemCount() = items.size 42 | 43 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 44 | val item = items[position] 45 | holder.bind(item) 46 | } 47 | 48 | class ViewHolder( 49 | private val view: View, 50 | private val onClickListener: ((device: ScanResult) -> Unit) 51 | ) : RecyclerView.ViewHolder(view) { 52 | 53 | @SuppressLint("MissingPermission", "SetTextI18n") 54 | fun bind(result: ScanResult) { 55 | view.findViewById(R.id.device_name).text = 56 | if (view.context.hasRequiredBluetoothPermissions()) { 57 | result.device.name ?: "Unnamed" 58 | } else { 59 | error("Missing required Bluetooth permissions") 60 | } 61 | view.findViewById(R.id.mac_address).text = result.device.address 62 | view.findViewById(R.id.signal_strength).text = "${result.rssi} dBm" 63 | view.setOnClickListener { onClickListener.invoke(result) } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/ble/BleExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid.ble 18 | 19 | import android.annotation.SuppressLint 20 | import android.annotation.TargetApi 21 | import android.bluetooth.BluetoothGatt 22 | import android.bluetooth.BluetoothGattCharacteristic 23 | import android.bluetooth.BluetoothGattDescriptor 24 | import android.os.Build 25 | import timber.log.Timber 26 | import java.util.Locale 27 | import java.util.UUID 28 | 29 | /** UUID of the Client Characteristic Configuration Descriptor (0x2902). */ 30 | const val CCC_DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805F9B34FB" 31 | 32 | // BluetoothGatt 33 | 34 | fun BluetoothGatt.printGattTable() { 35 | if (services.isEmpty()) { 36 | Timber.i("No service and characteristic available, call discoverServices() first?") 37 | return 38 | } 39 | services.forEach { service -> 40 | val characteristicsTable = service.characteristics.joinToString( 41 | separator = "\n|--", 42 | prefix = "|--" 43 | ) { char -> 44 | var description = "${char.uuid}: ${char.printProperties()}" 45 | if (char.descriptors.isNotEmpty()) { 46 | description += "\n" + char.descriptors.joinToString( 47 | separator = "\n|------", 48 | prefix = "|------" 49 | ) { descriptor -> 50 | "${descriptor.uuid}: ${descriptor.printProperties()}" 51 | } 52 | } 53 | description 54 | } 55 | Timber.i("Service ${service.uuid}\nCharacteristics:\n$characteristicsTable") 56 | } 57 | } 58 | 59 | fun BluetoothGatt.findCharacteristic( 60 | characteristicUuid: UUID, 61 | serviceUuid: UUID? = null 62 | ): BluetoothGattCharacteristic? { 63 | return if (serviceUuid != null) { 64 | // If serviceUuid is available, use it to disambiguate cases where multiple services have 65 | // distinct characteristics that happen to use the same UUID 66 | services 67 | ?.firstOrNull { it.uuid == serviceUuid } 68 | ?.characteristics?.firstOrNull { it.uuid == characteristicUuid } 69 | } else { 70 | // Iterate through services and find the first one with a match for the characteristic UUID 71 | services?.forEach { service -> 72 | service.characteristics?.firstOrNull { characteristic -> 73 | characteristic.uuid == characteristicUuid 74 | }?.let { matchingCharacteristic -> 75 | return matchingCharacteristic 76 | } 77 | } 78 | return null 79 | } 80 | } 81 | 82 | fun BluetoothGatt.findDescriptor( 83 | descriptorUuid: UUID, 84 | characteristicUuid: UUID? = null, 85 | serviceUuid: UUID? = null 86 | ): BluetoothGattDescriptor? { 87 | return if (characteristicUuid != null && serviceUuid != null) { 88 | // Use extra context to disambiguate between cases where there could be multiple descriptors 89 | // with the same UUID (e.g., the CCCD) belonging to different characteristics or services 90 | services 91 | ?.firstOrNull { it.uuid == serviceUuid } 92 | ?.characteristics?.firstOrNull { it.uuid == characteristicUuid } 93 | ?.descriptors?.firstOrNull { it.uuid == descriptorUuid } 94 | } else { 95 | services?.forEach { service -> 96 | service.characteristics.forEach { characteristic -> 97 | characteristic.descriptors?.firstOrNull { descriptor -> 98 | descriptor.uuid == descriptorUuid 99 | }?.let { matchingDescriptor -> 100 | return matchingDescriptor 101 | } 102 | } 103 | } 104 | return null 105 | } 106 | } 107 | 108 | // BluetoothGattCharacteristic 109 | 110 | fun BluetoothGattCharacteristic.printProperties(): String = mutableListOf().apply { 111 | if (isReadable()) add("READABLE") 112 | if (isWritable()) add("WRITABLE") 113 | if (isWritableWithoutResponse()) add("WRITABLE WITHOUT RESPONSE") 114 | if (isIndicatable()) add("INDICATABLE") 115 | if (isNotifiable()) add("NOTIFIABLE") 116 | if (isEmpty()) add("EMPTY") 117 | }.joinToString() 118 | 119 | fun BluetoothGattCharacteristic.isReadable(): Boolean = 120 | containsProperty(BluetoothGattCharacteristic.PROPERTY_READ) 121 | 122 | fun BluetoothGattCharacteristic.isWritable(): Boolean = 123 | containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE) 124 | 125 | fun BluetoothGattCharacteristic.isWritableWithoutResponse(): Boolean = 126 | containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) 127 | 128 | fun BluetoothGattCharacteristic.isIndicatable(): Boolean = 129 | containsProperty(BluetoothGattCharacteristic.PROPERTY_INDICATE) 130 | 131 | fun BluetoothGattCharacteristic.isNotifiable(): Boolean = 132 | containsProperty(BluetoothGattCharacteristic.PROPERTY_NOTIFY) 133 | 134 | fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean = 135 | properties and property != 0 136 | 137 | @SuppressLint("MissingPermission") 138 | fun BluetoothGattCharacteristic.executeWrite( 139 | gatt: BluetoothGatt, 140 | payload: ByteArray, 141 | writeType: Int 142 | ) { 143 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 144 | gatt.writeCharacteristic(this, payload, writeType) 145 | } else { 146 | // Fall back to deprecated version of writeCharacteristic for Android <13 147 | legacyCharacteristicWrite(gatt, payload, writeType) 148 | } 149 | } 150 | 151 | @TargetApi(Build.VERSION_CODES.S) 152 | @SuppressLint("MissingPermission") 153 | @Suppress("DEPRECATION") 154 | private fun BluetoothGattCharacteristic.legacyCharacteristicWrite( 155 | gatt: BluetoothGatt, 156 | payload: ByteArray, 157 | writeType: Int 158 | ) { 159 | this.writeType = writeType 160 | value = payload 161 | gatt.writeCharacteristic(this) 162 | } 163 | 164 | // BluetoothGattDescriptor 165 | 166 | fun BluetoothGattDescriptor.printProperties(): String = mutableListOf().apply { 167 | if (isReadable()) add("READABLE") 168 | if (isWritable()) add("WRITABLE") 169 | if (isEmpty()) add("EMPTY") 170 | }.joinToString() 171 | 172 | fun BluetoothGattDescriptor.isReadable(): Boolean = 173 | containsPermission(BluetoothGattDescriptor.PERMISSION_READ) 174 | 175 | fun BluetoothGattDescriptor.isWritable(): Boolean = 176 | containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE) 177 | 178 | fun BluetoothGattDescriptor.containsPermission(permission: Int): Boolean = 179 | permissions and permission != 0 180 | 181 | @SuppressLint("MissingPermission") 182 | fun BluetoothGattDescriptor.executeWrite( 183 | gatt: BluetoothGatt, 184 | payload: ByteArray 185 | ) { 186 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 187 | gatt.writeDescriptor(this, payload) 188 | } else { 189 | // Fall back to deprecated version of writeDescriptor for Android <13 190 | legacyDescriptorWrite(gatt, payload) 191 | } 192 | } 193 | 194 | @TargetApi(Build.VERSION_CODES.S) 195 | @SuppressLint("MissingPermission") 196 | @Suppress("DEPRECATION") 197 | private fun BluetoothGattDescriptor.legacyDescriptorWrite( 198 | gatt: BluetoothGatt, 199 | payload: ByteArray 200 | ) { 201 | value = payload 202 | gatt.writeDescriptor(this) 203 | } 204 | 205 | /** 206 | * Convenience extension function that returns true if this [BluetoothGattDescriptor] 207 | * is a Client Characteristic Configuration Descriptor. 208 | */ 209 | fun BluetoothGattDescriptor.isCccd() = 210 | uuid.toString().uppercase(Locale.US) == CCC_DESCRIPTOR_UUID.uppercase(Locale.US) 211 | 212 | // ByteArray 213 | 214 | fun ByteArray.toHexString(): String = 215 | joinToString(separator = " ", prefix = "0x") { String.format("%02X", it) } 216 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/ble/BleOperationType.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid.ble 18 | 19 | import android.bluetooth.BluetoothDevice 20 | import android.content.Context 21 | import java.util.UUID 22 | 23 | /** Abstract sealed class representing a type of BLE operation. */ 24 | sealed class BleOperationType { 25 | abstract val device: BluetoothDevice 26 | } 27 | 28 | /** Connect to [device] and perform service discovery. */ 29 | data class Connect(override val device: BluetoothDevice, val context: Context) : BleOperationType() 30 | 31 | /** Disconnect from [device] and release all connection resources. */ 32 | data class Disconnect(override val device: BluetoothDevice) : BleOperationType() 33 | 34 | /** Write [payload] as the value of a characteristic represented by [characteristicUuid]. */ 35 | data class CharacteristicWrite( 36 | override val device: BluetoothDevice, 37 | val characteristicUuid: UUID, 38 | val writeType: Int, 39 | val payload: ByteArray 40 | ) : BleOperationType() { 41 | override fun equals(other: Any?): Boolean { 42 | if (this === other) return true 43 | if (javaClass != other?.javaClass) return false 44 | 45 | other as CharacteristicWrite 46 | 47 | if (device != other.device) return false 48 | if (characteristicUuid != other.characteristicUuid) return false 49 | if (writeType != other.writeType) return false 50 | if (!payload.contentEquals(other.payload)) return false 51 | 52 | return true 53 | } 54 | 55 | override fun hashCode(): Int { 56 | var result = device.hashCode() 57 | result = 31 * result + characteristicUuid.hashCode() 58 | result = 31 * result + writeType 59 | result = 31 * result + payload.contentHashCode() 60 | return result 61 | } 62 | } 63 | 64 | /** Read the value of a characteristic represented by [characteristicUuid]. */ 65 | data class CharacteristicRead( 66 | override val device: BluetoothDevice, 67 | val characteristicUuid: UUID 68 | ) : BleOperationType() 69 | 70 | /** Write [payload] as the value of a descriptor represented by [descriptorUuid]. */ 71 | data class DescriptorWrite( 72 | override val device: BluetoothDevice, 73 | val descriptorUuid: UUID, 74 | val payload: ByteArray 75 | ) : BleOperationType() { 76 | override fun equals(other: Any?): Boolean { 77 | if (this === other) return true 78 | if (javaClass != other?.javaClass) return false 79 | 80 | other as DescriptorWrite 81 | 82 | if (device != other.device) return false 83 | if (descriptorUuid != other.descriptorUuid) return false 84 | if (!payload.contentEquals(other.payload)) return false 85 | 86 | return true 87 | } 88 | 89 | override fun hashCode(): Int { 90 | var result = device.hashCode() 91 | result = 31 * result + descriptorUuid.hashCode() 92 | result = 31 * result + payload.contentHashCode() 93 | return result 94 | } 95 | } 96 | 97 | /** Read the value of a descriptor represented by [descriptorUuid]. */ 98 | data class DescriptorRead( 99 | override val device: BluetoothDevice, 100 | val descriptorUuid: UUID 101 | ) : BleOperationType() 102 | 103 | /** Enable notifications/indications on a characteristic represented by [characteristicUuid]. */ 104 | data class EnableNotifications( 105 | override val device: BluetoothDevice, 106 | val characteristicUuid: UUID 107 | ) : BleOperationType() 108 | 109 | /** Disable notifications/indications on a characteristic represented by [characteristicUuid]. */ 110 | data class DisableNotifications( 111 | override val device: BluetoothDevice, 112 | val characteristicUuid: UUID 113 | ) : BleOperationType() 114 | 115 | /** Request for an MTU of [mtu]. */ 116 | data class MtuRequest( 117 | override val device: BluetoothDevice, 118 | val mtu: Int 119 | ) : BleOperationType() 120 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/ble/ConnectionEventListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid.ble 18 | 19 | import android.bluetooth.BluetoothDevice 20 | import android.bluetooth.BluetoothGatt 21 | import android.bluetooth.BluetoothGattCharacteristic 22 | import android.bluetooth.BluetoothGattDescriptor 23 | 24 | /** A listener containing callback methods to be registered with [ConnectionManager].*/ 25 | class ConnectionEventListener { 26 | var onConnectionSetupComplete: ((gatt: BluetoothGatt) -> Unit)? = null 27 | 28 | var onDisconnect: ((device: BluetoothDevice) -> Unit)? = null 29 | 30 | var onDescriptorRead: ( 31 | ( 32 | device: BluetoothDevice, 33 | descriptor: BluetoothGattDescriptor, 34 | value: ByteArray 35 | ) -> Unit 36 | )? = null 37 | 38 | var onDescriptorWrite: ( 39 | ( 40 | device: BluetoothDevice, 41 | descriptor: BluetoothGattDescriptor 42 | ) -> Unit 43 | )? = null 44 | 45 | var onCharacteristicChanged: ( 46 | ( 47 | device: BluetoothDevice, 48 | characteristic: BluetoothGattCharacteristic, 49 | value: ByteArray 50 | ) -> Unit 51 | )? = null 52 | 53 | var onCharacteristicRead: ( 54 | ( 55 | device: BluetoothDevice, 56 | characteristic: BluetoothGattCharacteristic, 57 | value: ByteArray 58 | ) -> Unit 59 | )? = null 60 | 61 | var onCharacteristicWrite: ( 62 | ( 63 | device: BluetoothDevice, 64 | characteristic: BluetoothGattCharacteristic 65 | ) -> Unit 66 | )? = null 67 | 68 | var onNotificationsEnabled: ( 69 | ( 70 | device: BluetoothDevice, 71 | characteristic: BluetoothGattCharacteristic 72 | ) -> Unit 73 | )? = null 74 | 75 | var onNotificationsDisabled: ( 76 | ( 77 | device: BluetoothDevice, 78 | characteristic: BluetoothGattCharacteristic 79 | ) -> Unit 80 | )? = null 81 | 82 | var onMtuChanged: ((device: BluetoothDevice, newMtu: Int) -> Unit)? = null 83 | } 84 | -------------------------------------------------------------------------------- /app/src/main/java/com/punchthrough/blestarterappandroid/ble/ConnectionManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Punch Through Design LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.punchthrough.blestarterappandroid.ble 18 | 19 | import android.annotation.SuppressLint 20 | import android.bluetooth.BluetoothDevice 21 | import android.bluetooth.BluetoothGatt 22 | import android.bluetooth.BluetoothGattCallback 23 | import android.bluetooth.BluetoothGattCharacteristic 24 | import android.bluetooth.BluetoothGattDescriptor 25 | import android.bluetooth.BluetoothGattService 26 | import android.bluetooth.BluetoothProfile 27 | import android.content.BroadcastReceiver 28 | import android.content.Context 29 | import android.content.Intent 30 | import android.content.IntentFilter 31 | import android.os.Build 32 | import android.os.Handler 33 | import android.os.Looper 34 | import android.os.Parcelable 35 | import timber.log.Timber 36 | import java.lang.ref.WeakReference 37 | import java.util.UUID 38 | import java.util.concurrent.ConcurrentHashMap 39 | import java.util.concurrent.ConcurrentLinkedQueue 40 | 41 | /** Maximum BLE MTU size as defined in gatt_api.h. */ 42 | private const val GATT_MAX_MTU_SIZE = 517 43 | private const val GATT_MIN_MTU_SIZE = 23 44 | 45 | @SuppressLint("MissingPermission") // Assume permissions are handled by UI 46 | object ConnectionManager { 47 | 48 | private var listeners: MutableSet> = mutableSetOf() 49 | private val listenersAsSet 50 | get() = listeners.toSet() 51 | 52 | private val deviceGattMap = ConcurrentHashMap() 53 | private val operationQueue = ConcurrentLinkedQueue() 54 | private var pendingOperation: BleOperationType? = null 55 | 56 | fun servicesOnDevice(device: BluetoothDevice): List? = 57 | deviceGattMap[device]?.services 58 | 59 | fun listenToBondStateChanges(context: Context) { 60 | context.applicationContext.registerReceiver( 61 | broadcastReceiver, 62 | IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) 63 | ) 64 | } 65 | 66 | fun registerListener(listener: ConnectionEventListener) { 67 | if (listeners.map { it.get() }.contains(listener)) { return } 68 | listeners.add(WeakReference(listener)) 69 | listeners = listeners.filter { it.get() != null }.toMutableSet() 70 | Timber.d("Added listener $listener, ${listeners.size} listeners total") 71 | } 72 | 73 | fun unregisterListener(listener: ConnectionEventListener) { 74 | // Removing elements while in a loop results in a java.util.ConcurrentModificationException 75 | var toRemove: WeakReference? = null 76 | listenersAsSet.forEach { 77 | if (it.get() == listener) { 78 | toRemove = it 79 | } 80 | } 81 | toRemove?.let { 82 | listeners.remove(it) 83 | Timber.d("Removed listener ${it.get()}, ${listeners.size} listeners total") 84 | } 85 | } 86 | 87 | fun connect(device: BluetoothDevice, context: Context) { 88 | if (device.isConnected()) { 89 | Timber.e("Already connected to ${device.address}!") 90 | } else { 91 | enqueueOperation(Connect(device, context.applicationContext)) 92 | } 93 | } 94 | 95 | fun teardownConnection(device: BluetoothDevice) { 96 | if (device.isConnected()) { 97 | enqueueOperation(Disconnect(device)) 98 | } else { 99 | Timber.e("Not connected to ${device.address}, cannot teardown connection!") 100 | } 101 | } 102 | 103 | fun readCharacteristic(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic) { 104 | if (device.isConnected() && characteristic.isReadable()) { 105 | enqueueOperation(CharacteristicRead(device, characteristic.uuid)) 106 | } else if (!characteristic.isReadable()) { 107 | Timber.e("Attempting to read ${characteristic.uuid} that isn't readable!") 108 | } else if (!device.isConnected()) { 109 | Timber.e("Not connected to ${device.address}, cannot perform characteristic read") 110 | } 111 | } 112 | 113 | fun writeCharacteristic( 114 | device: BluetoothDevice, 115 | characteristic: BluetoothGattCharacteristic, 116 | payload: ByteArray 117 | ) { 118 | val writeType = when { 119 | characteristic.isWritable() -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT 120 | characteristic.isWritableWithoutResponse() -> { 121 | BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE 122 | } 123 | else -> { 124 | Timber.e("Characteristic ${characteristic.uuid} cannot be written to") 125 | return 126 | } 127 | } 128 | if (device.isConnected()) { 129 | enqueueOperation(CharacteristicWrite(device, characteristic.uuid, writeType, payload)) 130 | } else { 131 | Timber.e("Not connected to ${device.address}, cannot perform characteristic write") 132 | } 133 | } 134 | 135 | fun readDescriptor(device: BluetoothDevice, descriptor: BluetoothGattDescriptor) { 136 | if (device.isConnected() && descriptor.isReadable()) { 137 | enqueueOperation(DescriptorRead(device, descriptor.uuid)) 138 | } else if (!descriptor.isReadable()) { 139 | Timber.e("Attempting to read ${descriptor.uuid} that isn't readable!") 140 | } else if (!device.isConnected()) { 141 | Timber.e("Not connected to ${device.address}, cannot perform descriptor read") 142 | } 143 | } 144 | 145 | fun writeDescriptor( 146 | device: BluetoothDevice, 147 | descriptor: BluetoothGattDescriptor, 148 | payload: ByteArray 149 | ) { 150 | if (device.isConnected() && (descriptor.isWritable() || descriptor.isCccd())) { 151 | enqueueOperation(DescriptorWrite(device, descriptor.uuid, payload)) 152 | } else if (!device.isConnected()) { 153 | Timber.e("Not connected to ${device.address}, cannot perform descriptor write") 154 | } else if (!descriptor.isWritable() && !descriptor.isCccd()) { 155 | Timber.e("Descriptor ${descriptor.uuid} cannot be written to") 156 | } 157 | } 158 | 159 | fun enableNotifications(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic) { 160 | if (device.isConnected() && 161 | (characteristic.isIndicatable() || characteristic.isNotifiable()) 162 | ) { 163 | enqueueOperation(EnableNotifications(device, characteristic.uuid)) 164 | } else if (!device.isConnected()) { 165 | Timber.e("Not connected to ${device.address}, cannot enable notifications") 166 | } else if (!characteristic.isIndicatable() && !characteristic.isNotifiable()) { 167 | Timber.e("Characteristic ${characteristic.uuid} doesn't support notifications/indications") 168 | } 169 | } 170 | 171 | fun disableNotifications(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic) { 172 | if (device.isConnected() && 173 | (characteristic.isIndicatable() || characteristic.isNotifiable()) 174 | ) { 175 | enqueueOperation(DisableNotifications(device, characteristic.uuid)) 176 | } else if (!device.isConnected()) { 177 | Timber.e("Not connected to ${device.address}, cannot disable notifications") 178 | } else if (!characteristic.isIndicatable() && !characteristic.isNotifiable()) { 179 | Timber.e("Characteristic ${characteristic.uuid} doesn't support notifications/indications") 180 | } 181 | } 182 | 183 | fun requestMtu(device: BluetoothDevice, mtu: Int) { 184 | if (device.isConnected()) { 185 | enqueueOperation(MtuRequest(device, mtu.coerceIn(GATT_MIN_MTU_SIZE, GATT_MAX_MTU_SIZE))) 186 | } else { 187 | Timber.e("Not connected to ${device.address}, cannot request MTU update!") 188 | } 189 | } 190 | 191 | // - Beginning of PRIVATE functions 192 | 193 | @Synchronized 194 | private fun enqueueOperation(operation: BleOperationType) { 195 | operationQueue.add(operation) 196 | if (pendingOperation == null) { 197 | doNextOperation() 198 | } 199 | } 200 | 201 | @Synchronized 202 | private fun signalEndOfOperation() { 203 | Timber.d("End of $pendingOperation") 204 | pendingOperation = null 205 | if (operationQueue.isNotEmpty()) { 206 | doNextOperation() 207 | } 208 | } 209 | 210 | /** 211 | * Perform a given [BleOperationType]. All permission checks are performed before an operation 212 | * can be enqueued by [enqueueOperation]. 213 | */ 214 | @Synchronized 215 | private fun doNextOperation() { 216 | if (pendingOperation != null) { 217 | Timber.e("doNextOperation() called when an operation is pending! Aborting.") 218 | return 219 | } 220 | 221 | val operation = operationQueue.poll() ?: run { 222 | Timber.v("Operation queue empty, returning") 223 | return 224 | } 225 | pendingOperation = operation 226 | 227 | // Handle Connect separately from other operations that require device to be connected 228 | if (operation is Connect) { 229 | with(operation) { 230 | Timber.w("Connecting to ${device.address}") 231 | device.connectGatt(context, false, callback) 232 | } 233 | return 234 | } 235 | 236 | // Check BluetoothGatt availability for other operations 237 | val gatt = deviceGattMap[operation.device] 238 | ?: this@ConnectionManager.run { 239 | Timber.e("Not connected to ${operation.device.address}! Aborting $operation operation.") 240 | signalEndOfOperation() 241 | return 242 | } 243 | 244 | when (operation) { 245 | is Disconnect -> with(operation) { 246 | Timber.w("Disconnecting from ${device.address}") 247 | gatt.close() 248 | deviceGattMap.remove(device) 249 | listenersAsSet.forEach { it.get()?.onDisconnect?.invoke(device) } 250 | signalEndOfOperation() 251 | } 252 | is CharacteristicWrite -> with(operation) { 253 | gatt.findCharacteristic(characteristicUuid)?.executeWrite( 254 | gatt, 255 | payload, 256 | writeType 257 | ) ?: this@ConnectionManager.run { 258 | Timber.e("Cannot find $characteristicUuid to write to") 259 | signalEndOfOperation() 260 | } 261 | } 262 | is CharacteristicRead -> with(operation) { 263 | gatt.findCharacteristic(characteristicUuid)?.let { characteristic -> 264 | gatt.readCharacteristic(characteristic) 265 | } ?: this@ConnectionManager.run { 266 | Timber.e("Cannot find $characteristicUuid to read from") 267 | signalEndOfOperation() 268 | } 269 | } 270 | is DescriptorWrite -> with(operation) { 271 | gatt.findDescriptor(descriptorUuid)?.executeWrite( 272 | gatt, 273 | payload 274 | ) ?: this@ConnectionManager.run { 275 | Timber.e("Cannot find $descriptorUuid to write to") 276 | signalEndOfOperation() 277 | } 278 | } 279 | is DescriptorRead -> with(operation) { 280 | gatt.findDescriptor(descriptorUuid)?.let { descriptor -> 281 | gatt.readDescriptor(descriptor) 282 | } ?: this@ConnectionManager.run { 283 | Timber.e("Cannot find $descriptorUuid to read from") 284 | signalEndOfOperation() 285 | } 286 | } 287 | is EnableNotifications -> with(operation) { 288 | gatt.findCharacteristic(characteristicUuid)?.let { characteristic -> 289 | val cccdUuid = UUID.fromString(CCC_DESCRIPTOR_UUID) 290 | val payload = when { 291 | characteristic.isIndicatable() -> 292 | BluetoothGattDescriptor.ENABLE_INDICATION_VALUE 293 | characteristic.isNotifiable() -> 294 | BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE 295 | else -> 296 | error("${characteristic.uuid} doesn't support notifications/indications") 297 | } 298 | 299 | characteristic.getDescriptor(cccdUuid)?.let { cccDescriptor -> 300 | if (!gatt.setCharacteristicNotification(characteristic, true)) { 301 | Timber.e("setCharacteristicNotification failed for ${characteristic.uuid}") 302 | signalEndOfOperation() 303 | return 304 | } 305 | cccDescriptor.executeWrite(gatt, payload) 306 | } ?: this@ConnectionManager.run { 307 | Timber.e("${characteristic.uuid} doesn't contain the CCC descriptor!") 308 | signalEndOfOperation() 309 | } 310 | } ?: this@ConnectionManager.run { 311 | Timber.e("Cannot find $characteristicUuid! Failed to enable notifications.") 312 | signalEndOfOperation() 313 | } 314 | } 315 | is DisableNotifications -> with(operation) { 316 | gatt.findCharacteristic(characteristicUuid)?.let { characteristic -> 317 | val cccdUuid = UUID.fromString(CCC_DESCRIPTOR_UUID) 318 | characteristic.getDescriptor(cccdUuid)?.let { cccDescriptor -> 319 | if (!gatt.setCharacteristicNotification(characteristic, false)) { 320 | Timber.e("setCharacteristicNotification failed for ${characteristic.uuid}") 321 | signalEndOfOperation() 322 | return 323 | } 324 | cccDescriptor.executeWrite( 325 | gatt, 326 | BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE 327 | ) 328 | } ?: this@ConnectionManager.run { 329 | Timber.e("${characteristic.uuid} doesn't contain the CCC descriptor!") 330 | signalEndOfOperation() 331 | } 332 | } ?: this@ConnectionManager.run { 333 | Timber.e("Cannot find $characteristicUuid! Failed to disable notifications.") 334 | signalEndOfOperation() 335 | } 336 | } 337 | is MtuRequest -> with(operation) { 338 | gatt.requestMtu(mtu) 339 | } 340 | else -> error("Unsupported operation: $operation") 341 | } 342 | } 343 | 344 | private val callback = object : BluetoothGattCallback() { 345 | override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { 346 | val deviceAddress = gatt.device.address 347 | 348 | if (status == BluetoothGatt.GATT_SUCCESS) { 349 | if (newState == BluetoothProfile.STATE_CONNECTED) { 350 | Timber.w("onConnectionStateChange: connected to $deviceAddress") 351 | deviceGattMap[gatt.device] = gatt 352 | Handler(Looper.getMainLooper()).post { 353 | gatt.discoverServices() 354 | } 355 | } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { 356 | Timber.e("onConnectionStateChange: disconnected from $deviceAddress") 357 | teardownConnection(gatt.device) 358 | } 359 | } else { 360 | Timber.e("onConnectionStateChange: status $status encountered for $deviceAddress!") 361 | if (pendingOperation is Connect) { 362 | signalEndOfOperation() 363 | } 364 | teardownConnection(gatt.device) 365 | } 366 | } 367 | 368 | override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { 369 | with(gatt) { 370 | if (status == BluetoothGatt.GATT_SUCCESS) { 371 | Timber.w("Discovered ${services.size} services for ${device.address}.") 372 | printGattTable() 373 | requestMtu(device, GATT_MAX_MTU_SIZE) 374 | listenersAsSet.forEach { it.get()?.onConnectionSetupComplete?.invoke(this) } 375 | } else { 376 | Timber.e("Service discovery failed due to status $status") 377 | teardownConnection(gatt.device) 378 | } 379 | } 380 | 381 | if (pendingOperation is Connect) { 382 | signalEndOfOperation() 383 | } 384 | } 385 | 386 | override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { 387 | Timber.w("ATT MTU changed to $mtu, success: ${status == BluetoothGatt.GATT_SUCCESS}") 388 | listenersAsSet.forEach { it.get()?.onMtuChanged?.invoke(gatt.device, mtu) } 389 | 390 | if (pendingOperation is MtuRequest) { 391 | signalEndOfOperation() 392 | } 393 | } 394 | 395 | @Deprecated("Deprecated for Android 13+") 396 | @Suppress("DEPRECATION") 397 | override fun onCharacteristicRead( 398 | gatt: BluetoothGatt, 399 | characteristic: BluetoothGattCharacteristic, 400 | status: Int 401 | ) { 402 | with(characteristic) { 403 | when (status) { 404 | BluetoothGatt.GATT_SUCCESS -> { 405 | Timber.i("Read characteristic $uuid | value: ${value.toHexString()}") 406 | listenersAsSet.forEach { 407 | it.get()?.onCharacteristicRead?.invoke( 408 | gatt.device, 409 | this, 410 | value 411 | ) 412 | } 413 | } 414 | BluetoothGatt.GATT_READ_NOT_PERMITTED -> { 415 | Timber.e("Read not permitted for $uuid!") 416 | } 417 | else -> { 418 | Timber.e("Characteristic read failed for $uuid, error: $status") 419 | } 420 | } 421 | } 422 | 423 | if (pendingOperation is CharacteristicRead) { 424 | signalEndOfOperation() 425 | } 426 | } 427 | 428 | override fun onCharacteristicRead( 429 | gatt: BluetoothGatt, 430 | characteristic: BluetoothGattCharacteristic, 431 | value: ByteArray, 432 | status: Int 433 | ) { 434 | val uuid = characteristic.uuid 435 | when (status) { 436 | BluetoothGatt.GATT_SUCCESS -> { 437 | Timber.i("Read characteristic $uuid | value: ${value.toHexString()}") 438 | listenersAsSet.forEach { 439 | it.get()?.onCharacteristicRead?.invoke(gatt.device, characteristic, value) 440 | } 441 | } 442 | BluetoothGatt.GATT_READ_NOT_PERMITTED -> { 443 | Timber.e("Read not permitted for $uuid!") 444 | } 445 | else -> { 446 | Timber.e("Characteristic read failed for $uuid, error: $status") 447 | } 448 | } 449 | 450 | if (pendingOperation is CharacteristicRead) { 451 | signalEndOfOperation() 452 | } 453 | } 454 | 455 | override fun onCharacteristicWrite( 456 | gatt: BluetoothGatt, 457 | characteristic: BluetoothGattCharacteristic, 458 | status: Int 459 | ) { 460 | val writtenValue = (pendingOperation as? CharacteristicWrite)?.payload 461 | with(characteristic) { 462 | when (status) { 463 | BluetoothGatt.GATT_SUCCESS -> { 464 | Timber.i("Wrote to characteristic $uuid | value: ${writtenValue?.toHexString()}") 465 | listenersAsSet.forEach { it.get()?.onCharacteristicWrite?.invoke(gatt.device, this) } 466 | } 467 | BluetoothGatt.GATT_WRITE_NOT_PERMITTED -> { 468 | Timber.e("Write not permitted for $uuid!") 469 | } 470 | else -> { 471 | Timber.e("Characteristic write failed for $uuid, error: $status") 472 | } 473 | } 474 | } 475 | 476 | if (pendingOperation is CharacteristicWrite) { 477 | signalEndOfOperation() 478 | } 479 | } 480 | 481 | @Deprecated("Deprecated for Android 13+") 482 | @Suppress("DEPRECATION") 483 | override fun onCharacteristicChanged( 484 | gatt: BluetoothGatt, 485 | characteristic: BluetoothGattCharacteristic 486 | ) { 487 | with(characteristic) { 488 | Timber.i("Characteristic $uuid changed | value: ${value.toHexString()}") 489 | listenersAsSet.forEach { 490 | it.get()?.onCharacteristicChanged?.invoke(gatt.device, this, value) 491 | } 492 | } 493 | } 494 | 495 | override fun onCharacteristicChanged( 496 | gatt: BluetoothGatt, 497 | characteristic: BluetoothGattCharacteristic, 498 | value: ByteArray 499 | ) { 500 | Timber.i("Characteristic ${characteristic.uuid} changed | value: ${value.toHexString()}") 501 | listenersAsSet.forEach { 502 | it.get()?.onCharacteristicChanged?.invoke(gatt.device, characteristic, value) 503 | } 504 | } 505 | 506 | @Deprecated("Deprecated for Android 13+") 507 | @Suppress("DEPRECATION") 508 | override fun onDescriptorRead( 509 | gatt: BluetoothGatt, 510 | descriptor: BluetoothGattDescriptor, 511 | status: Int 512 | ) { 513 | with(descriptor) { 514 | when (status) { 515 | BluetoothGatt.GATT_SUCCESS -> { 516 | Timber.i("Read descriptor $uuid | value: ${value.toHexString()}") 517 | listenersAsSet.forEach { 518 | it.get()?.onDescriptorRead?.invoke(gatt.device, this, value) 519 | } 520 | } 521 | BluetoothGatt.GATT_READ_NOT_PERMITTED -> { 522 | Timber.e("Read not permitted for $uuid!") 523 | } 524 | else -> { 525 | Timber.e("Descriptor read failed for $uuid, error: $status") 526 | } 527 | } 528 | } 529 | 530 | if (pendingOperation is DescriptorRead) { 531 | signalEndOfOperation() 532 | } 533 | } 534 | 535 | override fun onDescriptorRead( 536 | gatt: BluetoothGatt, 537 | descriptor: BluetoothGattDescriptor, 538 | status: Int, 539 | value: ByteArray 540 | ) { 541 | val uuid = descriptor.uuid 542 | when (status) { 543 | BluetoothGatt.GATT_SUCCESS -> { 544 | Timber.i("Read descriptor $uuid | value: ${value.toHexString()}") 545 | listenersAsSet.forEach { 546 | it.get()?.onDescriptorRead?.invoke(gatt.device, descriptor, value) 547 | } 548 | } 549 | BluetoothGatt.GATT_READ_NOT_PERMITTED -> { 550 | Timber.e("Read not permitted for $uuid!") 551 | } 552 | else -> { 553 | Timber.e("Descriptor read failed for $uuid, error: $status") 554 | } 555 | } 556 | 557 | if (pendingOperation is DescriptorRead) { 558 | signalEndOfOperation() 559 | } 560 | } 561 | 562 | override fun onDescriptorWrite( 563 | gatt: BluetoothGatt, 564 | descriptor: BluetoothGattDescriptor, 565 | status: Int 566 | ) { 567 | val operationType = pendingOperation 568 | with(descriptor) { 569 | when (status) { 570 | BluetoothGatt.GATT_SUCCESS -> { 571 | Timber.i("Wrote to descriptor $uuid | operation type: $operationType") 572 | 573 | if (isCccd() && 574 | (operationType is EnableNotifications || operationType is DisableNotifications) 575 | ) { 576 | onCccdWrite(gatt, characteristic, operationType) 577 | } else { 578 | listenersAsSet.forEach { 579 | it.get()?.onDescriptorWrite?.invoke(gatt.device, this) 580 | } 581 | } 582 | } 583 | BluetoothGatt.GATT_WRITE_NOT_PERMITTED -> { 584 | Timber.e("Write not permitted for $uuid!") 585 | } 586 | else -> { 587 | Timber.e("Descriptor write failed for $uuid, error: $status") 588 | } 589 | } 590 | } 591 | 592 | val isNotificationsOperation = descriptor.isCccd() && 593 | (operationType is EnableNotifications || operationType is DisableNotifications) 594 | val isManualWriteOperation = !descriptor.isCccd() && operationType is DescriptorWrite 595 | if (isNotificationsOperation || isManualWriteOperation) { 596 | signalEndOfOperation() 597 | } 598 | } 599 | 600 | private fun onCccdWrite( 601 | gatt: BluetoothGatt, 602 | characteristic: BluetoothGattCharacteristic, 603 | operationType: BleOperationType 604 | ) { 605 | val charUuid = characteristic.uuid 606 | 607 | when (operationType) { 608 | is EnableNotifications -> { 609 | Timber.w("Notifications or indications ENABLED on $charUuid") 610 | listenersAsSet.forEach { 611 | it.get()?.onNotificationsEnabled?.invoke( 612 | gatt.device, 613 | characteristic 614 | ) 615 | } 616 | } 617 | is DisableNotifications -> { 618 | Timber.w("Notifications or indications DISABLED on $charUuid") 619 | listenersAsSet.forEach { 620 | it.get()?.onNotificationsDisabled?.invoke( 621 | gatt.device, 622 | characteristic 623 | ) 624 | } 625 | } 626 | else -> { 627 | Timber.e("Unexpected operation type of $operationType on CCCD of $charUuid") 628 | } 629 | } 630 | } 631 | } 632 | 633 | private val broadcastReceiver = object : BroadcastReceiver() { 634 | override fun onReceive(context: Context, intent: Intent) { 635 | with(intent) { 636 | if (action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) { 637 | val device = parcelableExtraCompat(BluetoothDevice.EXTRA_DEVICE) 638 | val previousBondState = getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1) 639 | val bondState = getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) 640 | val bondTransition = "${previousBondState.toBondStateDescription()} to " + 641 | bondState.toBondStateDescription() 642 | Timber.w("${device?.address} bond state changed | $bondTransition") 643 | } 644 | } 645 | } 646 | 647 | private fun Int.toBondStateDescription() = when (this) { 648 | BluetoothDevice.BOND_BONDED -> "BONDED" 649 | BluetoothDevice.BOND_BONDING -> "BONDING" 650 | BluetoothDevice.BOND_NONE -> "NOT BONDED" 651 | else -> "ERROR: $this" 652 | } 653 | } 654 | 655 | private fun BluetoothDevice.isConnected() = deviceGattMap.containsKey(this) 656 | 657 | /** 658 | * A backwards compatible approach of obtaining a parcelable extra from an [Intent] object. 659 | * 660 | * NOTE: Despite the docs stating that [Intent.getParcelableExtra] is deprecated in Android 13, 661 | * Google has confirmed in https://issuetracker.google.com/issues/240585930#comment6 that the 662 | * replacement API is buggy for Android 13, and they suggested that developers continue to use the 663 | * deprecated API for Android 13. The issue will be fixed for Android 14 (U). 664 | */ 665 | internal inline fun Intent.parcelableExtraCompat(key: String): T? = when { 666 | Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU -> getParcelableExtra(key, T::class.java) 667 | else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T 668 | } 669 | } 670 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | 31 | 32 | 39 | 43 | 47 | 48 | 49 | 50 | 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 25 | 29 | 35 | 41 | 47 | 53 | 59 | 65 | 71 | 77 | 83 | 89 | 95 | 101 | 107 | 113 | 119 | 125 | 131 | 137 | 143 | 149 | 155 | 161 | 167 | 173 | 179 | 185 | 191 | 197 | 203 | 209 | 215 | 221 | 222 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_ble_operations.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 25 | 26 | 35 | 36 | 45 | 46 |