From 39448955504d9a1f53cb08a8551599cdb9d30caa Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 24 Sep 2026 03:34:23 -0400 Subject: [PATCH] chore(#695): vendor pristine flutter_blue_plus_android 8.2.2 Pristine import of pub.dev flutter_blue_plus_android-8.2.2 into packages/, wired via dependency_overrides, mirroring the WinRT vendor pattern from #686. FBP License v1.4 and BSD NOTICE retained. Verified against the pub cache file-by-file: 9 of 12 files byte-identical; AndroidManifest.xml, LICENSE and FlutterBluePlusPlugin.java differ ONLY in end-of-line whitespace (1, 4 and 11 lines respectively; the byte-perfect license text also ships as LICENSE.md, which is byte-identical). No content changes; verified with git diff --ignore-space-at-eol. Release APK builds clean against the vendored copy before any patch. Co-Authored-By: Claude Fable 5 --- .../flutter_blue_plus_android/CHANGELOG.md | 45 + packages/flutter_blue_plus_android/LICENSE | 77 + packages/flutter_blue_plus_android/LICENSE.md | 77 + packages/flutter_blue_plus_android/NOTICE.md | 39 + packages/flutter_blue_plus_android/README.md | 15 + .../analysis_options.yaml | 28 + .../android/build.gradle | 33 + .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 6 + .../FlutterBluePlusPlugin.java | 3085 +++++++++++++++++ .../lib/flutter_blue_plus_android.dart | 583 ++++ .../flutter_blue_plus_android/pubspec.yaml | 27 + pubspec.lock | 9 +- pubspec.yaml | 8 + 14 files changed, 4028 insertions(+), 5 deletions(-) create mode 100644 packages/flutter_blue_plus_android/CHANGELOG.md create mode 100644 packages/flutter_blue_plus_android/LICENSE create mode 100644 packages/flutter_blue_plus_android/LICENSE.md create mode 100644 packages/flutter_blue_plus_android/NOTICE.md create mode 100644 packages/flutter_blue_plus_android/README.md create mode 100644 packages/flutter_blue_plus_android/analysis_options.yaml create mode 100644 packages/flutter_blue_plus_android/android/build.gradle create mode 100644 packages/flutter_blue_plus_android/android/settings.gradle create mode 100644 packages/flutter_blue_plus_android/android/src/main/AndroidManifest.xml create mode 100644 packages/flutter_blue_plus_android/android/src/main/java/com/lib/flutter_blue_plus/FlutterBluePlusPlugin.java create mode 100644 packages/flutter_blue_plus_android/lib/flutter_blue_plus_android.dart create mode 100644 packages/flutter_blue_plus_android/pubspec.yaml diff --git a/packages/flutter_blue_plus_android/CHANGELOG.md b/packages/flutter_blue_plus_android/CHANGELOG.md new file mode 100644 index 0000000..e360bbb --- /dev/null +++ b/packages/flutter_blue_plus_android/CHANGELOG.md @@ -0,0 +1,45 @@ +## 8.2.2 +* **[Release]** version bump for the flutter_blue_plus 2.2.2 release + +## 8.2.1 +* **[LICENSE]** clarifications to the development exemption + +## 8.2.0 +* **[LICENSE]** new tiers based on company size & development exemption + +## 8.1.0 +* **[LICENSE]** commercial license is now required for 15 or more employees + +## 8.0.1 +* Add License symlink + +## 8.0.0 +* Switch to FBP License + +## 4.0.5 +* Fixes to check for location services when plugin is called from a service + +## 4.0.3 +* Added option to disable Location Services check + +## 4.0.2 +* Fix compile error + +## 4.0.1 +* Fix compile error + +## 4.0.0 +* Use bytes instead of hex for platform communication (#1130) +* Check if Android location services are enabled when doing scan (#1167) + +## 3.0.0 +* Add option to provide pairing PIN to `createBond` (#1119) + +## 2.0.1 +* Add log color + +## 2.0.0 +* Replace void return types with bool return types + +## 1.35.0 +* Split from `flutter_blue_plus` as a federated implementation diff --git a/packages/flutter_blue_plus_android/LICENSE b/packages/flutter_blue_plus_android/LICENSE new file mode 100644 index 0000000..856f7d1 --- /dev/null +++ b/packages/flutter_blue_plus_android/LICENSE @@ -0,0 +1,77 @@ +FlutterBluePlus License +Version 1.4 + +Copyright (c) 2026 Chip Weinberger +All rights reserved. + +--- + +### Preamble (Non-Binding) + +The intent of this license is to make FlutterBluePlus ("The Software") as free and open as possible for personal users, researchers, nonprofits, educational institutions, and hobbyists. For these users, the terms are similar to the BSD license: broad freedom to use, modify, and share. + +For for-profit use and by for-profit organizations, a commercial license is required. + +The revenue from commercial licenses supports the long-term maintenance and development of FlutterBluePlus. +This approach allows hobbyists, nonprofits, and educators to benefit from free and open software, while ensuring that for-profit companies who rely on it help sustain its future. + +Please be aware that this project originated under the BSD-3 license, and some portions of the codebase remain under that license today. Compliance with those clauses is not required under the FlutterBluePlus License itself, but to remain fully compliant with the terms set by those contributors, it is advised that users continue to follow the BSD-3 conditions described in NOTICE.md. That relationship is between you and the BSD-licensed contributors. + +This preamble is provided for explanation only and does not form part of the binding legal terms of this license. + +--- + +### Section 1: Common Conditions (apply to all users) + +Permission is hereby granted to any individual or organization to use, copy, modify, and distribute the Software in source code or binary form, subject to the following conditions: + +**1. Source Code Redistributions.** Redistributions of source code must retain the above copyright notice and this entire license text without modification. + +**2. Relicensing Prohibition.** The Software may not be relicensed under different terms by any party. + +**3. Commercial Use Requirement.** Use of the Software by any for-profit organization requires a commercial license under Section 3, regardless of how the Software was obtained. + +**4. Nonprofit & Educational Exemption.** Registered nonprofit organizations and accredited educational institutions are exempt from this requirement and may use the Software under Section 2. + +--- + +### Section 2: Open Use Terms (for nonprofits, educational institutions, personal use) + +Use of the Software is free of charge for any registered nonprofit organization, accredited educational institution, or personal user, subject to the Common Conditions and the following additional terms: + +**1. Endorsement Restriction.** Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this Software without specific prior written permission. + +--- + +### Section 3: Commercial Terms (for for-profit organizations) + +Any use of FlutterBluePlus by or for a for-profit company or corporation — including commercial use by individuals — requires the purchase of a commercial license, subject to the Common Conditions and the following additional terms: + +**1. License Fee.** Commercial licenses are offered in the following tiers, based on employee count **at the time of purchase**. The associated cost is listed on the official FlutterBluePlus payment portal. + +- **Starter:** 0–9 employees +- **Team:** 10–29 employees +- **Business:** 30–99 employees +- **Enterprise:** 100 or more employees + +**2. Perpetual Use.** The license grants perpetual rights to use the Software and to receive all current and future software updates made available by the licensor after purchase. + +**3. Development Clarification.** Use of the Software during development, testing, or evaluation by a for-profit organization is considered commercial use and requires a commercial license. + +**4. Official Payment Portal.** Payment must be made via the official payment portal: https://jamcorder.myshopify.com/products/flutterblueplus-commercial-license + +**5. Validity of Offer.** This Section constitutes an offer only while this license version is the current version published in the main branch of the official FlutterBluePlus repository. Once superseded, replaced, or withdrawn, this version shall no longer be considered an active offer, even if archived copies remain publicly visible. + + +A valid commercial license covers use of all current and future packages that +are part of the FlutterBluePlus project, including its federated packages +(e.g., flutter_blue_plus, flutter_blue_plus_darwin, flutter_blue_plus_android, +flutter_blue_plus_web, flutter_blue_plus_linux, flutter_blue_plus_platform_interface). +Only a single commercial license is required per organization, regardless of how many +FlutterBluePlus packages are used. + +--- + +### Section 4: Warranty Disclaimer + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_blue_plus_android/LICENSE.md b/packages/flutter_blue_plus_android/LICENSE.md new file mode 100644 index 0000000..08fd934 --- /dev/null +++ b/packages/flutter_blue_plus_android/LICENSE.md @@ -0,0 +1,77 @@ +FlutterBluePlus License +Version 1.4 + +Copyright (c) 2026 Chip Weinberger +All rights reserved. + +--- + +### Preamble (Non-Binding) + +The intent of this license is to make FlutterBluePlus ("The Software") as free and open as possible for personal users, researchers, nonprofits, educational institutions, and hobbyists. For these users, the terms are similar to the BSD license: broad freedom to use, modify, and share. + +For for-profit use and by for-profit organizations, a commercial license is required. + +The revenue from commercial licenses supports the long-term maintenance and development of FlutterBluePlus. +This approach allows hobbyists, nonprofits, and educators to benefit from free and open software, while ensuring that for-profit companies who rely on it help sustain its future. + +Please be aware that this project originated under the BSD-3 license, and some portions of the codebase remain under that license today. Compliance with those clauses is not required under the FlutterBluePlus License itself, but to remain fully compliant with the terms set by those contributors, it is advised that users continue to follow the BSD-3 conditions described in NOTICE.md. That relationship is between you and the BSD-licensed contributors. + +This preamble is provided for explanation only and does not form part of the binding legal terms of this license. + +--- + +### Section 1: Common Conditions (apply to all users) + +Permission is hereby granted to any individual or organization to use, copy, modify, and distribute the Software in source code or binary form, subject to the following conditions: + +**1. Source Code Redistributions.** Redistributions of source code must retain the above copyright notice and this entire license text without modification. + +**2. Relicensing Prohibition.** The Software may not be relicensed under different terms by any party. + +**3. Commercial Use Requirement.** Use of the Software by any for-profit organization requires a commercial license under Section 3, regardless of how the Software was obtained. + +**4. Nonprofit & Educational Exemption.** Registered nonprofit organizations and accredited educational institutions are exempt from this requirement and may use the Software under Section 2. + +--- + +### Section 2: Open Use Terms (for nonprofits, educational institutions, personal use) + +Use of the Software is free of charge for any registered nonprofit organization, accredited educational institution, or personal user, subject to the Common Conditions and the following additional terms: + +**1. Endorsement Restriction.** Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this Software without specific prior written permission. + +--- + +### Section 3: Commercial Terms (for for-profit organizations) + +Any use of FlutterBluePlus by or for a for-profit company or corporation — including commercial use by individuals — requires the purchase of a commercial license, subject to the Common Conditions and the following additional terms: + +**1. License Fee.** Commercial licenses are offered in the following tiers, based on employee count **at the time of purchase**. The associated cost is listed on the official FlutterBluePlus payment portal. + +- **Starter:** 0–9 employees +- **Team:** 10–29 employees +- **Business:** 30–99 employees +- **Enterprise:** 100 or more employees + +**2. Perpetual Use.** The license grants perpetual rights to use the Software and to receive all current and future software updates made available by the licensor after purchase. + +**3. Development Clarification.** Use of the Software during development, testing, or evaluation by a for-profit organization is considered commercial use and requires a commercial license. + +**4. Official Payment Portal.** Payment must be made via the official payment portal: https://jamcorder.myshopify.com/products/flutterblueplus-commercial-license + +**5. Validity of Offer.** This Section constitutes an offer only while this license version is the current version published in the main branch of the official FlutterBluePlus repository. Once superseded, replaced, or withdrawn, this version shall no longer be considered an active offer, even if archived copies remain publicly visible. + + +A valid commercial license covers use of all current and future packages that +are part of the FlutterBluePlus project, including its federated packages +(e.g., flutter_blue_plus, flutter_blue_plus_darwin, flutter_blue_plus_android, +flutter_blue_plus_web, flutter_blue_plus_linux, flutter_blue_plus_platform_interface). +Only a single commercial license is required per organization, regardless of how many +FlutterBluePlus packages are used. + +--- + +### Section 4: Warranty Disclaimer + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_blue_plus_android/NOTICE.md b/packages/flutter_blue_plus_android/NOTICE.md new file mode 100644 index 0000000..e9869a1 --- /dev/null +++ b/packages/flutter_blue_plus_android/NOTICE.md @@ -0,0 +1,39 @@ +To comply with the requirements of the original BSD-style license, +the full text of that license is reproduced below. It applies to all +releases of this project up to and including version 1.36.8, published +before October 2025. Those releases remain available under the terms +below, which are irrevocable. + +All releases beginning with version 2.0.0, published in October 2025 +and onward, are licensed under the FlutterBluePlus License. + +------ + +Copyright 2017-2024, Paul DeMarco, Bosko Popovic, Charles Weinberger, Thomas Clark. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Buffalo PC Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_blue_plus_android/README.md b/packages/flutter_blue_plus_android/README.md new file mode 100644 index 0000000..dc692c1 --- /dev/null +++ b/packages/flutter_blue_plus_android/README.md @@ -0,0 +1,15 @@ +# flutter_blue_plus_android + +The Android implementation of [`flutter_blue_plus`][1]. + +## Usage + +This package is [endorsed][2], which means you can simply use `flutter_blue_plus` +normally. This package will be automatically included in your app when you do, +so you do not need to add it to your `pubspec.yaml`. + +However, if you `import` this package to use any of its APIs directly, you +should add it to your `pubspec.yaml` as usual. + +[1]: https://pub.dev/packages/flutter_blue_plus +[2]: https://flutter.dev/to/endorsed-federated-plugin diff --git a/packages/flutter_blue_plus_android/analysis_options.yaml b/packages/flutter_blue_plus_android/analysis_options.yaml new file mode 100644 index 0000000..1e432d5 --- /dev/null +++ b/packages/flutter_blue_plus_android/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/tools/linter-rules. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/tools/analysis diff --git a/packages/flutter_blue_plus_android/android/build.gradle b/packages/flutter_blue_plus_android/android/build.gradle new file mode 100644 index 0000000..28f805b --- /dev/null +++ b/packages/flutter_blue_plus_android/android/build.gradle @@ -0,0 +1,33 @@ +group 'com.lib.flutter_blue_plus' +version '1.0' + +apply plugin: 'com.android.library' + +android { + compileSdkVersion = flutter.compileSdkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + lintOptions { + abortOnError false + } + + defaultConfig { + minSdkVersion 21 + } + + gradle.projectsEvaluated { + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:deprecation" + options.compilerArgs << "-Xlint:unchecked" + } + } + + // Conditional for compatibility with AGP <4.2. + if (project.android.hasProperty("namespace")) { + namespace 'com.lib.flutter_blue_plus' + } +} diff --git a/packages/flutter_blue_plus_android/android/settings.gradle b/packages/flutter_blue_plus_android/android/settings.gradle new file mode 100644 index 0000000..943eabd --- /dev/null +++ b/packages/flutter_blue_plus_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_blue_plus' diff --git a/packages/flutter_blue_plus_android/android/src/main/AndroidManifest.xml b/packages/flutter_blue_plus_android/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c82f58b --- /dev/null +++ b/packages/flutter_blue_plus_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/packages/flutter_blue_plus_android/android/src/main/java/com/lib/flutter_blue_plus/FlutterBluePlusPlugin.java b/packages/flutter_blue_plus_android/android/src/main/java/com/lib/flutter_blue_plus/FlutterBluePlusPlugin.java new file mode 100644 index 0000000..8f4b095 --- /dev/null +++ b/packages/flutter_blue_plus_android/android/src/main/java/com/lib/flutter_blue_plus/FlutterBluePlusPlugin.java @@ -0,0 +1,3085 @@ +// Copyright 2017-2023, Charles Weinberger & Paul DeMarco. +// All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +package com.lib.flutter_blue_plus; + +import android.Manifest; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.Application; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothStatusCodes; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanRecord; +import android.bluetooth.le.ScanSettings; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.location.LocationManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelUuid; +import android.provider.Settings; +import android.util.Log; +import android.util.SparseArray; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.io.StringWriter; +import java.io.PrintWriter; +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +import java.lang.reflect.Method; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.EventChannel.EventSink; +import io.flutter.plugin.common.EventChannel.StreamHandler; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.plugin.common.PluginRegistry.RequestPermissionsResultListener; +import io.flutter.plugin.common.PluginRegistry.ActivityResultListener; + +public class FlutterBluePlusPlugin implements + FlutterPlugin, + MethodCallHandler, + RequestPermissionsResultListener, + ActivityResultListener, + ActivityAware +{ + private static final String TAG = "[FBP-Android]"; + + private LogLevel logLevel = LogLevel.DEBUG; + + private Context context; + private MethodChannel methodChannel; + private static final String NAMESPACE = "flutter_blue_plus"; + + private BluetoothManager mBluetoothManager; + private BluetoothAdapter mBluetoothAdapter; + private boolean mIsScanning = false; + + private FlutterPluginBinding pluginBinding; + private ActivityPluginBinding activityBinding; + + static final private String CCCD = "2902"; + + private final Semaphore mMethodCallMutex = new Semaphore(1); + private final Map mConnectedDevices = new ConcurrentHashMap<>(); + private final Map mCurrentlyConnectingDevices = new ConcurrentHashMap<>(); + private final Map mBondingDevices = new ConcurrentHashMap<>(); + private final Map mBondingPins = new ConcurrentHashMap<>(); + private final Map mMtu = new ConcurrentHashMap<>(); + private final Map mAutoConnected = new ConcurrentHashMap<>(); + private final Map mWriteChr = new ConcurrentHashMap<>(); + private final Map mWriteDesc = new ConcurrentHashMap<>(); + private final Map mAdvSeen = new ConcurrentHashMap<>(); + private final Map mScanCounts = new ConcurrentHashMap<>(); + private HashMap mScanFilters = new HashMap(); + + private final Map operationsOnPermission = new HashMap<>(); + private int lastEventId = 1452; + + private final int enableBluetoothRequestCode = 13106; + + private interface OperationOnPermission { + void op(boolean granted, String permission); + } + + public FlutterBluePlusPlugin() {} + + // returns 128-bit representation + public String uuid128(Object uuid) + { + if (!(uuid instanceof UUID) && !(uuid instanceof String)) { + throw new IllegalArgumentException("input must be UUID or String"); + } + + String s = uuid.toString(); + + if (s.length() == 4) + { + // 16-bit uuid + return String.format("0000%s-0000-1000-8000-00805f9b34fb", s).toLowerCase(); + } + else if (s.length() == 8) + { + // 32-bit uuid + return String.format("%s-0000-1000-8000-00805f9b34fb", s).toLowerCase(); + } + else + { + // 128-bit uuid + return s.toLowerCase(); + } + } + + // returns shortest representation + public String uuidStr(Object uuid) + { + String s = uuid128(uuid); + boolean starts = s.startsWith("0000"); + boolean ends = s.endsWith("-0000-1000-8000-00805f9b34fb"); + if (starts && ends) + { + // 16-bit + return s.substring(4,8); + } + else if (ends) + { + // 32-bit + return s.substring(0,8); + } + else + { + // 128-bit + return s; + } + } + + private void acquireMutex(@NonNull Semaphore mutex) + { + boolean mutexAcquired = false; + while (mutexAcquired == false) { + try { + mutex.acquire(); + mutexAcquired = true; + } catch (InterruptedException e) { + log(LogLevel.ERROR, "failed to acquire mutex, retrying"); + } + } + } + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) + { + log(LogLevel.DEBUG, "onAttachedToEngine"); + + pluginBinding = flutterPluginBinding; + + this.context = (Application) pluginBinding.getApplicationContext(); + + methodChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), NAMESPACE + "/methods"); + methodChannel.setMethodCallHandler(this); + + IntentFilter filterAdapter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED); + this.context.registerReceiver(mBluetoothAdapterStateReceiver, filterAdapter); + + IntentFilter filterPair = new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST); + this.context.registerReceiver(mBluetoothPairRequestReceiver, filterPair); + + IntentFilter filterBond = new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED); + this.context.registerReceiver(mBluetoothBondStateReceiver, filterBond); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) + { + log(LogLevel.DEBUG, "onDetachedFromEngine"); + + invokeMethodUIThread("OnDetachedFromEngine", new HashMap<>()); + + pluginBinding = null; + + // stop scanning + if (mBluetoothAdapter != null && mIsScanning) { + BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner(); + if (scanner != null) { + log(LogLevel.DEBUG, "calling stopScan (OnDetachedFromEngine)"); + scanner.stopScan(getScanCallback()); + mIsScanning = false; + } + } + + disconnectAllDevices("onDetachedFromEngine"); + + context.unregisterReceiver(mBluetoothBondStateReceiver); + context.unregisterReceiver(mBluetoothPairRequestReceiver); + context.unregisterReceiver(mBluetoothAdapterStateReceiver); + context = null; + + methodChannel.setMethodCallHandler(null); + methodChannel = null; + + mBluetoothAdapter = null; + mBluetoothManager = null; + } + + @Override + public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) + { + log(LogLevel.DEBUG, "onAttachedToActivity"); + activityBinding = binding; + activityBinding.addRequestPermissionsResultListener(this); + activityBinding.addActivityResultListener(this); + } + + @Override + public void onDetachedFromActivityForConfigChanges() + { + log(LogLevel.DEBUG, "onDetachedFromActivityForConfigChanges"); + onDetachedFromActivity(); + } + + @Override + public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) + { + log(LogLevel.DEBUG, "onReattachedToActivityForConfigChanges"); + onAttachedToActivity(binding); + } + + @Override + public void onDetachedFromActivity() + { + log(LogLevel.DEBUG, "onDetachedFromActivity"); + activityBinding.removeRequestPermissionsResultListener(this); + activityBinding = null; + } + + //////////////////////////////////////////////////////////// + // ███ ███ ███████ ████████ ██ ██ ██████ ██████ + // ████ ████ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ████ ██ █████ ██ ███████ ██ ██ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ███████ ██ ██ ██ ██████ ██████ + // + // ██████ █████ ██ ██ + // ██ ██ ██ ██ ██ + // ██ ███████ ██ ██ + // ██ ██ ██ ██ ██ + // ██████ ██ ██ ███████ ███████ + + @Override + @SuppressWarnings({"deprecation", "unchecked"}) // needed for compatibility, type safety uses bluetooth_msgs.dart + public void onMethodCall(@NonNull MethodCall call, + @NonNull Result result) + { + try { + acquireMutex(mMethodCallMutex); + + log(LogLevel.DEBUG, "onMethodCall: " + call.method); + + // initialize adapter + if (mBluetoothAdapter == null) { + log(LogLevel.DEBUG, "initializing BluetoothAdapter"); + mBluetoothManager = (BluetoothManager) this.context.getSystemService(Context.BLUETOOTH_SERVICE); + mBluetoothAdapter = mBluetoothManager != null ? mBluetoothManager.getAdapter() : null; + } + + // check that we have an adapter, except for + // the functions that do not need it + if(mBluetoothAdapter == null && + "flutterRestart".equals(call.method) == false && + "connectedCount".equals(call.method) == false && + "setLogLevel".equals(call.method) == false && + "isSupported".equals(call.method) == false && + "getAdapterName".equals(call.method) == false && + "getAdapterState".equals(call.method) == false) { + result.error("bluetoothUnavailable", "the device does not support bluetooth", null); + return; + } + + switch (call.method) { + + case "flutterRestart": + { + // no adapter? + if (mBluetoothAdapter == null) { + result.success(0); // no work to do + break; + } + + // stop scanning + BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner(); + if(scanner != null && mIsScanning) { + log(LogLevel.DEBUG, "calling stopScan (flutterRestart)"); + scanner.stopScan(getScanCallback()); + mIsScanning = false; + } + + // all dart state is reset after flutter restart + // (i.e. Hot Restart) so also reset native state + disconnectAllDevices("flutterRestart"); + + log(LogLevel.DEBUG, "connectedPeripherals: " + mConnectedDevices.size()); + + result.success(mConnectedDevices.size()); + break; + } + + case "setOptions": + { + // Currently ignored on Android + result.success(true); + break; + } + + case "connectedCount": + { + log(LogLevel.DEBUG, "connectedPeripherals: " + mConnectedDevices.size()); + if (mConnectedDevices.size() == 0) { + log(LogLevel.DEBUG, "Hot Restart: complete"); + } + result.success(mConnectedDevices.size()); + break; + } + + case "setLogLevel": + { + int idx = (int)call.arguments; + + // set global var + logLevel = LogLevel.values()[idx]; + + result.success(true); + break; + } + + case "isSupported": + { + result.success(mBluetoothAdapter != null); + break; + } + + case "getAdapterName": + { + ArrayList permissions = new ArrayList<>(); + + if (Build.VERSION.SDK_INT >= 31) { // Android 12 (October 2021) + permissions.add(Manifest.permission.BLUETOOTH_CONNECT); + } + + if (Build.VERSION.SDK_INT <= 30) { // Android 11 (September 2020) + permissions.add(Manifest.permission.BLUETOOTH); + } + + ensurePermissions(permissions, (granted, perm) -> { + + String adapterName = mBluetoothAdapter != null ? mBluetoothAdapter.getName() : "N/A"; + result.success(adapterName != null ? adapterName : ""); + + }); + break; + } + + case "getAdapterState": + { + // get adapterState, if we have permission + int adapterState = -1; // unknown + try { + adapterState = mBluetoothAdapter.getState(); + } catch (Exception e) {} + + // see: BmBluetoothAdapterState + HashMap map = new HashMap<>(); + map.put("adapter_state", bmAdapterStateEnum(adapterState)); + + result.success(map); + break; + } + + case "turnOn": + { + ArrayList permissions = new ArrayList<>(); + + if (Build.VERSION.SDK_INT >= 31) { // Android 12 (October 2021) + permissions.add(Manifest.permission.BLUETOOTH_CONNECT); + } + + if (Build.VERSION.SDK_INT <= 30) { // Android 11 (September 2020) + permissions.add(Manifest.permission.BLUETOOTH); + } + + ensurePermissions(permissions, (granted, perm) -> { + + if (granted == false) { + result.error("turnOn", + String.format("Permission %s required to turn Bluetooth on", perm), null); + return; + } + + if (mBluetoothAdapter.isEnabled()) { + result.success(false); // no work to do + return; + } + + Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); + + activityBinding.getActivity().startActivityForResult(enableBtIntent, enableBluetoothRequestCode); + + result.success(true); + return; + }); + break; + } + + case "turnOff": + { + ArrayList permissions = new ArrayList<>(); + + if (Build.VERSION.SDK_INT >= 31) { // Android 12 (October 2021) + permissions.add(Manifest.permission.BLUETOOTH_CONNECT); + } + + if (Build.VERSION.SDK_INT <= 30) { // Android 11 (September 2020) + permissions.add(Manifest.permission.BLUETOOTH); + } + + ensurePermissions(permissions, (granted, perm) -> { + + if (granted == false) { + result.error("turnOff", + String.format("Permission %s required to turn Bluetooth off", perm), null); + return; + } + + if (mBluetoothAdapter.isEnabled() == false) { + result.success(true); // no work to do + return; + } + + // this is deprecated in API level 33. + boolean disabled = mBluetoothAdapter.disable(); + + result.success(disabled); + return; + }); + break; + } + + case "startScan": + { + // see: BmScanSettings + HashMap data = call.arguments(); + List withServices = (List) data.get("with_services"); + List withRemoteIds = (List) data.get("with_remote_ids"); + List withNames = (List) data.get("with_names"); + List withKeywords = (List) data.get("with_keywords"); + List withMsd = (List) data.get("with_msd"); + List withServiceData = (List) data.get("with_service_data"); + boolean continuousUpdates = (boolean) data.get("continuous_updates"); + boolean androidLegacy = (boolean) data.get("android_legacy"); + int androidScanMode = (int) data.get("android_scan_mode"); + boolean androidUsesFineLocation = (boolean) data.get("android_uses_fine_location"); + boolean androidCheckLocationServices = (boolean) data.get("android_check_location_services"); + + if (androidCheckLocationServices && !isLocationEnabled()) { + result.error("startScan", "Location services are required for Bluetooth scan", null); + return; + } + + ArrayList permissions = new ArrayList<>(); + + if (Build.VERSION.SDK_INT >= 31) { // Android 12 (October 2021) + permissions.add(Manifest.permission.BLUETOOTH_SCAN); + if (androidUsesFineLocation) { + permissions.add(Manifest.permission.ACCESS_FINE_LOCATION); + } + // it is unclear why this is needed, but some phones throw a + // SecurityException AdapterService getRemoteName, without it + permissions.add(Manifest.permission.BLUETOOTH_CONNECT); + } + + if (Build.VERSION.SDK_INT <= 30) { // Android 11 (September 2020) + permissions.add(Manifest.permission.ACCESS_FINE_LOCATION); + } + + ensurePermissions(permissions, (granted, perm) -> { + + if (granted == false) { + result.error("startScan", + String.format("Permission %s required to scan devices", perm), null); + return; + } + + // check adapter + if (isAdapterOn() == false) { + result.error("startScan", "Bluetooth must be turned on", null); + return; + } + + // get scanner + BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner(); + if (scanner == null) { + result.error("startScan", "getBluetoothLeScanner() is null. Is the Adapter on?", null); + return; + } + + // build scan settings + ScanSettings.Builder builder = new ScanSettings.Builder(); + builder.setScanMode(androidScanMode); + if (Build.VERSION.SDK_INT >= 26) { // Android 8.0 (August 2017) + builder.setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED); + builder.setLegacy(androidLegacy); + } + ScanSettings settings = builder.build(); + + // set filters + List filters = new ArrayList<>(); + + // services + for (int i = 0; i < withServices.size(); i++) { + ParcelUuid s = ParcelUuid.fromString(uuid128(withServices.get(i))); + ScanFilter f = new ScanFilter.Builder().setServiceUuid(s).build(); + filters.add(f); + } + + // remoteIds + for (int i = 0; i < withRemoteIds.size(); i++) { + String address = withRemoteIds.get(i); + ScanFilter f = new ScanFilter.Builder().setDeviceAddress(address).build(); + filters.add(f); + } + + // names + for (int i = 0; i < withNames.size(); i++) { + String name = withNames.get(i); + ScanFilter f = new ScanFilter.Builder().setDeviceName(name).build(); + filters.add(f); + } + + // keywords + if (Build.VERSION.SDK_INT >= 33) { // Android 13 (August 2022) + if (withKeywords.size() > 0) { + // device must advertise a name + int a1 = ScanRecord.DATA_TYPE_LOCAL_NAME_SHORT; + int a2 = ScanRecord.DATA_TYPE_LOCAL_NAME_COMPLETE; + ScanFilter f1 = new ScanFilter.Builder().setAdvertisingDataType(a1).build(); + ScanFilter f2 = new ScanFilter.Builder().setAdvertisingDataType(a2).build(); + filters.add(f1); + filters.add(f2); + } + } + + // msd + for (int i = 0; i < withMsd.size(); i++) { + HashMap m = (HashMap) withMsd.get(i); + int id = (int) m.get("manufacturer_id"); + byte[] mdata = (byte[]) m.get("data"); + byte[] mask = (byte[]) m.get("mask"); + ScanFilter f = null; + if (mask.length == 0) { + f = new ScanFilter.Builder().setManufacturerData(id, mdata).build(); + } else { + f = new ScanFilter.Builder().setManufacturerData(id, mdata, mask).build(); + } + filters.add(f); + } + + // service data + for (int i = 0; i < withServiceData.size(); i++) { + HashMap m = (HashMap) withServiceData.get(i); + ParcelUuid s = ParcelUuid.fromString(uuid128((String) m.get("service"))); + byte[] mdata = (byte[]) m.get("data"); + byte[] mask = (byte[]) m.get("mask"); + ScanFilter f = null; + if (mask.length == 0) { + f = new ScanFilter.Builder().setServiceData(s, mdata).build(); + } else { + f = new ScanFilter.Builder().setServiceData(s, mdata, mask).build(); + } + filters.add(f); + } + + // remember for later + mScanFilters = data; + + // clear seen devices + mAdvSeen.clear(); + mScanCounts.clear(); + + scanner.startScan(filters, settings, getScanCallback()); + + mIsScanning = true; + + result.success(true); + }); + break; + } + + case "stopScan": + { + BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner(); + + if (scanner != null) { + scanner.stopScan(getScanCallback()); + mIsScanning = false; + } + + result.success(true); + break; + } + + case "getSystemDevices": + { + ArrayList permissions = new ArrayList<>(); + + if (Build.VERSION.SDK_INT >= 31) { // Android 12 (October 2021) + permissions.add(Manifest.permission.BLUETOOTH_CONNECT); + } + + ensurePermissions(permissions, (granted, perm) -> { + + if (granted == false) { + result.error("getSystemDevices", + String.format("Permission %s required to get system devices", perm), null); + return; + } + + // this includes devices connected by other apps + List devices = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + List> devList = new ArrayList>(); + for (BluetoothDevice d : devices) { + devList.add(bmBluetoothDevice(d)); + } + + // See: BmDevicesList + HashMap response = new HashMap<>(); + response.put("devices", devList); + + result.success(response); + }); + break; + } + + case "connect": + { + // see: BmConnectRequest + HashMap args = call.arguments(); + String remoteId = (String) args.get("remote_id"); + boolean autoConnect = ((int) args.get("auto_connect")) != 0; + + ArrayList permissions = new ArrayList<>(); + + if (Build.VERSION.SDK_INT >= 31) { // Android 12 (October 2021) + permissions.add(Manifest.permission.BLUETOOTH_CONNECT); + } + + ensurePermissions(permissions, (granted, perm) -> { + + if (granted == false) { + result.error("connect", + String.format("Permission %s required for new connection", perm), null); + return; + } + + // check adapter + if (isAdapterOn() == false) { + result.error("connect", "Bluetooth must be turned on", null); + return; + } + + // already connecting? + if (mCurrentlyConnectingDevices.get(remoteId) != null) { + log(LogLevel.DEBUG, "already connecting"); + result.success(true); // still work to do + return; + } + + // already connected? + if (mConnectedDevices.get(remoteId) != null) { + log(LogLevel.DEBUG, "already connected"); + result.success(false); // no work to do + return; + } + + // connect + BluetoothGatt gatt = null; + BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(remoteId); + if (Build.VERSION.SDK_INT >= 23) { // Android 6.0 (October 2015) + gatt = device.connectGatt(context, autoConnect, mGattCallback, BluetoothDevice.TRANSPORT_LE); + } else { + try { + // From Android LOLLIPOP (21) the transport types exists, but it is private + // have to use reflection to call it for TRANSPORT_LE + Method connectGattMethod = device.getClass() + .getDeclaredMethod("connectGatt", Context.class, boolean.class, BluetoothGattCallback.class, int.class); + connectGattMethod.setAccessible(true); + gatt = (BluetoothGatt) connectGattMethod.invoke(device, context, autoConnect, mGattCallback, 2 /* TRANSPORT_LE */); + } catch (Exception ex) { + // fall back to default method if reflection fails + gatt = device.connectGatt(context, autoConnect, mGattCallback); + } + } + + // error check + if (gatt == null) { + result.error("connect", "device.connectGatt returned null", null); + return; + } + + // add to currently connecting peripherals + mCurrentlyConnectingDevices.put(remoteId, gatt); + + // remember autoconnect + if (autoConnect) { + mAutoConnected.put(remoteId, gatt); + } else { + mAutoConnected.remove(remoteId); + } + + result.success(true); + }); + break; + } + + case "disconnect": + { + String remoteId = (String) call.arguments; + + // already disconnected? + BluetoothGatt gatt = null; + if (gatt == null) { + gatt = mCurrentlyConnectingDevices.get(remoteId); + if (gatt != null) { + log(LogLevel.DEBUG, "disconnect: cancelling connection in progress"); + } + } + if (gatt == null) { + gatt = mConnectedDevices.get(remoteId); + } + if (gatt == null) { + gatt = mAutoConnected.get(remoteId); + if (gatt != null) { + log(LogLevel.DEBUG, "already disconnected. disabling autoconnect"); + mAutoConnected.remove(remoteId); + gatt.disconnect(); + gatt.close(); + result.success(false); // no work to do + return; + } + } + if (gatt == null) { + log(LogLevel.DEBUG, "already disconnected"); + result.success(false); // no work to do + return; + } + + // calling disconnect explicitly turns off autoconnect. + // this allows gatt resources to be reclaimed + mAutoConnected.remove(remoteId); + + // disconnect + gatt.disconnect(); + + // was connecting? + if (mCurrentlyConnectingDevices.get(remoteId) != null) { + + // remove + mCurrentlyConnectingDevices.remove(remoteId); + + // cleanup + gatt.close(); + + // random number defined by flutter blue plus. + int bmUserCanceledErrorCode = 23789258; + + // see: BmConnectionStateResponse + HashMap response = new HashMap<>(); + response.put("remote_id", remoteId); + response.put("connection_state", bmConnectionStateEnum(BluetoothProfile.STATE_DISCONNECTED)); + response.put("disconnect_reason_code", bmUserCanceledErrorCode); + response.put("disconnect_reason_string", "connection canceled"); + + invokeMethodUIThread("OnConnectionStateChanged", response); + } + + result.success(true); + break; + } + + case "discoverServices": + { + String remoteId = (String) call.arguments; + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("discoverServices", "device is disconnected", null); + break; + } + + // discover services + if(gatt.discoverServices() == false) { + result.error("discoverServices", "gatt.discoverServices() returned false", null); + break; + } + + result.success(true); + break; + } + + case "readCharacteristic": + { + // see: BmReadCharacteristicRequest + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + String primaryServiceUuid = (String) data.get("primary_service_uuid"); + String serviceUuid = (String) data.get("service_uuid"); + String characteristicUuid = (String) data.get("characteristic_uuid"); + Integer instanceId = (Integer) data.get("instance_id"); + + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("readCharacteristic", "device is disconnected", null); + break; + } + + // find characteristic + ChrFound found = locateCharacteristic(gatt, primaryServiceUuid, serviceUuid, characteristicUuid, instanceId); + if (found.error != null) { + result.error("readCharacteristic", found.error, null); + break; + } + + BluetoothGattCharacteristic characteristic = found.characteristic; + + // check readable + if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) == 0) { + result.error("readCharacteristic", + "The READ property is not supported by this BLE characteristic", null); + break; + } + + // read + if(gatt.readCharacteristic(characteristic) == false) { + result.error("readCharacteristic", + "gatt.readCharacteristic() returned false", null); + break; + } + + result.success(true); + break; + } + + case "writeCharacteristic": + { + // see: BmWriteCharacteristicRequest + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + String primaryServiceUuid = (String) data.get("primary_service_uuid"); + String serviceUuid = (String) data.get("service_uuid"); + String characteristicUuid = (String) data.get("characteristic_uuid"); + Integer instanceId = (Integer) data.get("instance_id"); + byte[] value = (byte[]) data.get("value"); + int writeTypeInt = (int) data.get("write_type"); + boolean allowLongWrite = ((int) data.get("allow_long_write")) != 0; + + int writeType = writeTypeInt == 0 ? + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT : + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("writeCharacteristic", "device is disconnected", null); + break; + } + + // find characteristic + ChrFound found = locateCharacteristic(gatt, primaryServiceUuid, serviceUuid, characteristicUuid, instanceId); + if (found.error != null) { + result.error("writeCharacteristic", found.error, null); + break; + } + + BluetoothGattCharacteristic characteristic = found.characteristic; + + // check writeable + if(writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) { + if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) == 0) { + result.error("writeCharacteristic", + "The WRITE_NO_RESPONSE property is not supported by this BLE characteristic", null); + break; + } + } else { + if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE) == 0) { + result.error("writeCharacteristic", + "The WRITE property is not supported by this BLE characteristic", null); + break; + } + } + + // check maximum payload + int maxLen = getMaxPayload(remoteId, writeType, allowLongWrite); + int dataLen = value.length; + if (dataLen > maxLen) { + String a = writeTypeInt == 0 ? "withResponse" : "withoutResponse"; + String b = writeTypeInt == 0 ? (allowLongWrite ? ", allowLongWrite" : ", noLongWrite") : ""; + String str = "data longer than allowed. dataLen: " + dataLen + " > max: " + maxLen + " (" + a + b +")"; + result.error("writeCharacteristic", str, null); + break; + } + + // remember the data we are writing + if (primaryServiceUuid == null) {primaryServiceUuid = "";} + String key = remoteId + ":" + primaryServiceUuid + ":" + serviceUuid + ":" + + characteristicUuid + ":" + instanceId; + mWriteChr.put(key, value); + + // write characteristic + if (Build.VERSION.SDK_INT >= 33) { // Android 13 (August 2022) + + int rv = gatt.writeCharacteristic(characteristic, value, writeType); + + if (rv != BluetoothStatusCodes.SUCCESS) { + String s = "gatt.writeCharacteristic() returned " + rv + " : " + bluetoothStatusString(rv); + result.error("writeCharacteristic", s, null); + return; + } + + } else { + // set value + if(!characteristic.setValue(value)) { + result.error("writeCharacteristic", "characteristic.setValue() returned false", null); + break; + } + + // Write type + characteristic.setWriteType(writeType); + + // Write Char + if(!gatt.writeCharacteristic(characteristic)){ + result.error("writeCharacteristic", "gatt.writeCharacteristic() returned false", null); + break; + } + } + + result.success(true); + break; + } + + case "readDescriptor": + { + // see: BmReadDescriptorRequest + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + String primaryServiceUuid = (String) data.get("primary_service_uuid"); + String serviceUuid = (String) data.get("service_uuid"); + String characteristicUuid = (String) data.get("characteristic_uuid"); + Integer instanceId = (Integer) data.get("instance_id"); + String descriptorUuid = (String) data.get("descriptor_uuid"); + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("readDescriptor", "device is disconnected", null); + break; + } + + // find characteristic + ChrFound found = locateCharacteristic(gatt, primaryServiceUuid, serviceUuid, characteristicUuid, instanceId); + if (found.error != null) { + result.error("readDescriptor", found.error, null); + break; + } + + BluetoothGattCharacteristic characteristic = found.characteristic; + + + // find descriptor + BluetoothGattDescriptor descriptor = getDescriptorFromArray(descriptorUuid, characteristic.getDescriptors()); + if(descriptor == null) { + String s = "descriptor not found on characteristic. (desc: " + descriptorUuid + " chr: " + characteristicUuid + ")"; + result.error("writeDescriptor", s, null); + break; + } + + // read descriptor + if(gatt.readDescriptor(descriptor) == false) { + result.error("readDescriptor", "gatt.readDescriptor() returned false", null); + break; + } + + result.success(true); + break; + } + + case "writeDescriptor": + { + // see: BmWriteDescriptorRequest + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + String primaryServiceUuid = (String) data.get("primary_service_uuid"); + String serviceUuid = (String) data.get("service_uuid"); + String characteristicUuid = (String) data.get("characteristic_uuid"); + Integer instanceId = (Integer) data.get("instance_id"); + String descriptorUuid = (String) data.get("descriptor_uuid"); + byte[] value = (byte[]) data.get("value"); + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("writeDescriptor", "device is disconnected", null); + break; + } + + // find characteristic + ChrFound found = locateCharacteristic(gatt, primaryServiceUuid, serviceUuid, characteristicUuid, instanceId); + if (found.error != null) { + result.error("writeDescriptor", found.error, null); + break; + } + + BluetoothGattCharacteristic characteristic = found.characteristic; + + // find descriptor + BluetoothGattDescriptor descriptor = getDescriptorFromArray(descriptorUuid, characteristic.getDescriptors()); + if(descriptor == null) { + String s = "descriptor not found on characteristic. (desc: " + descriptorUuid + " chr: " + characteristicUuid + ")"; + result.error("writeDescriptor", s, null); + break; + } + + // check mtu + int mtu = mMtu.get(remoteId); + if ((mtu-3) < value.length) { + String s = "data longer than mtu allows. dataLength: " + value.length + "> max: " + (mtu-3); + result.error("writeDescriptor", s, null); + break; + } + + // remember the data we are writing + if (primaryServiceUuid == null) {primaryServiceUuid = "";} + String key = remoteId + ":" + primaryServiceUuid + ":" + serviceUuid + ":" + + characteristicUuid + ":" + instanceId + ":" + descriptorUuid; + mWriteDesc.put(key, value); + + // write descriptor + if (Build.VERSION.SDK_INT >= 33) { // Android 13 (August 2022) + + int rv = gatt.writeDescriptor(descriptor, value); + if (rv != BluetoothStatusCodes.SUCCESS) { + String s = "gatt.writeDescriptor() returned " + rv + " : " + bluetoothStatusString(rv); + result.error("writeDescriptor", s, null); + return; + } + + } else { + + // Set descriptor + if(!descriptor.setValue(value)){ + result.error("writeDescriptor", "descriptor.setValue() returned false", null); + break; + } + + // Write descriptor + if(!gatt.writeDescriptor(descriptor)){ + result.error("writeDescriptor", "gatt.writeDescriptor() returned false", null); + break; + } + } + + result.success(true); + break; + } + + case "setNotifyValue": + { + // see: BmSetNotifyValueRequest + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + String primaryServiceUuid = (String) data.get("primary_service_uuid"); + String serviceUuid = (String) data.get("service_uuid"); + String characteristicUuid = (String) data.get("characteristic_uuid"); + Integer instanceId = (Integer) data.get("instance_id"); + boolean forceIndications = (boolean) data.get("force_indications"); + boolean enable = (boolean) data.get("enable"); + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("setNotifyValue", "device is disconnected", null); + break; + } + + // find characteristic + ChrFound found = locateCharacteristic(gatt, primaryServiceUuid, serviceUuid, characteristicUuid, instanceId); + if (found.error != null) { + result.error("setNotifyValue", found.error, null); + break; + } + + BluetoothGattCharacteristic characteristic = found.characteristic; + + // configure local Android device to listen for characteristic changes + if(!gatt.setCharacteristicNotification(characteristic, enable)){ + result.error("setNotifyValue", + "gatt.setCharacteristicNotification(" + enable + ") returned false", null); + break; + } + + // find cccd descriptor + BluetoothGattDescriptor cccd = getDescriptorFromArray(CCCD, characteristic.getDescriptors()); + if(cccd == null) { + // Some ble devices do not actually need their CCCD updated. + // thus setCharacteristicNotification() is all that is required to enable notifications. + // The arduino "bluno" devices are an example. + String uuid = uuidStr(characteristic.getUuid()); + log(LogLevel.WARNING, "CCCD descriptor for characteristic not found: " + uuid); + result.success(false); + return; + } + + byte[] descriptorValue = null; + + // determine value to write + if(enable) { + + boolean canNotify = (characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_NOTIFY) > 0; + boolean canIndicate = (characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_INDICATE) > 0; + + if(!canIndicate && !canNotify) { + result.error("setNotifyValue", + "neither NOTIFY nor INDICATE properties are supported by this BLE characteristic", null); + break; + } + + if (forceIndications && !canIndicate) { + result.error("setNotifyValue","INDICATE not supported by this BLE characteristic", null); + break; + } + + // If a characteristic supports both notifications and indications, + // we use notifications. This matches how CoreBluetooth works on iOS. + // Except of course, if forceIndications is enabled. + if(canIndicate) {descriptorValue = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;} + if(canNotify) {descriptorValue = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;} + if(forceIndications) {descriptorValue = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;} + + } else { + descriptorValue = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE; + } + + // remember the data we are writing + if (primaryServiceUuid == null) {primaryServiceUuid = "";} + String key = remoteId + ":" + primaryServiceUuid + ":" + serviceUuid + ":" + + characteristicUuid + ":" + instanceId + ":" + CCCD; + mWriteDesc.put(key, descriptorValue); + + // write descriptor + if (Build.VERSION.SDK_INT >= 33) { // Android 13 (August 2022) + + int rv = gatt.writeDescriptor(cccd, descriptorValue); + if (rv != BluetoothStatusCodes.SUCCESS) { + String s = "gatt.writeDescriptor() returned " + rv + " : " + bluetoothStatusString(rv); + result.error("setNotifyValue", s, null); + break; + } + + } else { + + // set new value + if (!cccd.setValue(descriptorValue)) { + result.error("setNotifyValue", "cccd.setValue() returned false", null); + break; + } + + // update notifications on remote BLE device + if (!gatt.writeDescriptor(cccd)) { + result.error("setNotifyValue", "gatt.writeDescriptor() returned false", null); + break; + } + } + + result.success(true); + break; + } + + case "requestMtu": + { + // see: BmMtuChangeRequest + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + int mtu = (int) data.get("mtu"); + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("requestMtu", "device is disconnected", null); + break; + } + + // request mtu + if(gatt.requestMtu(mtu) == false) { + result.error("requestMtu", "gatt.requestMtu() returned false", null); + break; + } + + result.success(true); + break; + } + + case "readRssi": + { + String remoteId = (String) call.arguments; + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("readRssi", "device is disconnected", null); + break; + } + + // read rssi + if(gatt.readRemoteRssi() == false) { + result.error("readRssi", "gatt.readRemoteRssi() returned false", null); + break; + } + + result.success(true); + break; + } + + case "requestConnectionPriority": + { + // see: BmConnectionPriorityRequest + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + int connectionPriority = (int) data.get("connection_priority"); + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("requestConnectionPriority", "device is disconnected", null); + break; + } + + int cpInteger = bmConnectionPriorityParse(connectionPriority); + + // request priority + if(gatt.requestConnectionPriority(cpInteger) == false) { + result.error("requestConnectionPriority", "gatt.requestConnectionPriority() returned false", null); + break; + } + + result.success(true); + break; + } + + case "getPhySupport": + { + if(Build.VERSION.SDK_INT < 26) { // Android 8.0 (August 2017) + result.error("getPhySupport", + "Only supported on devices >= API 26. This device == " + + Build.VERSION.SDK_INT, null); + break; + } + + // see: PhySupport + HashMap map = new HashMap<>(); + map.put("le_2M", mBluetoothAdapter.isLe2MPhySupported()); + map.put("le_coded", mBluetoothAdapter.isLeCodedPhySupported()); + + result.success(map); + break; + } + + case "setPreferredPhy": + { + if(Build.VERSION.SDK_INT < 26) { // Android 8.0 (August 2017) + result.error("setPreferredPhy", + "Only supported on devices >= API 26. This device == " + + Build.VERSION.SDK_INT, null); + break; + } + + // see: BmPreferredPhy + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + int txPhy = (int) data.get("tx_phy"); + int rxPhy = (int) data.get("rx_phy"); + int phyOptions = (int) data.get("phy_options"); + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("setPreferredPhy", "device is disconnected", null); + break; + } + + // set preferred phy + gatt.setPreferredPhy(txPhy, rxPhy, phyOptions); + + result.success(true); + break; + } + + case "getBondedDevices": + { + final Set bondedDevices = mBluetoothAdapter.getBondedDevices(); + + List> devList = new ArrayList>(); + for (BluetoothDevice d : bondedDevices) { + devList.add(bmBluetoothDevice(d)); + } + + HashMap response = new HashMap(); + response.put("devices", devList); + + result.success(response); + break; + } + + case "getBondState": + { + String remoteId = (String) call.arguments; + + // get bond state + BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(remoteId); + + // see: BmBondStateResponse + HashMap response = new HashMap<>(); + response.put("remote_id", remoteId); + response.put("bond_state", bmBondStateEnum(device.getBondState())); + response.put("prev_state", null); + + result.success(response); + break; + } + + case "createBond": + { + HashMap data = call.arguments(); + String remoteId = (String) data.get("remote_id"); + if (data.get("pin") != null) { + mBondingPins.put(remoteId, (byte[]) data.get("pin")); + } + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("createBond", "device is disconnected", null); + break; + } + + BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(remoteId); + + // already bonded? + if (device.getBondState() == BluetoothDevice.BOND_BONDED) { + log(LogLevel.WARNING, "already bonded"); + result.success(false); // no work to do + break; + } + + // bonding already in progress? + if (device.getBondState() == BluetoothDevice.BOND_BONDING) { + log(LogLevel.WARNING, "bonding already in progress"); + result.success(true); // caller must wait for bond completion + break; + } + + // bond + if(device.createBond() == false) { + result.error("createBond", "device.createBond() returned false", null); + break; + } + + result.success(true); + break; + } + + case "removeBond": + { + String remoteId = (String) call.arguments; + + BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(remoteId); + + // already removed? + if (device.getBondState() == BluetoothDevice.BOND_NONE) { + log(LogLevel.WARNING, "already not bonded"); + result.success(false); // no work to do + break; + } + + Method removeBondMethod = device.getClass().getMethod("removeBond"); + boolean rv = (boolean) removeBondMethod.invoke(device); + if(rv == false) { + result.error("removeBond", "device.removeBond() returned false", null); + break; + } + + result.success(true); + break; + } + + case "clearGattCache": + { + String remoteId = (String) call.arguments; + + // check connection + BluetoothGatt gatt = mConnectedDevices.get(remoteId); + if(gatt == null) { + result.error("clearGattCache", "device is disconnected", null); + break; + } + + final Method refreshMethod = gatt.getClass().getMethod("refresh"); + if (refreshMethod == null) { + result.error("clearGattCache", "unsupported on this android version", null); + break; + } + + refreshMethod.invoke(gatt); + + result.success(true); + break; + } + + default: + { + result.notImplemented(); + break; + } + } + } catch (Exception e) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + String stackTrace = sw.toString(); + result.error("androidException", e.toString(), stackTrace); + return; + } finally { + mMethodCallMutex.release(); + } + } + + ////////////////////////////////////////////////////////////////////// + // █████ ██████ ████████ ██ ██ ██ ██ ████████ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ███████ ██ ██ ██ ██ ██ ██ ██ ████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ██████ ██ ██ ████ ██ ██ ██ + // + // ██████ ███████ ███████ ██ ██ ██ ████████ + // ██ ██ ██ ██ ██ ██ ██ ██ + // ██████ █████ ███████ ██ ██ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ███████ ███████ ██████ ███████ ██ + + @Override + public boolean onActivityResult(int requestCode, int resultCode, Intent data) + { + if (requestCode == enableBluetoothRequestCode) { + + // see: BmTurnOnResponse + HashMap map = new HashMap<>(); + map.put("user_accepted", resultCode == Activity.RESULT_OK); + + invokeMethodUIThread("OnTurnOnResponse", map); + + return true; + } + + return false; // did not handle anything + } + + ////////////////////////////////////////////////////////////////////////////////////// + // ██████ ███████ ██████ ███ ███ ██ ███████ ███████ ██ ██████ ███ ██ + // ██ ██ ██ ██ ██ ████ ████ ██ ██ ██ ██ ██ ██ ████ ██ + // ██████ █████ ██████ ██ ████ ██ ██ ███████ ███████ ██ ██ ██ ██ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ███████ ██ ██ ██ ██ ██ ███████ ███████ ██ ██████ ██ ████ + + @Override + public boolean onRequestPermissionsResult(int requestCode, + String[] permissions, + int[] grantResults) { + OperationOnPermission operation = operationsOnPermission.get(requestCode); + operationsOnPermission.remove(requestCode); // Clean up to prevent memory leaks + + if (operation != null && grantResults.length > 0) { + for (int i = 0; i < grantResults.length; i++) { + if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { + operation.op(false, permissions[i]); // permission denied + return true; + } + } + operation.op(true, null); // permission granted + return true; + } else { + return false; + } + } + + private void ensurePermissions(List permissions, OperationOnPermission operation) { + // check that we have a context + if (context == null) { + operation.op(false, "Application Context is null"); + return; + } + + // Filter out permissions that are already granted + List permissionsNeeded = new ArrayList<>(); + for (String permission : permissions) { + if (permission != null && ContextCompat.checkSelfPermission(context, permission) + != PackageManager.PERMISSION_GRANTED) { + permissionsNeeded.add(permission); + } + } + + // If all permissions are granted, proceed with the operation + if (permissionsNeeded.isEmpty()) { + operation.op(true, null); + return; + } + + askPermission(permissionsNeeded, operation); + } + + private void askPermission(List permissionsNeeded, OperationOnPermission operation) { + if (permissionsNeeded.isEmpty()) { + operation.op(true, null); + return; + } + + // Store the operation with the current request code + operationsOnPermission.put(lastEventId, operation); + + ActivityCompat.requestPermissions( + activityBinding.getActivity(), + permissionsNeeded.toArray(new String[0]), + lastEventId); + + lastEventId++; + } + + // Check if Android location services are enabled + @SuppressWarnings("deprecation") + private boolean isLocationEnabled() { + if (Build.VERSION.SDK_INT >= 31) { + // Android 12 (October 2021) - location services are not required for BLE scanning + return true; + } + + Context context = pluginBinding.getApplicationContext(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // This is a new method provided in API 28 / Android 9 August 2018 + LocationManager lm = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + return lm != null && lm.isLocationEnabled(); + } else { + // This was deprecated in API 28 + int mode = Settings.Secure.getInt(context.getContentResolver(), + Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_OFF); + return mode != Settings.Secure.LOCATION_MODE_OFF; + } + } + + ////////////////////////////////////////////// + // ██████ ██ ███████ + // ██ ██ ██ ██ + // ██████ ██ █████ + // ██ ██ ██ ██ + // ██████ ███████ ███████ + // + // ██ ██ ████████ ██ ██ ███████ + // ██ ██ ██ ██ ██ ██ + // ██ ██ ██ ██ ██ ███████ + // ██ ██ ██ ██ ██ ██ + // ██████ ██ ██ ███████ ███████ + + class ChrFound { + public BluetoothGattCharacteristic characteristic; + public String error; + + public ChrFound(BluetoothGattCharacteristic characteristic, String error) { + this.characteristic = characteristic; + this.error = error; + } + } + + private static int getInstanceId(BluetoothGattCharacteristic target) { + BluetoothGattService svc = target.getService(); + if (svc == null) return 0; + + UUID needle = target.getUuid(); + List list = svc.getCharacteristics(); + + int sameUuidCount = 0; + Integer sysIdForTarget = null; + + for (BluetoothGattCharacteristic c : list) { + if (needle.equals(c.getUuid())) { + sameUuidCount++; + if (c == target) { + // System-provided instance id + // (stable for the life of the discovery result) + sysIdForTarget = c.getInstanceId(); + } + } + } + + // Only use system instanceId to *disambiguate* when duplicates exist + return (sameUuidCount > 1) ? (sysIdForTarget != null ? sysIdForTarget : 0) : 0; + } + + + private ChrFound locateCharacteristic(BluetoothGatt gatt, + String primaryServiceUuid, + String serviceUuid, + String characteristicUuid, + Integer instanceId) + { + // remember this + boolean isSecondaryService = primaryServiceUuid != null; + + // primary service + if (primaryServiceUuid == null) { + primaryServiceUuid = serviceUuid; + } + + // primary + BluetoothGattService primaryService = getServiceFromArray(primaryServiceUuid, gatt.getServices()); + if(primaryService == null) { + return new ChrFound(null, "primary service not found '" + primaryServiceUuid + "'"); + } + + // secondary + BluetoothGattService secondaryService = null; + if(isSecondaryService) { + secondaryService = getServiceFromArray(serviceUuid, primaryService.getIncludedServices()); + if(secondaryService == null) { + return new ChrFound(null, "secondary service not found '" + serviceUuid + "' (primary service '" + primaryServiceUuid + "')"); + } + } + + // which service? + BluetoothGattService service = (secondaryService != null) ? secondaryService : primaryService; + + // characteristic + BluetoothGattCharacteristic characteristic = getCharacteristicFromArray(characteristicUuid, service.getCharacteristics(), instanceId); + if(characteristic == null) { + return new ChrFound(null, "characteristic not found in service " + + "(chr: '" + characteristicUuid + "' svc: '" + serviceUuid + "')"); + } + + return new ChrFound(characteristic, null); + } + + private BluetoothGattService getServiceFromArray(String uuid, List array) + { + for (BluetoothGattService s : array) { + if (uuid128(s.getUuid()).equals(uuid128(uuid))) { + return s; + } + } + return null; + } + + private BluetoothGattCharacteristic getCharacteristicFromArray(String uuid, List array, Integer instanceId) + { + for (BluetoothGattCharacteristic c : array) { + if (uuid128(c.getUuid()).equals(uuid128(uuid))) { + if (getInstanceId(c) == instanceId) { + return c; + } + } + } + return null; + } + + private BluetoothGattDescriptor getDescriptorFromArray(String uuid, List array) + { + for (BluetoothGattDescriptor d : array) { + if (uuid128(d.getUuid()).equals(uuid128(uuid))) { + return d; + } + } + return null; + } + + private boolean filterKeywords(List keywords, String target) { + if (keywords.isEmpty()) { + return true; + } + if (target == null) { + return false; + } + for (String k : keywords) { + if (target.contains(k)) { + return true; + } + } + return false; + } + + private int getMaxPayload(String remoteId, int writeType, boolean allowLongWrite) + { + // 512 this comes from the BLE spec. Characteritics should not + // be longer than 512. Android also enforces this as the maximum in internal code. + int maxAttrLen = 512; + + // if no response, or allowLongWrite is not allowed, we can only write up to MTU-3. + // This is the same limitation as iOS, and ensures transfer reliability. + if (writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE || allowLongWrite == false) { + + // get mtu + Integer mtu = mMtu.get(remoteId); + if (mtu == null) { + mtu = 23; // 23 is the minumum MTU, as per the BLE spec + } + + return Math.min(mtu - 3, maxAttrLen); + + } else { + // if withResponse and allowLongWrite is allowed, + // android will auto split up to the maxAttrLen. + return maxAttrLen; + } + } + + private void disconnectAllDevices(String func) + { + log(LogLevel.DEBUG, "disconnectAllDevices("+func+")"); + + // merge connected & autoconnected + Map allDevices = new HashMap<>(); + allDevices.putAll(mConnectedDevices); + allDevices.putAll(mAutoConnected); + + // request disconnections + for (BluetoothGatt gatt : allDevices.values()) { + + if (func == "adapterTurnOff") { + + // Note: + // - calling `disconnect` and `close` after the adapter + // is turned off is not necessary. It is implied. + // Calling them leads to a `DeadObjectException`. + // - But, we must make sure the disconnect callback is called. + // It's surprising but android does not invoke this callback itself. + mGattCallback.onConnectionStateChange(gatt, 0, BluetoothProfile.STATE_DISCONNECTED); + + } else { + + String remoteId = gatt.getDevice().getAddress(); + + // disconnect + log(LogLevel.DEBUG, "calling disconnect: " + remoteId); + gatt.disconnect(); + + // it is important to close after disconnection, otherwise we will + // quickly run out of bluetooth resources, preventing new connections + log(LogLevel.DEBUG, "calling close: " + remoteId); + gatt.close(); + } + } + + mConnectedDevices.clear(); + mCurrentlyConnectingDevices.clear(); + mBondingDevices.clear(); + mBondingPins.clear(); + mMtu.clear(); + mWriteChr.clear(); + mWriteDesc.clear(); + mAutoConnected.clear(); + } + + int getAppearanceFromScanRecord(ScanRecord adv) { + + if (Build.VERSION.SDK_INT >= 33) { // Android 13 (August 2022) + Map map = adv.getAdvertisingDataMap(); + if (map.containsKey(ScanRecord.DATA_TYPE_APPEARANCE)) { + byte[] bytes = map.get(ScanRecord.DATA_TYPE_APPEARANCE); + if (bytes.length == 2) { + int loByte = bytes[0] & 0xFF; + int hiByte = bytes[1] & 0xFF; + return hiByte * 256 + loByte; + } + } + return 0; + } + + // For API Level 21+ + byte[] bytes = adv.getBytes(); + + int n = 0; + + while (n < bytes.length) { + int fieldLen = bytes[n] & 0xFF; + + // no more or malformed data + if (fieldLen <= 0) { + break; + } + + // Ensuring we don't go past the bytes array + if (n + fieldLen >= bytes.length) { + break; + } + + int dataType = bytes[n + 1] & 0xFF; + + // no more data + if (dataType == 0) { + break; + } + + // appearance type byte + if (dataType == 0x19 && fieldLen == 3) { + int high = (bytes[n + 3] & 0xFF) << 8; + int low = (bytes[n + 2] & 0xFF); + return high | low; + } + + n += fieldLen + 1; + } + + return 0; + } + + // The original android implementation has a bug - it does not + // concatenate handle multiple MSD in the same advertisement. + byte[] getManufacturerSpecificData(ScanRecord adv) { + byte[] bytes = adv.getBytes(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int n = 0; + while (n < bytes.length) { + + // layout: + // n[0] = fieldlen + // n[1] = datatype (MSD) + // n[2] = manufacturerId (low) + // n[3] = manufacturerId (high) + // n[4] = data... + int fieldLen = bytes[n] & 0xFF; + + // no more or malformed data + if (fieldLen <= 0) { + break; + } + + // Ensuring we don't go past the bytes array + if (n + fieldLen >= bytes.length) { + break; + } + + int dataType = bytes[n + 1] & 0xFF; + + // Check for Manufacturer Specific Data type (0xFF) + // and that the field is large enough (at least 2 bytes: type + at least 1 data byte) + if (dataType == 0xFF && fieldLen >= 2) { + output.write(bytes, n + 2, fieldLen - 1); + } + + n += fieldLen + 1; + } + return output.toByteArray(); + } + + + ///////////////////////////////////////////////////////////////////////////////////// + // █████ ██████ █████ ██████ ████████ ███████ ██████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ███████ ██ ██ ███████ ██████ ██ █████ ██████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ██████ ██ ██ ██ ██ ███████ ██ ██ + // + // ██████ ███████ ██████ ███████ ██ ██ ██ ███████ ██████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██████ █████ ██ █████ ██ ██ ██ █████ ██████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ███████ ██████ ███████ ██ ████ ███████ ██ ██ + + private final BroadcastReceiver mBluetoothAdapterStateReceiver = new BroadcastReceiver() + { + @Override + public void onReceive(Context context, Intent intent) + { + final String action = intent.getAction(); + + // no change? + if (action == null || BluetoothAdapter.ACTION_STATE_CHANGED.equals(action) == false) { + return; + } + + final int adapterState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR); + + log(LogLevel.DEBUG, "OnAdapterStateChanged: " + adapterStateString(adapterState)); + + // stop scanning when adapter is turned back on. + // Otherwise, scanning automatically resumes when the adapter is + // turned back on. I don't think most users expect that. + if (adapterState == BluetoothAdapter.STATE_ON) { + if (mBluetoothAdapter != null && mIsScanning) { + BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner(); + if (scanner != null) { + log(LogLevel.DEBUG, "calling stopScan (Bluetooth Restarted)"); + scanner.stopScan(getScanCallback()); + mIsScanning = false; + } + } + } + + // see: BmBluetoothAdapterState + HashMap map = new HashMap<>(); + map.put("adapter_state", bmAdapterStateEnum(adapterState)); + + invokeMethodUIThread("OnAdapterStateChanged", map); + + // disconnect all devices + if (adapterState == BluetoothAdapter.STATE_TURNING_OFF || + adapterState == BluetoothAdapter.STATE_OFF) { + disconnectAllDevices("adapterTurnOff"); + } + } + }; + + ///////////////////////////////////////////////////////////////////////////////////// + // ██████ ██████ ███ ██ ██████ + // ██ ██ ██ ██ ████ ██ ██ ██ + // ██████ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██████ ██████ ██ ████ ██████ + // + // ██████ ███████ ██████ ███████ ██ ██ ██ ███████ ██████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██████ █████ ██ █████ ██ ██ ██ █████ ██████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ███████ ██████ ███████ ██ ████ ███████ ██ ██ + + + private final BroadcastReceiver mBluetoothPairRequestReceiver = new BroadcastReceiver() { + @Override + @SuppressWarnings("deprecation") // need for compatability + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (action == null || action.equals(BluetoothDevice.ACTION_PAIRING_REQUEST) == false) { + return; + } + + // BluetoothDevice + final BluetoothDevice device; + if (Build.VERSION.SDK_INT >= 33) { // Android 13 (August 2022) + device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice.class); + } else { + device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + } + + String remoteId = device.getAddress(); + if (mBondingPins.containsKey(remoteId)) { + byte[] pin = mBondingPins.get(remoteId); + log(LogLevel.DEBUG, "Setting PIN code for " + remoteId + ": " + bytesToHex(pin)); + // min. API level 19 + if (device.setPin(pin) == false) { + log(LogLevel.ERROR, "setPin() failed on " + remoteId); + } + mBondingPins.remove(remoteId); + } + } + }; + + private final BroadcastReceiver mBluetoothBondStateReceiver = new BroadcastReceiver() + { + @Override + @SuppressWarnings("deprecation") // need for compatability + public void onReceive(Context context, Intent intent) + { + final String action = intent.getAction(); + + // no change? + if (action == null || action.equals(BluetoothDevice.ACTION_BOND_STATE_CHANGED) == false) { + return; + } + + // BluetoothDevice + final BluetoothDevice device; + if (Build.VERSION.SDK_INT >= 33) { // Android 13 (August 2022) + device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice.class); + } else { + device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + } + + final int cur = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR); + final int prev = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1); + + log(LogLevel.DEBUG, "OnBondStateChanged: " + bondStateString(cur) + " prev: " + bondStateString(prev)); + + String remoteId = device.getAddress(); + + // remember which devices are currently bonding + if (cur == BluetoothDevice.BOND_BONDING) { + mBondingDevices.put(remoteId, device); + } else { + mBondingDevices.remove(remoteId); + } + + // see: BmBondStateResponse + HashMap map = new HashMap<>(); + map.put("remote_id", remoteId); + map.put("bond_state", bmBondStateEnum(cur)); + map.put("prev_state", bmBondStateEnum(prev)); + + invokeMethodUIThread("OnBondStateChanged", map); + } + }; + + ///////////////////////////////////////////////////////////////////////////// + // ███████ ██████ █████ ███ ██ + // ██ ██ ██ ██ ████ ██ + // ███████ ██ ███████ ██ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ + // ███████ ██████ ██ ██ ██ ████ + // + // ██████ █████ ██ ██ ██████ █████ ██████ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ███████ ██ ██ ██████ ███████ ██ █████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██████ ██ ██ ███████ ███████ ██████ ██ ██ ██████ ██ ██ + + private ScanCallback scanCallback; + + private int scanCountIncrement(String remoteId) { + if (mScanCounts.get(remoteId) == null) {mScanCounts.put(remoteId, 0);} + int count = mScanCounts.get(remoteId); + mScanCounts.put(remoteId, count+1); + return count; + } + + private ScanCallback getScanCallback() + { + if(scanCallback == null) { + + scanCallback = new ScanCallback() + { + @Override + @SuppressWarnings("unchecked") // type safety uses bluetooth_msgs.dart + public void onScanResult(int callbackType, ScanResult result) + { + log(LogLevel.VERBOSE, "onScanResult"); + + super.onScanResult(callbackType, result); + + BluetoothDevice device = result.getDevice(); + String remoteId = device.getAddress(); + ScanRecord scanRecord = result.getScanRecord(); + String advHex = scanRecord != null ? bytesToHex(scanRecord.getBytes()) : ""; + + // filter duplicates + if (((boolean) mScanFilters.get("continuous_updates")) == false) { + boolean isDuplicate = mAdvSeen.containsKey(remoteId) && mAdvSeen.get(remoteId).equals(advHex); + mAdvSeen.put(remoteId, advHex); // remember + if (isDuplicate) { + return; + } + } + + // filter keywords + String name = scanRecord != null ? scanRecord.getDeviceName() : ""; + List keywords = (List) mScanFilters.get("with_keywords"); + if (filterKeywords(keywords, name) == false) { + return; + } + + // filter divisor + if (((boolean) mScanFilters.get("continuous_updates")) != false) { + int count = scanCountIncrement(remoteId); + int divisor = (int) mScanFilters.get("continuous_divisor"); + if ((count % divisor) != 0) { + return; + } + } + + // see BmScanResponse + HashMap response = new HashMap<>(); + response.put("advertisements", Arrays.asList(bmScanAdvertisement(device, result))); + + invokeMethodUIThread("OnScanResponse", response); + } + + @Override + public void onBatchScanResults(List results) + { + super.onBatchScanResults(results); + } + + @Override + public void onScanFailed(int errorCode) + { + log(LogLevel.ERROR, "onScanFailed: " + scanFailedString(errorCode)); + + super.onScanFailed(errorCode); + + // see BmScanResponse + HashMap response = new HashMap<>(); + response.put("advertisements", new ArrayList<>()); + response.put("success", 0); + response.put("error_code", errorCode); + response.put("error_string", scanFailedString(errorCode)); + + invokeMethodUIThread("OnScanResponse", response); + } + }; + } + return scanCallback; + } + + ///////////////////////////////////////////////////////////////////////////// + // ██████ █████ ████████ ████████ + // ██ ██ ██ ██ ██ + // ██ ███ ███████ ██ ██ + // ██ ██ ██ ██ ██ ██ + // ██████ ██ ██ ██ ██ + // + // ██████ █████ ██ ██ ██████ █████ ██████ ██ ██ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ███████ ██ ██ ██████ ███████ ██ █████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██████ ██ ██ ███████ ███████ ██████ ██ ██ ██████ ██ ██ + + private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() + { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) + { + try { + // Prevent callback thread & method call thread from writing to + // mConnectedDevices & mCurrentlyConnectingDevices concurrently. + acquireMutex(mMethodCallMutex); + + log(LogLevel.DEBUG, "onConnectionStateChange:" + connectionStateString(newState)); + log(LogLevel.DEBUG, " status: " + hciStatusString(status)); + + // android never uses this callback with enums values of CONNECTING or DISCONNECTING, + // (theyre only used for gatt.getConnectionState()), but just to be + // future proof, explicitly ignore anything else. iOS & macOS is the same way. + if(newState != BluetoothProfile.STATE_CONNECTED && + newState != BluetoothProfile.STATE_DISCONNECTED) { + return; + } + + String remoteId = gatt.getDevice().getAddress(); + + // edge case. see function for details + if (handleUnexpectedConnectionEvents(gatt, newState, remoteId)) { + return; + } + + // connected? + if(newState == BluetoothProfile.STATE_CONNECTED) { + // add to connected devices + mConnectedDevices.put(remoteId, gatt); + + // remove from currently connecting devices + mCurrentlyConnectingDevices.remove(remoteId); + + // default minimum mtu + mMtu.put(remoteId, 23); + } + + // disconnected? + if(newState == BluetoothProfile.STATE_DISCONNECTED) { + + // remove from connected devices + mConnectedDevices.remove(remoteId); + + // remove from currently connecting devices + mCurrentlyConnectingDevices.remove(remoteId); + + // remove from currently bonding devices + mBondingDevices.remove(remoteId); + + // remove from cached PINs + mBondingPins.remove(remoteId); + + // we cannot call 'close' for autoconnected devices + // because it prevents autoconnect from working + if (mAutoConnected.containsKey(remoteId)) { + log(LogLevel.DEBUG, "autoconnect is true. skipping gatt.close()"); + } else { + // it is important to close after disconnection, otherwise we will + // quickly run out of bluetooth resources, preventing new connections + gatt.close(); + } + } + + // see: BmConnectionStateResponse + HashMap response = new HashMap<>(); + response.put("remote_id", remoteId); + response.put("connection_state", bmConnectionStateEnum(newState)); + response.put("disconnect_reason_code", status); + response.put("disconnect_reason_string", hciStatusString(status)); + + invokeMethodUIThread("OnConnectionStateChanged", response); + } finally { + mMethodCallMutex.release(); + } + } + + // Android has an annoying edge case. If disconnect is called right as the connection is being + // established, Android sometimes ignores the request to disconnect and completes the connection + // anyway. To handle this case, we make sure the device is still in our currently connecting + // devices map, otherwise kill the connection since the user was not expecting it to connect. + private boolean handleUnexpectedConnectionEvents(BluetoothGatt gatt, int newState, String remoteId) + { + boolean unexpectedEvent = false; + if (newState == BluetoothProfile.STATE_CONNECTED) { + if (mCurrentlyConnectingDevices.get(remoteId) == null && mAutoConnected.get(remoteId) == null) { + log(LogLevel.DEBUG, "[unexpected connection] disconnecting now"); + + // this is an unexpected connection + unexpectedEvent = true; + + // remove from connected devices + mConnectedDevices.remove(remoteId); + + // remove from currently bonding devices + mBondingDevices.remove(remoteId); + + // remove from cached PINs + mBondingPins.remove(remoteId); + + // disconnect and close the connection straight away + gatt.disconnect(); + gatt.close(); + } + + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + + if (mCurrentlyConnectingDevices.get(remoteId) == null && + mConnectedDevices.get(remoteId) == null && + mAutoConnected.get(remoteId) == null) { + + log(LogLevel.DEBUG, "[unexpected connection] disconnect complete"); + + // we have no record of this device, mark this is an unexpected event + unexpectedEvent = true; + + // remove from currently bonding devices + mBondingDevices.remove(remoteId); + + // remove from cached PINs + mBondingPins.remove(remoteId); + + // close the connection + gatt.close(); + } + } + return unexpectedEvent; + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) + { + LogLevel level = status == 0 ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onServicesDiscovered:"); + log(level, " count: " + gatt.getServices().size()); + log(level, " status: " + status + gattErrorString(status)); + + List services = new ArrayList(); + for(BluetoothGattService s : gatt.getServices()) { + services.add(bmBluetoothService(gatt.getDevice(), s, null, gatt)); + for(BluetoothGattService s2 : s.getIncludedServices()) { + services.add(bmBluetoothService(gatt.getDevice(), s2, s, gatt)); + } + } + + // see: BmDiscoverServicesResult + HashMap response = new HashMap<>(); + response.put("remote_id", gatt.getDevice().getAddress()); + response.put("services", services); + response.put("success", status == BluetoothGatt.GATT_SUCCESS ? 1 : 0); + response.put("error_code", status); + response.put("error_string", gattErrorString(status)); + + invokeMethodUIThread("OnDiscoveredServices", response); + } + + // called for both notifications & reads + public void onCharacteristicReceived(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value, int status) + { + // https://www.bluetooth.com/wp-content/uploads/Files/Specification/Assigned_Numbers.html + + // Generic Attribute service 0x1801 + if (uuidStr(characteristic.getService().getUuid()).equals("1801")) { + + // Service Changed 0x2A05 + if (uuidStr(characteristic.getUuid()).toUpperCase().equals("2A05")) { + HashMap response = bmBluetoothDevice(gatt.getDevice()); + invokeMethodUIThread("OnServicesReset", response); + } + } + + // has associated primary service? + BluetoothGattService primaryService = getPrimaryService(gatt, characteristic); + + // see: BmCharacteristicData + HashMap response = new HashMap<>(); + response.put("remote_id", gatt.getDevice().getAddress()); + if (primaryService != null) { + response.put("primary_service_uuid", uuidStr(primaryService.getUuid())); + } + response.put("service_uuid", uuidStr(characteristic.getService().getUuid())); + response.put("characteristic_uuid", uuidStr(characteristic.getUuid())); + response.put("instance_id", getInstanceId(characteristic)); + response.put("value", value); + response.put("success", status == BluetoothGatt.GATT_SUCCESS ? 1 : 0); + response.put("error_code", status); + response.put("error_string", gattErrorString(status)); + + invokeMethodUIThread("OnCharacteristicReceived", response); + } + + @Override + @TargetApi(33) // newer function with byte[] value argument + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) + { + // this callback is only for notifications & indications + LogLevel level = LogLevel.DEBUG; + log(level, "onCharacteristicChanged:"); + log(level, " chr: " + uuidStr(characteristic.getUuid())); + onCharacteristicReceived(gatt, characteristic, value, BluetoothGatt.GATT_SUCCESS); + } + + @Override + @TargetApi(33) // newer function with byte[] value argument + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value, int status) + { + // this callback is only for explicit characteristic reads + LogLevel level = status == BluetoothGatt.GATT_SUCCESS ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onCharacteristicRead:"); + log(level, " chr: " + uuidStr(characteristic.getUuid())); + log(level, " status: " + gattErrorString(status) + " (" + status + ")"); + log(level, " instanceId: " + getInstanceId(characteristic)); + onCharacteristicReceived(gatt, characteristic, value, status); + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) + { + LogLevel level = status == BluetoothGatt.GATT_SUCCESS ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onCharacteristicWrite:"); + log(level, " chr: " + uuidStr(characteristic.getUuid())); + log(level, " status: " + gattErrorString(status) + " (" + status + ")"); + + // For "writeWithResponse", onCharacteristicWrite is called after the remote sends back a write response. + // For "writeWithoutResponse", onCharacteristicWrite is called as long as there is still space left + // in android's internal buffer. When the buffer is full, it delays calling onCharacteristicWrite + // until there is at least ~50% free space again. + + // has associated primary service? + BluetoothGattService primaryService = getPrimaryService(gatt, characteristic); + + // for convenience + String remoteId = gatt.getDevice().getAddress(); + String primaryServiceUuid = primaryService != null ? uuidStr(primaryService.getUuid()) : ""; + String serviceUuid = uuidStr(characteristic.getService().getUuid()); + String characteristicUuid = uuidStr(characteristic.getUuid()); + Integer instanceId = getInstanceId(characteristic); + + // what data did we write? + String key = remoteId + ":" + primaryServiceUuid + ":" + serviceUuid + ":" + + characteristicUuid + ":" + instanceId; + byte[] value = mWriteChr.remove(key); + if (value == null) value = new byte[0]; + + // see: BmCharacteristicData + HashMap response = new HashMap<>(); + response.put("remote_id", remoteId); + if (primaryService != null) { + response.put("primary_service_uuid", uuidStr(primaryService.getUuid())); + } + response.put("service_uuid", serviceUuid); + response.put("characteristic_uuid", characteristicUuid); + response.put("instance_id", instanceId); + response.put("value", value); + response.put("success", status == BluetoothGatt.GATT_SUCCESS ? 1 : 0); + response.put("error_code", status); + response.put("error_string", gattErrorString(status)); + + invokeMethodUIThread("OnCharacteristicWritten", response); + } + + @Override + @TargetApi(33) // newer function, passes byte[] value + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status, byte[] value) + { + LogLevel level = status == 0 ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onDescriptorRead:"); + log(level, " chr: " + uuidStr(descriptor.getCharacteristic().getUuid())); + log(level, " desc: " + uuidStr(descriptor.getUuid())); + log(level, " status: " + gattErrorString(status) + " (" + status + ")"); + + // has associated primary service? + BluetoothGattService primaryService = getPrimaryService(gatt, descriptor.getCharacteristic()); + + // see: BmDescriptorData + HashMap response = new HashMap<>(); + response.put("remote_id", gatt.getDevice().getAddress()); + if (primaryService != null) { + response.put("primary_service_uuid", uuidStr(primaryService.getUuid())); + } + response.put("service_uuid", uuidStr(descriptor.getCharacteristic().getService().getUuid())); + response.put("characteristic_uuid", uuidStr(descriptor.getCharacteristic().getUuid())); + response.put("descriptor_uuid", uuidStr(descriptor.getUuid())); + response.put("instance_id", getInstanceId(descriptor.getCharacteristic())); + response.put("value", value); + response.put("success", status == BluetoothGatt.GATT_SUCCESS ? 1 : 0); + response.put("error_code", status); + response.put("error_string", gattErrorString(status)); + + invokeMethodUIThread("OnDescriptorRead", response); + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) + { + LogLevel level = status == 0 ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onDescriptorWrite:"); + log(level, " chr: " + uuidStr(descriptor.getCharacteristic().getUuid())); + log(level, " desc: " + uuidStr(descriptor.getUuid())); + log(level, " status: " + gattErrorString(status) + " (" + status + ")"); + + // has associated primary service? + BluetoothGattService primaryService = getPrimaryService(gatt, descriptor.getCharacteristic()); + + // for convenience + String remoteId = gatt.getDevice().getAddress(); + String primaryServiceUuid = primaryService != null ? uuidStr(primaryService.getUuid()) : ""; + String serviceUuid = uuidStr(descriptor.getCharacteristic().getService().getUuid()); + String characteristicUuid = uuidStr(descriptor.getCharacteristic().getUuid()); + Integer instanceId = getInstanceId(descriptor.getCharacteristic()); + String descriptorUuid = uuidStr(descriptor.getUuid()); + + // what data did we write? + String key = remoteId + ":" + primaryServiceUuid + ":" + serviceUuid + ":" + + characteristicUuid + ":" + instanceId + ":" + descriptorUuid; + byte[] value = mWriteDesc.remove(key); + if (value == null) value = new byte[0]; + + // see: BmDescriptorData + HashMap response = new HashMap<>(); + response.put("remote_id", remoteId); + if (primaryService != null) { + response.put("primary_service_uuid", uuidStr(primaryService.getUuid())); + } + response.put("service_uuid", serviceUuid); + response.put("characteristic_uuid", characteristicUuid); + response.put("descriptor_uuid", descriptorUuid); + response.put("instance_id", getInstanceId(descriptor.getCharacteristic())); + response.put("value", value); + response.put("success", status == BluetoothGatt.GATT_SUCCESS ? 1 : 0); + response.put("error_code", status); + response.put("error_string", gattErrorString(status)); + + invokeMethodUIThread("OnDescriptorWritten", response); + } + + @Override + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) + { + LogLevel level = status == 0 ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onReliableWriteCompleted:"); + log(level, " status: " + gattErrorString(status) + " (" + status + ")"); + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) + { + LogLevel level = status == 0 ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onReadRemoteRssi:"); + log(level, " rssi: " + rssi); + log(level, " status: " + gattErrorString(status) + " (" + status + ")"); + + // see: BmReadRssiResult + HashMap response = new HashMap<>(); + response.put("remote_id", gatt.getDevice().getAddress()); + response.put("rssi", rssi); + response.put("success", status == BluetoothGatt.GATT_SUCCESS ? 1 : 0); + response.put("error_code", status); + response.put("error_string", gattErrorString(status)); + + invokeMethodUIThread("OnReadRssi", response); + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) + { + LogLevel level = status == 0 ? LogLevel.DEBUG : LogLevel.ERROR; + log(level, "onMtuChanged:"); + log(level, " mtu: " + mtu ); + log(level, " status: " + gattErrorString(status) + " (" + status + ")"); + + String remoteId = gatt.getDevice().getAddress(); + + // remember mtu + mMtu.put(remoteId, mtu); + + // see: BmMtuChangedResponse + HashMap response = new HashMap<>(); + response.put("remote_id", remoteId); + response.put("mtu", mtu); + response.put("success", status == BluetoothGatt.GATT_SUCCESS ? 1 : 0); + response.put("error_code", status); + response.put("error_string", gattErrorString(status)); + + invokeMethodUIThread("OnMtuChanged", response); + } + + @Override + @SuppressWarnings("deprecation") // needed for android 12 & lower compatability + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) + { + // getValue() was deprecated in API level 33 because the function makes it look like + // you could always call getValue on a characteristic. But in reality, getValue() + // only works after a *read* has been made, not a *write*. + this.onCharacteristicChanged(gatt, characteristic, characteristic.getValue()); + } + + @Override + @SuppressWarnings("deprecation") // needed for android 12 & lower compatability + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) + { + // getValue() was deprecated in API level 33 because the function makes it look like + // you could always call getValue on a characteristic. But in reality, getValue() + // only works after a *read* has been made, not a *write*. + this.onCharacteristicRead(gatt, characteristic, characteristic.getValue(), status); + } + + @Override + @SuppressWarnings("deprecation") // needed for android 12 & lower compatability + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) + { + // getValue() was deprecated in API level 33 because the api makes it look like + // you could always call getValue on a descriptor. But in reality, getValue() + // only works after a *read* has been made, not a *write*. + this.onDescriptorRead(gatt, descriptor, status, descriptor.getValue()); + } + + }; // BluetoothGattCallback + + ////////////////////////////////////////////////////////////////////// + // ███ ███ ███████ ██████ + // ████ ████ ██ ██ + // ██ ████ ██ ███████ ██ ███ + // ██ ██ ██ ██ ██ ██ + // ██ ██ ███████ ██████ + // + // ██ ██ ███████ ██ ██████ ███████ ██████ ███████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ███████ █████ ██ ██████ █████ ██████ ███████ + // ██ ██ ██ ██ ██ ██ ██ ██ ██ + // ██ ██ ███████ ███████ ██ ███████ ██ ██ ███████ + + HashMap bmScanAdvertisement(BluetoothDevice device, ScanResult result) { + + int min = Integer.MIN_VALUE; + + ScanRecord adv = result.getScanRecord(); + + boolean connectable; + if (Build.VERSION.SDK_INT >= 26) { // Android 8.0, August 2017 + connectable = result.isConnectable(); + } else { + // Prior to Android 8.0, it is not possible to get if connectable. + // Previously, we used to check `adv.getAdvertiseFlags() & 0x2` but that + // returns if the device wants to be *discoverable*, which is not the same thing. + connectable = true; + } + + String advName = adv != null ? adv.getDeviceName() : null; + int txPower = adv != null ? adv.getTxPowerLevel() : min; + int appearance = adv != null ? getAppearanceFromScanRecord(adv) : 0; + byte[] rawMsd = adv != null ? getManufacturerSpecificData(adv) : null; + List serviceUuids = adv != null ? adv.getServiceUuids() : null; + Map serviceData = adv != null ? adv.getServiceData() : null; + + // Manufacturer Specific Data + HashMap manufDataB = new HashMap<>(); + if (rawMsd != null && rawMsd.length >= 2) { + // manufacturer ID uses little-endian order. + int manufacturerId = (rawMsd[0] & 0xFF) | ((rawMsd[1] & 0xFF) << 8); + + // payload + int payloadLen = rawMsd.length - 2; + byte[] payload = new byte[payloadLen]; + System.arraycopy(rawMsd, 2, payload, 0, payloadLen); + + // add to array + manufDataB.put(manufacturerId, payload); + } + + // Service Data + HashMap serviceDataB = new HashMap<>(); + if (serviceData != null) { + for (Map.Entry entry : serviceData.entrySet()) { + ParcelUuid key = entry.getKey(); + byte[] value = entry.getValue(); + serviceDataB.put(uuidStr(key.getUuid()), value); + } + } + + // Service UUIDs + List serviceUuidsB = new ArrayList<>(); + if (serviceUuids != null) { + for (ParcelUuid s : serviceUuids) { + serviceUuidsB.add(uuidStr(s.getUuid())); + } + } + + // See: BmScanAdvertisement + // perf: only add keys if they exists + HashMap map = new HashMap<>(); + if (device.getAddress() != null) {map.put("remote_id", device.getAddress());}; + if (device.getName() != null) {map.put("platform_name", device.getName());} + if (connectable) {map.put("connectable", 1);} + if (advName != null) {map.put("adv_name", advName);} + if (txPower != min) {map.put("tx_power_level", txPower);} + if (appearance != 0) {map.put("appearance", appearance);} + if (manufDataB.size() != 0) {map.put("manufacturer_data", manufDataB);} + if (serviceData != null) {map.put("service_data", serviceDataB);} + if (serviceUuids != null) {map.put("service_uuids", serviceUuidsB);} + if (result.getRssi() != 0) {map.put("rssi", result.getRssi());}; + return map; + } + + // See: BmBluetoothDevice + HashMap bmBluetoothDevice(BluetoothDevice device) { + HashMap map = new HashMap<>(); + map.put("remote_id", device.getAddress()); + map.put("platform_name", device.getName()); + return map; + } + + HashMap bmBluetoothService( + BluetoothDevice device, + BluetoothGattService service, + BluetoothGattService primaryService, + BluetoothGatt gatt) + { + + List characteristics = new ArrayList(); + for(BluetoothGattCharacteristic c : service.getCharacteristics()) { + characteristics.add(bmBluetoothCharacteristic(device, c, gatt)); + } + + // See: BmBluetoothService + HashMap map = new HashMap<>(); + map.put("remote_id", device.getAddress()); + if (primaryService != null) { + map.put("primary_service_uuid", uuidStr(primaryService.getUuid())); + } + map.put("service_uuid", uuidStr(service.getUuid())); + map.put("characteristics", characteristics); + return map; + } + + HashMap bmBluetoothCharacteristic(BluetoothDevice device, BluetoothGattCharacteristic characteristic, BluetoothGatt gatt) { + + List descriptors = new ArrayList(); + for(BluetoothGattDescriptor d : characteristic.getDescriptors()) { + descriptors.add(bmBluetoothDescriptor(device, d, gatt)); + } + + // has associated primary service? + BluetoothGattService primaryService = getPrimaryService(gatt, characteristic); + + // See: BmBluetoothCharacteristic + HashMap map = new HashMap<>(); + map.put("remote_id", device.getAddress()); + if (primaryService != null) { + map.put("primary_service_uuid", uuidStr(primaryService.getUuid())); + } + map.put("service_uuid", uuidStr(characteristic.getService().getUuid())); + map.put("characteristic_uuid", uuidStr(characteristic.getUuid())); + map.put("instance_id", getInstanceId(characteristic)); + map.put("descriptors", descriptors); + map.put("properties", bmCharacteristicProperties(characteristic.getProperties())); + return map; + } + + // See: BmBluetoothDescriptor + HashMap bmBluetoothDescriptor(BluetoothDevice device, BluetoothGattDescriptor descriptor, BluetoothGatt gatt) { + + // has associated primary service? + BluetoothGattService primaryService = getPrimaryService(gatt, descriptor.getCharacteristic()); + + HashMap map = new HashMap<>(); + map.put("remote_id", device.getAddress()); + if (primaryService != null) { + map.put("primary_service_uuid", uuidStr(primaryService.getUuid())); + } + map.put("service_uuid", uuidStr(descriptor.getCharacteristic().getService().getUuid())); + map.put("descriptor_uuid", uuidStr(descriptor.getUuid())); + map.put("characteristic_uuid", uuidStr(descriptor.getCharacteristic().getUuid())); + map.put("instance_id", getInstanceId(descriptor.getCharacteristic())); + return map; + } + + // See: BmCharacteristicProperties + HashMap bmCharacteristicProperties(int properties) { + HashMap props = new HashMap<>(); + props.put("broadcast", (properties & 1) != 0 ? 1 : 0); + props.put("read", (properties & 2) != 0 ? 1 : 0); + props.put("write_without_response", (properties & 4) != 0 ? 1 : 0); + props.put("write", (properties & 8) != 0 ? 1 : 0); + props.put("notify", (properties & 16) != 0 ? 1 : 0); + props.put("indicate", (properties & 32) != 0 ? 1 : 0); + props.put("authenticated_signed_writes", (properties & 64) != 0 ? 1 : 0); + props.put("extended_properties", (properties & 128) != 0 ? 1 : 0); + props.put("notify_encryption_required", (properties & 256) != 0 ? 1 : 0); + props.put("indicate_encryption_required", (properties & 512) != 0 ? 1 : 0); + return props; + } + + // See: BmConnectionStateEnum + static int bmConnectionStateEnum(int cs) { + switch (cs) { + case BluetoothProfile.STATE_DISCONNECTED: return 0; + case BluetoothProfile.STATE_CONNECTED: return 1; + default: return 0; + } + } + + // See: BmAdapterStateEnum + static int bmAdapterStateEnum(int as) { + switch (as) { + case BluetoothAdapter.STATE_OFF: return 6; + case BluetoothAdapter.STATE_ON: return 4; + case BluetoothAdapter.STATE_TURNING_OFF: return 5; + case BluetoothAdapter.STATE_TURNING_ON: return 3; + default: return 0; + } + } + + // See: BmBondStateEnum + static int bmBondStateEnum(int bs) { + switch (bs) { + case BluetoothDevice.BOND_NONE: return 0; + case BluetoothDevice.BOND_BONDING: return 1; + case BluetoothDevice.BOND_BONDED: return 2; + default: return 0; + } + } + + // See: BmConnectionPriority + static int bmConnectionPriorityParse(int value) { + switch(value) { + case 0: return BluetoothGatt.CONNECTION_PRIORITY_BALANCED; + case 1: return BluetoothGatt.CONNECTION_PRIORITY_HIGH; + case 2: return BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER; + default: return BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER; + } + } + + static BluetoothGattService getPrimaryService(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + + BluetoothGattService service = characteristic.getService(); + + // is this *already* a primary service? + if(service.getType() == BluetoothGattService.SERVICE_TYPE_PRIMARY) { + return null; + } + + // Otherwise, iterate included services until we find the primary service + for(BluetoothGattService primary : gatt.getServices()) { + for(BluetoothGattService secondary : primary.getIncludedServices()) { + if(secondary.getUuid().equals(service.getUuid())) { + return primary; + } + } + } + + return null; + } + + ////////////////////////////////////////// + // ██ ██ ████████ ██ ██ ███████ + // ██ ██ ██ ██ ██ ██ + // ██ ██ ██ ██ ██ ███████ + // ██ ██ ██ ██ ██ ██ + // ██████ ██ ██ ███████ ███████ + + private void log(LogLevel level, String message) + { + if(level.ordinal() > logLevel.ordinal()) { + return; + } + switch(level) { + case DEBUG: + Log.d(TAG, "[FBP] " + message); + break; + case WARNING: + Log.w(TAG, "[FBP] " + message); + break; + case ERROR: + Log.e(TAG, "[FBP] " + message); + break; + default: + Log.d(TAG, "[FBP] " + message); + break; + } + } + + private void invokeMethodUIThread(final String method, HashMap data) + { + new Handler(Looper.getMainLooper()).post(() -> { + //Could already be teared down at this moment + if (methodChannel != null) { + methodChannel.invokeMethod(method, data); + } else { + log(LogLevel.WARNING, "invokeMethodUIThread: tried to call method on closed channel: " + method); + } + }); + } + + private boolean isAdapterOn() + { + // get adapterState, if we have permission + try { + return mBluetoothAdapter.getState() == BluetoothAdapter.STATE_ON; + } catch (Exception e) { + return false; + } + } + + private static String bytesToHex(byte[] bytes) { + if (bytes == null) { + return ""; + } + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + private static String connectionStateString(int cs) { + switch (cs) { + case BluetoothProfile.STATE_DISCONNECTED: return "disconnected"; + case BluetoothProfile.STATE_CONNECTING: return "connecting"; + case BluetoothProfile.STATE_CONNECTED: return "connected"; + case BluetoothProfile.STATE_DISCONNECTING: return "disconnecting"; + default: return "UNKNOWN_CONNECTION_STATE (" + cs + ")"; + } + } + + private static String adapterStateString(int as) { + switch (as) { + case BluetoothAdapter.STATE_OFF: return "off"; + case BluetoothAdapter.STATE_ON: return "on"; + case BluetoothAdapter.STATE_TURNING_OFF: return "turningOff"; + case BluetoothAdapter.STATE_TURNING_ON: return "turningOn"; + default: return "UNKNOWN_ADAPTER_STATE (" + as + ")"; + } + } + + private static String bondStateString(int bs) { + switch (bs) { + case BluetoothDevice.BOND_BONDING: return "bonding"; + case BluetoothDevice.BOND_BONDED: return "bonded"; + case BluetoothDevice.BOND_NONE: return "bond-none"; + default: return "UNKNOWN_BOND_STATE (" + bs + ")"; + } + } + + // Defined in the Bluetooth Standard + private static String gattErrorString(int value) { + switch(value) { + case BluetoothGatt.GATT_SUCCESS : return "GATT_SUCCESS"; // 0 + case 0x01 : return "GATT_INVALID_HANDLE"; // 1 + case BluetoothGatt.GATT_READ_NOT_PERMITTED : return "GATT_READ_NOT_PERMITTED"; // 2 + case BluetoothGatt.GATT_WRITE_NOT_PERMITTED : return "GATT_WRITE_NOT_PERMITTED"; // 3 + case 0x04 : return "GATT_INVALID_PDU"; // 4 + case BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION : return "GATT_INSUFFICIENT_AUTHENTICATION"; // 5 + case BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED : return "GATT_REQUEST_NOT_SUPPORTED"; // 6 + case BluetoothGatt.GATT_INVALID_OFFSET : return "GATT_INVALID_OFFSET"; // 7 + case BluetoothGatt.GATT_INSUFFICIENT_AUTHORIZATION : return "GATT_INSUFFICIENT_AUTHORIZATION"; // 8 + case 0x09 : return "GATT_PREPARE_QUEUE_FULL"; // 9 + case 0x0a : return "GATT_ATTR_NOT_FOUND"; // 10 + case 0x0b : return "GATT_ATTR_NOT_LONG"; // 11 + case 0x0c : return "GATT_INSUFFICIENT_KEY_SIZE"; // 12 + case BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH : return "GATT_INVALID_ATTRIBUTE_LENGTH"; // 13 + case 0x0e : return "GATT_UNLIKELY"; // 14 + case BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION : return "GATT_INSUFFICIENT_ENCRYPTION"; // 15 + case 0x10 : return "GATT_UNSUPPORTED_GROUP"; // 16 + case 0x11 : return "GATT_INSUFFICIENT_RESOURCES"; // 17 + case 0x80 : return "GATT_NO_RESOURCES"; // 128 + case 0x81 : return "GATT_INTERNAL_ERROR"; // 129 + case 0x82 : return "GATT_WRONG_STATE"; // 130 + case 0x83 : return "GATT_DB_FULL"; // 131 + case 0x84 : return "GATT_BUSY"; // 132 + case 0x85 : return "GATT_ERROR"; // 133 + case 0x86 : return "GATT_CMD_STARTED"; // 134 + case 0x87 : return "GATT_ILLEGAL_PARAMETER"; // 135 + case 0x88 : return "GATT_PENDING"; // 136 + case 0x89 : return "GATT_AUTH_FAIL"; // 137 + case 0x8a : return "GATT_MORE"; // 138 + case 0x8b : return "GATT_INVALID_CFG"; // 139 + case 0x8c : return "GATT_SERVICE_STARTED"; // 140 + case 0x8d : return "GATT_ENCRYPTED_NO_MITM"; // 141 + case 0x8e : return "GATT_NOT_ENCRYPTED"; // 142 + case BluetoothGatt.GATT_CONNECTION_CONGESTED : return "GATT_CONNECTION_CONGESTED"; // 143 + case BluetoothGatt.GATT_FAILURE : return "GATT_FAILURE"; // 257 + default: return "UNKNOWN_GATT_ERROR (" + value + ")"; + } + } + + private static String bluetoothStatusString(int value) { + switch(value) { + case BluetoothStatusCodes.ERROR_BLUETOOTH_NOT_ALLOWED : return "ERROR_BLUETOOTH_NOT_ALLOWED"; + case BluetoothStatusCodes.ERROR_BLUETOOTH_NOT_ENABLED : return "ERROR_BLUETOOTH_NOT_ENABLED"; + case BluetoothStatusCodes.ERROR_DEVICE_NOT_BONDED : return "ERROR_DEVICE_NOT_BONDED"; + case BluetoothStatusCodes.ERROR_GATT_WRITE_NOT_ALLOWED : return "ERROR_GATT_WRITE_NOT_ALLOWED"; + case BluetoothStatusCodes.ERROR_GATT_WRITE_REQUEST_BUSY : return "ERROR_GATT_WRITE_REQUEST_BUSY"; + case BluetoothStatusCodes.ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION : return "ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION"; + case BluetoothStatusCodes.ERROR_PROFILE_SERVICE_NOT_BOUND : return "ERROR_PROFILE_SERVICE_NOT_BOUND"; + case BluetoothStatusCodes.ERROR_UNKNOWN : return "ERROR_UNKNOWN"; + //case BluetoothStatusCodes.FEATURE_NOT_CONFIGURED : return "FEATURE_NOT_CONFIGURED"; + case BluetoothStatusCodes.FEATURE_NOT_SUPPORTED : return "FEATURE_NOT_SUPPORTED"; + case BluetoothStatusCodes.FEATURE_SUPPORTED : return "FEATURE_SUPPORTED"; + case BluetoothStatusCodes.SUCCESS : return "SUCCESS"; + default: return "UNKNOWN_BLE_ERROR (" + value + ")"; + } + } + + private static String scanFailedString(int value) { + switch(value) { + case ScanCallback.SCAN_FAILED_ALREADY_STARTED : return "SCAN_FAILED_ALREADY_STARTED"; + case ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED: return "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED"; + case ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED : return "SCAN_FAILED_FEATURE_UNSUPPORTED"; + case ScanCallback.SCAN_FAILED_INTERNAL_ERROR : return "SCAN_FAILED_INTERNAL_ERROR"; + case ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES : return "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES"; + case ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY : return "SCAN_FAILED_SCANNING_TOO_FREQUENTLY"; + default: return "UNKNOWN_SCAN_ERROR (" + value + ")"; + } + } + + + // Defined in the Bluetooth Standard, Volume 1, Part F, 1.3 HCI Error Code, pages 364-377. + // See https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=478726, + // For Android specific errors, see https://developer.android.com/reference/android/bluetooth/BluetoothGatt#constants_1 + private static String hciStatusString(int value) { + switch(value) { + case 0x00: return "SUCCESS"; + case 0x01: return "UNKNOWN_COMMAND"; // The controller does not understand the HCI Command Packet OpCode that the Host sent. + case 0x02: return "UNKNOWN_CONNECTION_IDENTIFIER"; // The connection identifier used is unknown + case 0x03: return "HARDWARE_FAILURE"; // A hardware failure has occurred + case 0x04: return "PAGE_TIMEOUT"; // a page timed out because of the Page Timeout configuration parameter. + case 0x05: return "AUTHENTICATION_FAILURE"; // Pairing or authentication failed. This could be due to an incorrect PIN or Link Key. + case 0x06: return "PIN_OR_KEY_MISSING"; // Pairing failed because of a missing PIN + case 0x07: return "MEMORY_FULL"; // The Controller has run out of memory to store new parameters. + case 0x08: return "LINK_SUPERVISION_TIMEOUT"; // The link supervision timeout has expired for a given connection. + case 0x09: return "CONNECTION_LIMIT_EXCEEDED"; // The Controller is already at its limit of the number of connections it can support. + case 0x0A: return "MAX_NUM_OF_CONNECTIONS_EXCEEDED"; // The Controller has reached the limit of connections + case 0x0B: return "CONNECTION_ALREADY_EXISTS"; // A connection to this device already exists + case 0x0C: return "COMMAND_DISALLOWED"; // The command requested cannot be executed by the Controller at this time. + case 0x0D: return "CONNECTION_REJECTED_LIMITED_RESOURCES"; // A connection was rejected due to limited resources. + case 0x0E: return "CONNECTION_REJECTED_SECURITY_REASONS"; // A connection was rejected due to security, e.g. aauth or pairing. + case 0x0F: return "CONNECTION_REJECTED_UNACCEPTABLE_MAC_ADDRESS"; // connection rejected, this device does not accept the BD_ADDR + case 0x10: return "CONNECTION_ACCEPT_TIMEOUT_EXCEEDED"; // Connection Accept Timeout exceeded for this connection attempt. + case 0x11: return "UNSUPPORTED_PARAMETER_VALUE"; // A feature or parameter value in the HCI command is not supported. + case 0x12: return "INVALID_COMMAND_PARAMETERS"; // At least one of the HCI command parameters is invalid. + case 0x13: return "REMOTE_USER_TERMINATED_CONNECTION"; // The user on the remote device terminated the connection. + case 0x14: return "REMOTE_DEVICE_TERMINATED_CONNECTION_LOW_RESOURCES"; // remote device terminated connection due to low resources. + case 0x15: return "REMOTE_DEVICE_TERMINATED_CONNECTION_POWER_OFF"; // The remote device terminated the connection due to power off + case 0x16: return "CONNECTION_TERMINATED_BY_LOCAL_HOST"; // The local device terminated the connection. + case 0x17: return "REPEATED_ATTEMPTS"; // The Controller is disallowing auth because of too quick attempts. + case 0x18: return "PAIRING_NOT_ALLOWED"; // The device does not allow pairing + case 0x19: return "UNKNOWN_LMP_PDU"; // The Controller has received an unknown LMP OpCode. + case 0x1A: return "UNSUPPORTED_REMOTE_FEATURE"; // The remote device does not support feature for the issued command or LMP PDU. + case 0x1B: return "SCO_OFFSET_REJECTED"; // The offset requested in the LMP_SCO_link_req PDU has been rejected. + case 0x1C: return "SCO_INTERVAL_REJECTED"; // The interval requested in the LMP_SCO_link_req PDU has been rejected. + case 0x1D: return "SCO_AIR_MODE_REJECTED"; // The air mode requested in the LMP_SCO_link_req PDU has been rejected. + case 0x1E: return "INVALID_LMP_OR_LL_PARAMETERS"; // Some LMP PDU / LL Control PDU parameters were invalid. + case 0x1F: return "UNSPECIFIED"; // No other error code specified is appropriate to use + case 0x20: return "UNSUPPORTED_LMP_OR_LL_PARAMETER_VALUE"; // An LMP PDU or an LL Control PDU contains a value that is not supported + case 0x21: return "ROLE_CHANGE_NOT_ALLOWED"; // a Controller will not allow a role change at this time. + case 0x22: return "LMP_OR_LL_RESPONSE_TIMEOUT"; // An LMP transaction failed to respond within the LMP response timeout + case 0x23: return "LMP_OR_LL_ERROR_TRANS_COLLISION"; // An LMP transaction or LL procedure has collided with the same transaction + case 0x24: return "LMP_PDU_NOT_ALLOWED"; // A Controller sent an LMP PDU with an OpCode that was not allowed. + case 0x25: return "ENCRYPTION_MODE_NOT_ACCEPTABLE"; // The requested encryption mode is not acceptable at this time. + case 0x26: return "LINK_KEY_CANNOT_BE_EXCHANGED"; // A link key cannot be changed because a fixed unit key is being used. + case 0x27: return "REQUESTED_QOS_NOT_SUPPORTED"; // The requested Quality of Service is not supported. + case 0x28: return "INSTANT_PASSED"; // The LMP PDU or LL PDU instant has already passed + case 0x29: return "PAIRING_WITH_UNIT_KEY_NOT_SUPPORTED"; // It was not possible to pair as a unit key is not supported. + case 0x2A: return "DIFFERENT_TRANSACTION_COLLISION"; // An LMP transaction or LL Procedure collides with an ongoing transaction. + case 0x2B: return "UNDEFINED_0x2B"; // Undefined error code + case 0x2C: return "QOS_UNACCEPTABLE_PARAMETER"; // The quality of service parameters could not be accepted at this time. + case 0x2D: return "QOS_REJECTED"; // The specified quality of service parameters cannot be accepted. negotiation should be terminated + case 0x2E: return "CHANNEL_CLASSIFICATION_NOT_SUPPORTED"; // The Controller cannot perform channel assessment. not supported. + case 0x2F: return "INSUFFICIENT_SECURITY"; // The HCI command or LMP PDU sent is only possible on an encrypted link. + case 0x30: return "PARAMETER_OUT_OF_RANGE"; // A parameter in the HCI command is outside of valid range + case 0x31: return "UNDEFINED_0x31"; // Undefined error + case 0x32: return "ROLE_SWITCH_PENDING"; // A Role Switch is pending, sothe HCI command or LMP PDU is rejected + case 0x33: return "UNDEFINED_0x33"; // Undefined error + case 0x34: return "RESERVED_SLOT_VIOLATION"; // Synchronous negotiation terminated with negotiation state set to Reserved Slot Violation. + case 0x35: return "ROLE_SWITCH_FAILED"; // A role switch was attempted but it failed and the original piconet structure is restored. + case 0x36: return "INQUIRY_RESPONSE_TOO_LARGE"; // The extended inquiry response is too large to fit in packet supported by Controller. + case 0x37: return "SECURE_SIMPLE_PAIRING_NOT_SUPPORTED"; // Host does not support Secure Simple Pairing, but receiving Link Manager does. + case 0x38: return "HOST_BUSY_PAIRING"; // The Host is busy with another pairing operation. The receiving device should retry later. + case 0x39: return "CONNECTION_REJECTED_NO_SUITABLE_CHANNEL"; // Controller could not calculate an appropriate value for Channel selection. + case 0x3A: return "CONTROLLER_BUSY"; // The Controller was busy and unable to process the request. + case 0x3B: return "UNACCEPTABLE_CONNECTION_PARAMETERS"; // The remote device terminated connection, unacceptable connection parameters. + case 0x3C: return "ADVERTISING_TIMEOUT"; // Advertising completed. Or for directed advertising, no connection was created. + case 0x3D: return "CONNECTION_TERMINATED_MIC_FAILURE"; // Connection terminated because Message Integrity Check failed on received packet. + case 0x3E: return "CONNECTION_FAILED_ESTABLISHMENT"; // The LL initiated a connection but the connection has failed to be established. + case 0x3F: return "MAC_CONNECTION_FAILED"; // The MAC of the 802.11 AMP was requested to connect to a peer, but the connection failed. + case 0x40: return "COARSE_CLOCK_ADJUSTMENT_REJECTED"; // The master is unable to make a coarse adjustment to the piconet clock. + case 0x41: return "TYPE0_SUBMAP_NOT_DEFINED"; // The LMP PDU is rejected because the Type 0 submap is not currently defined. + case 0x42: return "UNKNOWN_ADVERTISING_IDENTIFIER"; // A command was sent from the Host but the Advertising or Sync handle does not exist. + case 0x43: return "LIMIT_REACHED"; // The number of operations requested has been reached and has indicated the completion of the activity + case 0x44: return "OPERATION_CANCELLED_BY_HOST"; // A request to the Controller issued by the Host and still pending was successfully canceled. + case 0x45: return "PACKET_TOO_LONG"; // An attempt was made to send or receive a packet that exceeds the maximum allowed packet length. + case 0x85: return "ANDROID_SPECIFIC_ERROR"; // Additional Android specific errors + case 0x8f: return "GATT_CONNECTION_CONGESTED"; // A remote device connection is congested. + case 0x93: return "GATT_CONNECTION_TIMEOUT"; // GATT connection timed out, likely due to the remote device being out of range or not advertising as connectable. + case 0x101: return "FAILURE_REGISTERING_CLIENT"; // max of 30 clients has been reached. + default: return "UNKNOWN_HCI_ERROR (" + value + ")"; + } + } + + + enum LogLevel + { + NONE, // 0 + ERROR, // 1 + WARNING, // 2 + INFO, // 3 + DEBUG, // 4 + VERBOSE // 5 + } +} diff --git a/packages/flutter_blue_plus_android/lib/flutter_blue_plus_android.dart b/packages/flutter_blue_plus_android/lib/flutter_blue_plus_android.dart new file mode 100644 index 0000000..0b22082 --- /dev/null +++ b/packages/flutter_blue_plus_android/lib/flutter_blue_plus_android.dart @@ -0,0 +1,583 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart'; + +final class FlutterBluePlusAndroid extends FlutterBluePlusPlatform { + @visibleForTesting + final methodChannel = const MethodChannel('flutter_blue_plus/methods'); + + var _didRestart = false; + var _logLevel = LogLevel.none; + var _logColor = true; + + final _onAdapterStateChangedController = StreamController.broadcast(); + final _onBondStateChangedController = StreamController.broadcast(); + final _onCharacteristicReceivedController = StreamController.broadcast(); + final _onCharacteristicWrittenController = StreamController.broadcast(); + final _onConnectionStateChangedController = StreamController.broadcast(); + final _onDescriptorReadController = StreamController.broadcast(); + final _onDescriptorWrittenController = StreamController.broadcast(); + final _onDetachedFromEngineController = StreamController.broadcast(); + final _onDiscoveredServicesController = StreamController.broadcast(); + final _onMtuChangedController = StreamController.broadcast(); + final _onNameChangedController = StreamController.broadcast(); + final _onReadRssiController = StreamController.broadcast(); + final _onScanResponseController = StreamController.broadcast(); + final _onServicesResetController = StreamController.broadcast(); + final _onTurnOnResponseController = StreamController.broadcast(); + + @override + Stream get onAdapterStateChanged { + return _onAdapterStateChangedController.stream; + } + + @override + Stream get onBondStateChanged { + return _onBondStateChangedController.stream; + } + + @override + Stream get onCharacteristicReceived { + return _onCharacteristicReceivedController.stream; + } + + @override + Stream get onCharacteristicWritten { + return _onCharacteristicWrittenController.stream; + } + + @override + Stream get onConnectionStateChanged { + return _onConnectionStateChangedController.stream; + } + + @override + Stream get onDescriptorRead { + return _onDescriptorReadController.stream; + } + + @override + Stream get onDescriptorWritten { + return _onDescriptorWrittenController.stream; + } + + @override + Stream get onDetachedFromEngine { + return _onDetachedFromEngineController.stream; + } + + @override + Stream get onDiscoveredServices { + return _onDiscoveredServicesController.stream; + } + + @override + Stream get onMtuChanged { + return _onMtuChangedController.stream; + } + + @override + Stream get onNameChanged { + return _onNameChangedController.stream; + } + + @override + Stream get onReadRssi { + return _onReadRssiController.stream; + } + + @override + Stream get onScanResponse { + return _onScanResponseController.stream; + } + + @override + Stream get onServicesReset { + return _onServicesResetController.stream; + } + + @override + Stream get onTurnOnResponse { + return _onTurnOnResponseController.stream; + } + + static void registerWith() { + FlutterBluePlusPlatform.instance = FlutterBluePlusAndroid(); + } + + @override + Future clearGattCache( + BmClearGattCacheRequest request, + ) async { + return await _callAndroidMethod( + 'clearGattCache', + request.remoteId.str, + ) == + true; + } + + @override + Future connect( + BmConnectRequest request, + ) async { + return await _callAndroidMethod( + 'connect', + request.toMap(), + ) == + true; + } + + @override + Future createBond( + BmCreateBondRequest request, + ) async { + return await _callAndroidMethod( + 'createBond', + request.toMap(), + ) == + true; + } + + @override + Future disconnect( + BmDisconnectRequest request, + ) async { + return await _callAndroidMethod( + 'disconnect', + request.remoteId.str, + ) == + true; + } + + @override + Future discoverServices( + BmDiscoverServicesRequest request, + ) async { + return await _callAndroidMethod( + 'discoverServices', + request.remoteId.str, + ) == + true; + } + + @override + Future getAdapterName( + BmBluetoothAdapterNameRequest request, + ) async { + return BmBluetoothAdapterName( + adapterName: await _callAndroidMethod( + 'getAdapterName', + ), + ); + } + + @override + Future getAdapterState( + BmBluetoothAdapterStateRequest request, + ) async { + return BmBluetoothAdapterState.fromMap( + await _callAndroidMethod( + 'getAdapterState', + ), + ); + } + + @override + Future getBondState( + BmBondStateRequest request, + ) async { + return BmBondStateResponse.fromMap( + await _callAndroidMethod( + 'getBondState', + request.remoteId.str, + ), + ); + } + + @override + Future getBondedDevices( + BmBondedDevicesRequest request, + ) async { + return BmDevicesList.fromMap( + await _callAndroidMethod( + 'getBondedDevices', + ), + ); + } + + @override + Future getPhySupport( + PhySupportRequest request, + ) async { + return PhySupport.fromMap( + await _callAndroidMethod( + 'getPhySupport', + ), + ); + } + + @override + Future getSystemDevices( + BmSystemDevicesRequest request, + ) async { + return BmDevicesList.fromMap( + await _callAndroidMethod( + 'getSystemDevices', + ), + ); + } + + @override + Future isSupported( + BmIsSupportedRequest request, + ) async { + return await _callAndroidMethod( + 'isSupported', + ) == + true; + } + + @override + Future readCharacteristic( + BmReadCharacteristicRequest request, + ) async { + return await _callAndroidMethod( + 'readCharacteristic', + request.toMap(), + ) == + true; + } + + @override + Future readDescriptor( + BmReadDescriptorRequest request, + ) async { + return await _callAndroidMethod( + 'readDescriptor', + request.toMap(), + ) == + true; + } + + @override + Future readRssi( + BmReadRssiRequest request, + ) async { + return await _callAndroidMethod( + 'readRssi', + request.remoteId.str, + ) == + true; + } + + @override + Future removeBond( + BmRemoveBondRequest request, + ) async { + return await _callAndroidMethod( + 'removeBond', + request.remoteId.str, + ) == + true; + } + + @override + Future requestConnectionPriority( + BmConnectionPriorityRequest request, + ) async { + return await _callAndroidMethod( + 'requestConnectionPriority', + request.toMap(), + ) == + true; + } + + @override + Future requestMtu( + BmMtuChangeRequest request, + ) async { + return await _callAndroidMethod( + 'requestMtu', + request.toMap(), + ) == + true; + } + + @override + Future setLogLevel( + BmSetLogLevelRequest request, + ) async { + _logLevel = request.logLevel; + _logColor = request.logColor; + + return await _callAndroidMethod( + 'setLogLevel', + request.logLevel.index, + ) == + true; + } + + @override + Future setNotifyValue( + BmSetNotifyValueRequest request, + ) async { + return await _callAndroidMethod( + 'setNotifyValue', + request.toMap(), + ) == + true; + } + + @override + Future setOptions( + BmSetOptionsRequest request, + ) async { + return await _callAndroidMethod( + 'setOptions', + request.toMap(), + ) == + true; + } + + @override + Future setPreferredPhy( + BmPreferredPhy request, + ) async { + return await _callAndroidMethod( + 'setPreferredPhy', + request.toMap(), + ) == + true; + } + + @override + Future startScan( + BmScanSettings request, + ) async { + return await _callAndroidMethod( + 'startScan', + request.toMap(), + ) == + true; + } + + @override + Future stopScan( + BmStopScanRequest request, + ) async { + return await _callAndroidMethod( + 'stopScan', + ) == + true; + } + + @override + Future turnOff( + BmTurnOffRequest request, + ) async { + return await _callAndroidMethod( + 'turnOff', + ) == + true; + } + + @override + Future turnOn( + BmTurnOnRequest request, + ) async { + return await _callAndroidMethod( + 'turnOn', + ) == + true; + } + + @override + Future writeCharacteristic( + BmWriteCharacteristicRequest request, + ) async { + return await _callAndroidMethod( + 'writeCharacteristic', + request.toMap(), + ) == + true; + } + + @override + Future writeDescriptor( + BmWriteDescriptorRequest request, + ) async { + return await _callAndroidMethod( + 'writeDescriptor', + request.toMap(), + ) == + true; + } + + Future _callAndroidMethod( + String method, [ + dynamic arguments, + ]) async { + // restart platform + if (!_didRestart && method != "setLogLevel") { + await _flutterRestart(); + } + + // set platform method handler + methodChannel.setMethodCallHandler(_methodCallHandler); + + // log args + if (_logLevel == LogLevel.verbose) { + var func = '<$method>'; + var args = arguments.toString(); + func = _logColor ? '\x1B[1;30m$func\x1B[0m' : func; + args = _logColor ? '\x1B[1;35m$args\x1B[0m' : args; + FlutterBluePlusPlatform.log('[FBP] $func args: $args'); + } + + // invoke + final out = await methodChannel.invokeMethod(method, arguments); + + // log result + if (_logLevel == LogLevel.verbose) { + var func = '($method)'; + var result = out.toString(); + func = _logColor ? '\x1B[1;30m$func\x1B[0m' : func; + result = _logColor ? '\x1B[1;33m$result\x1B[0m' : result; + FlutterBluePlusPlatform.log('[FBP] $func result: $result'); + } + + return out; + } + + Future _flutterRestart() async { + // wait for all devices to disconnect + if ((await methodChannel.invokeMethod('flutterRestart')) != 0) { + await Future.delayed(Duration(milliseconds: 50)); + while ((await methodChannel.invokeMethod('connectedCount')) != 0) { + await Future.delayed(Duration(milliseconds: 50)); + } + } + _didRestart = true; + } + + Future _methodCallHandler( + MethodCall call, + ) async { + // log result + if (_logLevel == LogLevel.verbose) { + var func = '[[ ${call.method} ]]'; + var result = switch (call.method) { + 'OnDiscoveredServices' => _prettyPrint(call.arguments), + _ => call.arguments.toString(), + }; + func = _logColor ? '\x1B[1;30m$func\x1B[0m' : func; + result = _logColor ? '\x1B[1;33m$result\x1B[0m' : result; + FlutterBluePlusPlatform.log('[FBP] $func result: $result'); + } + + // handle method call + switch (call.method) { + case 'OnAdapterStateChanged': + return _onAdapterStateChangedController.add( + BmBluetoothAdapterState.fromMap( + call.arguments, + ), + ); + case 'OnBondStateChanged': + return _onBondStateChangedController.add( + BmBondStateResponse.fromMap( + call.arguments, + ), + ); + case 'OnCharacteristicReceived': + return _onCharacteristicReceivedController.add( + BmCharacteristicData.fromMap( + call.arguments, + ), + ); + case 'OnCharacteristicWritten': + return _onCharacteristicWrittenController.add( + BmCharacteristicData.fromMap( + call.arguments, + ), + ); + case 'OnConnectionStateChanged': + return _onConnectionStateChangedController.add( + BmConnectionStateResponse.fromMap( + call.arguments, + ), + ); + case 'OnDescriptorRead': + return _onDescriptorReadController.add( + BmDescriptorData.fromMap( + call.arguments, + ), + ); + case 'OnDescriptorWritten': + return _onDescriptorWrittenController.add( + BmDescriptorData.fromMap( + call.arguments, + ), + ); + case 'OnDetachedFromEngine': + return _onDetachedFromEngineController.add( + BmDetachedFromEngineResponse(), + ); + case 'OnDiscoveredServices': + return _onDiscoveredServicesController.add( + BmDiscoverServicesResult.fromMap( + call.arguments, + ), + ); + case 'OnMtuChanged': + return _onMtuChangedController.add( + BmMtuChangedResponse.fromMap( + call.arguments, + ), + ); + case 'OnNameChanged': + return _onNameChangedController.add( + BmNameChanged.fromMap( + call.arguments, + ), + ); + case 'OnReadRssi': + return _onReadRssiController.add( + BmReadRssiResult.fromMap( + call.arguments, + ), + ); + case 'OnScanResponse': + return _onScanResponseController.add( + BmScanResponse.fromMap( + call.arguments, + ), + ); + case 'OnServicesReset': + return _onServicesResetController.add( + BmBluetoothDevice.fromMap( + call.arguments, + ), + ); + case 'OnTurnOnResponse': + return _onTurnOnResponseController.add( + BmTurnOnResponse.fromMap( + call.arguments, + ), + ); + } + } + + String _prettyPrint( + dynamic data, + ) { + if (data is Map || data is List) { + return JsonEncoder.withIndent(' ').convert(data); + } else { + return data.toString(); + } + } +} diff --git a/packages/flutter_blue_plus_android/pubspec.yaml b/packages/flutter_blue_plus_android/pubspec.yaml new file mode 100644 index 0000000..0be9785 --- /dev/null +++ b/packages/flutter_blue_plus_android/pubspec.yaml @@ -0,0 +1,27 @@ +name: flutter_blue_plus_android +description: Android implementation of the flutter_blue_plus plugin. +version: 8.2.2 +homepage: https://github.com/chipweinberger/flutter_blue_plus + +environment: + sdk: ^3.0.0 + flutter: '>=3.7.0' + +dependencies: + flutter: + sdk: flutter + flutter_blue_plus_platform_interface: 8.2.2 + +dev_dependencies: + flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter + +flutter: + plugin: + implements: flutter_blue_plus + platforms: + android: + package: com.lib.flutter_blue_plus + pluginClass: FlutterBluePlusPlugin + dartPluginClass: FlutterBluePlusAndroid diff --git a/pubspec.lock b/pubspec.lock index 2add0a5..8da533e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -440,12 +440,11 @@ packages: source: hosted version: "2.2.3" flutter_blue_plus_android: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_blue_plus_android - sha256: d66bdcb0438e643d5de4af914851bdf7322448384795666efe92236626484740 - url: "https://pub.dev" - source: hosted + path: "packages/flutter_blue_plus_android" + relative: true + source: path version: "8.2.2" flutter_blue_plus_darwin: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index d5c1e07..fb43e68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,6 +98,14 @@ hooks: android-x64: backends: [cpu] +# Vendored copy of the endorsed Android implementation, patched to close +# leaked BluetoothGatt handles (#694). Pristine 8.2.2 import first; the +# patch rides on top as its own commit for reviewability, like the WinRT +# vendor on #686. +dependency_overrides: + flutter_blue_plus_android: + path: packages/flutter_blue_plus_android + dev_dependencies: flutter_test: sdk: flutter