From cc01402c3892f0cca2fc54863932ece06251971b Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 01:59:40 -0400 Subject: [PATCH 1/6] Add JavaSake as a git submodule and wire it into the Gradle build JavaSake is added at JavaSake/ mirroring the layout PythonPumpConnector uses for PythonSake: an independent OpenMinimed repository embedded as a git submodule. After cloning, contributors initialise it with: git submodule update --init --recursive Gradle consumes the submodule as a composite build so JavaSake keeps its own standalone build (and standalone test suite). The Android app declares a dependency on the published artifact coordinates of the library subproject; Gradle automatically substitutes the local composite-build project at configuration time. --- .gitmodules | 3 +++ JavaSake | 1 + app/build.gradle.kts | 2 ++ settings.gradle.kts | 2 ++ 4 files changed, 8 insertions(+) create mode 100644 .gitmodules create mode 160000 JavaSake diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4b94742 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "JavaSake"] + path = JavaSake + url = https://github.com/OpenMinimed/JavaSake.git diff --git a/JavaSake b/JavaSake new file mode 160000 index 0000000..07dc6d6 --- /dev/null +++ b/JavaSake @@ -0,0 +1 @@ +Subproject commit 07dc6d69aec0a246027cffafab991a280cc7aa52 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cb4f18b..e193b02 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,6 +34,8 @@ android { } dependencies { + implementation("org.openminimed:lib:0.1.0-SNAPSHOT") + implementation(libs.appcompat) implementation(libs.material) implementation(libs.activity) diff --git a/settings.gradle.kts b/settings.gradle.kts index c77005b..367e74f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,5 @@ dependencyResolutionManagement { rootProject.name = "PumpConnector" include(":app") + +includeBuild("JavaSake") From 798fd475606df00f0292239f3fbc9d622e41250b Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 02:01:28 -0400 Subject: [PATCH 2/6] Add SakeHandler: BLE glue for the SAKE handshake Mirrors the pysake_handler.py logic from PythonPumpConnector. Owns a SakeServer pinned to Constants.KEYDB_PUMP_EXTRACTED and translates between GATT server callbacks and handshake step calls. All SAKE work runs on a dedicated HandlerThread (sake-handler) so the binder thread that delivers BLE callbacks never blocks on crypto work. On notification subscribe the handler emits 20 zero bytes as the wake-up frame. The pump replies with its own 20 zero bytes (which is the first input fed to SakeServer at stage 0) and the handshake proceeds from there. notifyCharacteristicChanged uses the API 33+ value-bearing overload when available and falls back to setValue + notify for API 24-32. --- .../pumpconnector/SakeHandler.java | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java diff --git a/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java new file mode 100644 index 0000000..9224bc2 --- /dev/null +++ b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java @@ -0,0 +1,149 @@ +package org.openminimed.pumpconnector; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattServer; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; + +import org.openminimed.sake.Constants; +import org.openminimed.sake.SakeServer; + +/** + * BLE glue layer for the SAKE handshake. + * + *

Owns a {@link SakeServer} pinned to the extracted-from-pump key database + * and translates between BLE GATT server events and handshake step calls. + * All SAKE work is serialized onto a dedicated {@link HandlerThread} so the + * binder thread that fires BLE callbacks is never blocked by crypto work.

+ * + *

Wire protocol the pump expects, in order:

+ *
    + *
  1. Pump subscribes to notifications on the SAKE characteristic. + * Handler emits twenty zero bytes as a wake-up notification.
  2. + *
  3. Pump writes its own twenty zero bytes as the matching wake-up. + * Handler feeds that to {@link SakeServer#handshake(byte[])}, gets + * msg0 back, notifies it to the pump.
  4. + *
  5. Pump writes msg1, msg3, msg5 in turn. Handler responds with + * msg2, msg4, and finally completes at stage 6.
  6. + *
+ */ +public final class SakeHandler { + + private static final String TAG = "SakeHandler"; + private static final int HANDSHAKE_COMPLETE_STAGE = 6; + private static final byte[] WAKE_UP = new byte[20]; + + private final SakeServer server; + private final HandlerThread thread; + private final Handler handler; + + private BluetoothGattServer gattServer; + private BluetoothGattCharacteristic characteristic; + private BluetoothDevice peer; + private boolean pumpSubscribed; + + public SakeHandler() { + this.server = new SakeServer(Constants.KEYDB_PUMP_EXTRACTED); + this.thread = new HandlerThread("sake-handler"); + this.thread.start(); + this.handler = new Handler(this.thread.getLooper()); + } + + /** + * Bind the GATT server and characteristic the handler will use to send + * SAKE notifications back to the pump. + */ + public void attach(BluetoothGattServer gattServer, + BluetoothGattCharacteristic characteristic) { + this.gattServer = gattServer; + this.characteristic = characteristic; + } + + /** + * Called from the GATT server callback when the pump subscribes to + * notifications on the SAKE characteristic. Emits a 20-byte wake-up frame. + */ + public void onNotificationsEnabled(BluetoothDevice device) { + handler.post(() -> { + peer = device; + if (pumpSubscribed) { + return; + } + pumpSubscribed = true; + Log.i(TAG, "Pump subscribed to SAKE notifications; sending wake-up"); + sendNotification(WAKE_UP.clone()); + }); + } + + /** Called from the GATT server callback when the pump unsubscribes. */ + public void onNotificationsDisabled() { + handler.post(() -> { + pumpSubscribed = false; + Log.w(TAG, "Pump unsubscribed from SAKE notifications"); + }); + } + + /** + * Called from the GATT server callback for every write on the SAKE + * characteristic. Drives the next handshake step and emits the response. + */ + public void onWrite(byte[] value) { + byte[] copy = value.clone(); + handler.post(() -> { + if (server.getStage() == HANDSHAKE_COMPLETE_STAGE) { + Log.w(TAG, "Ignoring write after handshake completion"); + return; + } + try { + byte[] response = server.handshake(copy); + if (response != null) { + sendNotification(response); + } else { + Log.i(TAG, "SAKE handshake complete"); + } + } catch (Exception e) { + Log.e(TAG, "SAKE handshake failed", e); + } + }); + } + + /** @return true once the handshake has reached stage 6. */ + public boolean isHandshakeComplete() { + return server.getStage() == HANDSHAKE_COMPLETE_STAGE; + } + + /** Stop the worker thread and release resources. */ + public void close() { + thread.quitSafely(); + } + + @SuppressWarnings("deprecation") + private void sendNotification(byte[] data) { + if (gattServer == null || characteristic == null || peer == null) { + Log.e(TAG, "Cannot send: handler is not attached or has no peer"); + return; + } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gattServer.notifyCharacteristicChanged(peer, characteristic, false, data); + } else { + characteristic.setValue(data); + gattServer.notifyCharacteristicChanged(peer, characteristic, false); + } + Log.d(TAG, "Notified SAKE bytes: " + bytesToHex(data)); + } catch (SecurityException e) { + Log.e(TAG, "Security exception sending SAKE notification", e); + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } +} From 80623f6b917ffd744727d3a7f91f2c7f0f705211 Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 02:02:40 -0400 Subject: [PATCH 3/6] Wire SakeHandler into BlePeripheralDevice and complete SAKE handshake TODO BlePeripheralDevice now owns a SakeHandler. After all GATT services are added, the handler is attached to the GATT server and the SAKE characteristic. CCC subscribes on the SAKE characteristic are forwarded to the handler's onNotificationsEnabled/Disabled hooks, and every write on the SAKE characteristic is forwarded to onWrite for processing on the handler's worker thread. The README TODO 'implement SAKE handshake' is now ticked. --- README.md | 2 +- .../pumpconnector/BlePeripheralDevice.java | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 07f62dc..b166c8e 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,5 @@ ### TODO: - [x] rename from me.palmarci to org.openminimed -- [ ] implement SAKE handshake +- [x] implement SAKE handshake diff --git a/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java b/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java index 589ba12..8337ad1 100644 --- a/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java +++ b/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java @@ -58,6 +58,8 @@ public class BlePeripheralDevice { private BluetoothLeAdvertiser advertiser; private BluetoothGattServer gattServer; private Queue addServiceQueue; + private BluetoothGattCharacteristic sakeCharacteristic; + private final SakeHandler sakeHandler = new SakeHandler(); // Permission check method public boolean hasBluetoothPermissions() { @@ -351,6 +353,8 @@ private BluetoothGattService createSakeService() { service.addCharacteristic(sakeChar); + this.sakeCharacteristic = sakeChar; + return service; } @@ -366,6 +370,7 @@ private void addNextService() { Log.d(TAG, "All services added successfully"); Log.d(TAG, "Device Info Service UUID: " + DEVICE_INFO_SERVICE_UUID); Log.d(TAG, "SAKE Service UUID: " + SAKE_SERVICE_UUID); + sakeHandler.attach(gattServer, sakeCharacteristic); } else { BluetoothGattService firstItem = addServiceQueue.poll(); if (firstItem != null) { @@ -495,6 +500,10 @@ public void onCharacteristicWriteRequest(BluetoothDevice device, int requestId, Log.d(TAG, "Value: " + bytesToHex(value)); Log.d(TAG, "Prepared write: " + preparedWrite + ", Response needed: " + responseNeeded); + if (characteristic.getUuid().equals(SAKE_CHARACTERISTIC_UUID)) { + sakeHandler.onWrite(value); + } + if (responseNeeded) { try { gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value); @@ -545,6 +554,16 @@ public void onDescriptorWriteRequest(BluetoothDevice device, int requestId, Blue if (cccValue == 0x0000) { Log.i(TAG, "Client unsubscribed from notifications/indications"); } + + boolean isSakeChar = descriptor.getCharacteristic() != null + && SAKE_CHARACTERISTIC_UUID.equals(descriptor.getCharacteristic().getUuid()); + if (isSakeChar) { + if ((cccValue & 0x0001) != 0) { + sakeHandler.onNotificationsEnabled(device); + } else if (cccValue == 0x0000) { + sakeHandler.onNotificationsDisabled(); + } + } } // Update descriptor value From 2f6361bf91af39a0f3947c6d7bb6927e52ce6e4c Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 02:44:05 -0400 Subject: [PATCH 4/6] Add GitHub Actions workflow and .editorconfig Workflow runs on every pull request and on manual dispatch. Single Ubuntu job: checks out the source with submodules (so JavaSake is populated), sets up Temurin JDK 17, the Android SDK, and Gradle with caching, then runs assembleDebug, Android lintDebug and the unit test suite for the debug variant. On failure both app and JavaSake reports are uploaded as artifacts for seven days. .editorconfig matches the JavaSake configuration: 4-space indent for java/kt/kts, 2-space for xml/yml, LF line endings, UTF-8, trailing whitespace trimmed except in Markdown. --- .editorconfig | 17 ++++++++++++++ .github/workflows/build.yml | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/build.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dadba56 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{java,kt,kts}] +indent_size = 4 + +[*.{xml,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7d77807 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: build + +on: + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out source with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Assemble debug APK + run: ./gradlew :app:assembleDebug --no-daemon + + - name: Run Android lint + run: ./gradlew :app:lintDebug --no-daemon + + - name: Run unit tests + run: ./gradlew :app:testDebugUnitTest --no-daemon + + - name: Upload reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: reports + path: | + app/build/reports/ + JavaSake/lib/build/reports/ + retention-days: 7 From 25ded032ad1a499069ee117cf7709f7accb1e3ea Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 02:50:06 -0400 Subject: [PATCH 5/6] Add Spotless with google-java-format AOSP style and lint baseline Adds the com.diffplug.spotless plugin to the :app module, wired via the version catalog. Spotless runs google-java-format 1.22.0 AOSP style on every Java source under app/src; spotlessCheck is bound to check, so the GitHub Actions workflow added in the previous commit enforces formatting on every pull request. The lint configuration adopts the AGP-recommended baseline pattern. app/lint-baseline.xml records pre-existing layout warnings carried over from the original scaffolding so CI fails on newly introduced issues only. Maintainers can drop entries from the baseline as those issues are fixed. Remainder of the diff is the one-shot canonicalisation pass of the existing Java sources under the new formatter. No behavioural changes. :app:assembleDebug, :app:lintDebug and :app:testDebugUnitTest all pass after the reformat. --- app/build.gradle.kts | 16 + app/lint-baseline.xml | 147 ++++ .../ExampleInstrumentedTest.java | 10 +- .../pumpconnector/BlePeripheralDevice.java | 711 ++++++++++-------- .../pumpconnector/MainActivity.java | 25 +- .../pumpconnector/SakeHandler.java | 105 +-- .../pumpconnector/ExampleUnitTest.java | 4 +- gradle/libs.versions.toml | 3 + 8 files changed, 648 insertions(+), 373 deletions(-) create mode 100644 app/lint-baseline.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e193b02..b917f13 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.spotless) } android { @@ -31,6 +32,21 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + + lint { + baseline = file("lint-baseline.xml") + warningsAsErrors = false + } +} + +spotless { + java { + target("src/**/*.java") + googleJavaFormat(libs.versions.googleJavaFormat.get()).aosp() + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } } dependencies { diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 0000000..25adf4e --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/org/openminimed/pumpconnector/ExampleInstrumentedTest.java b/app/src/androidTest/java/org/openminimed/pumpconnector/ExampleInstrumentedTest.java index 0eaaffa..6c4b0c5 100644 --- a/app/src/androidTest/java/org/openminimed/pumpconnector/ExampleInstrumentedTest.java +++ b/app/src/androidTest/java/org/openminimed/pumpconnector/ExampleInstrumentedTest.java @@ -1,15 +1,13 @@ package org.openminimed.pumpconnector; -import android.content.Context; +import static org.junit.Assert.*; -import androidx.test.platform.app.InstrumentationRegistry; +import android.content.Context; import androidx.test.ext.junit.runners.AndroidJUnit4; - +import androidx.test.platform.app.InstrumentationRegistry; import org.junit.Test; import org.junit.runner.RunWith; -import static org.junit.Assert.*; - /** * Instrumented test, which will execute on an Android device. * @@ -23,4 +21,4 @@ public void useAppContext() { Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); assertEquals("org.openminimed.pumpconnector", appContext.getPackageName()); } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java b/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java index 8337ad1..07eaa67 100644 --- a/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java +++ b/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java @@ -16,41 +16,49 @@ import android.bluetooth.le.BluetoothLeAdvertiser; import android.content.Context; import android.content.pm.PackageManager; -import androidx.appcompat.app.AppCompatActivity; import android.os.ParcelUuid; import android.util.Log; - +import androidx.appcompat.app.AppCompatActivity; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.LinkedList; import java.util.List; import java.util.Queue; import java.util.UUID; -/** - * BLE Peripheral Device implementation with custom advertisement and services - */ +/** BLE Peripheral Device implementation with custom advertisement and services */ public class BlePeripheralDevice { private static final String TAG = "BlePeripheralDevice"; // Custom UUIDs - private static final UUID DEVICE_INFO_SERVICE_UUID = UUID.fromString("00000900-0000-1000-0000-009132591325"); - private static final UUID SAKE_SERVICE_UUID = UUID.fromString("0000fe82-0000-1000-8000-00805f9b34fb"); - private static final UUID SAKE_CHARACTERISTIC_UUID = UUID.fromString("0000fe82-0000-1000-0000-009132591325"); + private static final UUID DEVICE_INFO_SERVICE_UUID = + UUID.fromString("00000900-0000-1000-0000-009132591325"); + private static final UUID SAKE_SERVICE_UUID = + UUID.fromString("0000fe82-0000-1000-8000-00805f9b34fb"); + private static final UUID SAKE_CHARACTERISTIC_UUID = + UUID.fromString("0000fe82-0000-1000-0000-009132591325"); // Standard Device Information Service characteristics (16-bit UUIDs) - private static final UUID MANUFACTURER_NAME_UUID = UUID.fromString("00002a29-0000-1000-8000-00805f9b34fb"); - private static final UUID MODEL_NUMBER_UUID = UUID.fromString("00002a24-0000-1000-8000-00805f9b34fb"); - private static final UUID SERIAL_NUMBER_UUID = UUID.fromString("00002a25-0000-1000-8000-00805f9b34fb"); - private static final UUID HARDWARE_REVISION_UUID = UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb"); - private static final UUID FIRMWARE_REVISION_UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb"); - private static final UUID SOFTWARE_REVISION_UUID = UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb"); - private static final UUID SYSTEM_ID_UUID = UUID.fromString("00002a23-0000-1000-8000-00805f9b34fb"); + private static final UUID MANUFACTURER_NAME_UUID = + UUID.fromString("00002a29-0000-1000-8000-00805f9b34fb"); + private static final UUID MODEL_NUMBER_UUID = + UUID.fromString("00002a24-0000-1000-8000-00805f9b34fb"); + private static final UUID SERIAL_NUMBER_UUID = + UUID.fromString("00002a25-0000-1000-8000-00805f9b34fb"); + private static final UUID HARDWARE_REVISION_UUID = + UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb"); + private static final UUID FIRMWARE_REVISION_UUID = + UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb"); + private static final UUID SOFTWARE_REVISION_UUID = + UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb"); + private static final UUID SYSTEM_ID_UUID = + UUID.fromString("00002a23-0000-1000-8000-00805f9b34fb"); private static final UUID PNP_ID_UUID = UUID.fromString("00002a50-0000-1000-8000-00805f9b34fb"); - private static final UUID REGULATORY_CERT_UUID = UUID.fromString("00002a2a-0000-1000-8000-00805f9b34fb"); + private static final UUID REGULATORY_CERT_UUID = + UUID.fromString("00002a2a-0000-1000-8000-00805f9b34fb"); // Client Characteristic Configuration descriptor - private static final UUID CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + private static final UUID CCC_DESCRIPTOR_UUID = + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); private final Context context; private BluetoothManager bluetoothManager; @@ -65,12 +73,15 @@ public class BlePeripheralDevice { public boolean hasBluetoothPermissions() { // For API 31+ (Android 12+), we need BLUETOOTH_CONNECT and BLUETOOTH_ADVERTISE if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - return context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && - context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED; + return context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) + == PackageManager.PERMISSION_GRANTED + && context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADVERTISE) + == PackageManager.PERMISSION_GRANTED; } // For API 23-30 (Android 6.0 - 11), we need ACCESS_FINE_LOCATION for Bluetooth operations else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - return context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; + return context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED; } // For API < 23, no runtime permissions needed else { @@ -95,15 +106,16 @@ public void requestBluetoothPermissions() { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { // For Android 12+ - activity.requestPermissions(new String[]{ - android.Manifest.permission.BLUETOOTH_CONNECT, - android.Manifest.permission.BLUETOOTH_ADVERTISE - }, 101); + activity.requestPermissions( + new String[] { + android.Manifest.permission.BLUETOOTH_CONNECT, + android.Manifest.permission.BLUETOOTH_ADVERTISE + }, + 101); } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { // For Android 6.0 - 11 - activity.requestPermissions(new String[]{ - android.Manifest.permission.ACCESS_FINE_LOCATION - }, 101); + activity.requestPermissions( + new String[] {android.Manifest.permission.ACCESS_FINE_LOCATION}, 101); } // For Android < 6.0, no runtime permissions needed } @@ -156,27 +168,29 @@ private void startAdvertising() { } // Create advertisement data - AdvertiseSettings settings = new AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) - .setConnectable(true) - .setTimeout(0) // Advertise until explicitly stopped - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) - .build(); + AdvertiseSettings settings = + new AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setConnectable(true) + .setTimeout(0) // Advertise until explicitly stopped + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build(); AdvertiseData data = createAdvertisementData(); - AdvertiseCallback callback = new AdvertiseCallback() { - @Override - public void onStartSuccess(AdvertiseSettings settingsInEffect) { - Log.i(TAG, "Advertising started successfully"); - Log.d(TAG, "Advertise settings: " + settingsInEffect); - } + AdvertiseCallback callback = + new AdvertiseCallback() { + @Override + public void onStartSuccess(AdvertiseSettings settingsInEffect) { + Log.i(TAG, "Advertising started successfully"); + Log.d(TAG, "Advertise settings: " + settingsInEffect); + } - @Override - public void onStartFailure(int errorCode) { - Log.e(TAG, "Advertising failed with error: " + errorCode); - } - }; + @Override + public void onStartFailure(int errorCode) { + Log.e(TAG, "Advertising failed with error: " + errorCode); + } + }; advertiser.startAdvertising(settings, data, callback); Log.d(TAG, "Advertising started"); @@ -196,7 +210,7 @@ private AdvertiseData createAdvertisementData() { // Add service data for the custom service byte[] serviceData = new byte[] {0x01}; // Simple service data - // builder.addServiceData(serviceUuid, serviceData); + // builder.addServiceData(serviceUuid, serviceData); return builder.build(); } @@ -240,115 +254,125 @@ private void startGattServer() { } private BluetoothGattService createDeviceInfoService() { - BluetoothGattService service = new BluetoothGattService( - DEVICE_INFO_SERVICE_UUID, - BluetoothGattService.SERVICE_TYPE_PRIMARY - ); + BluetoothGattService service = + new BluetoothGattService( + DEVICE_INFO_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY); // Manufacturer Name String [R] - BluetoothGattCharacteristic manufacturerName = new BluetoothGattCharacteristic( - MANUFACTURER_NAME_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); + BluetoothGattCharacteristic manufacturerName = + new BluetoothGattCharacteristic( + MANUFACTURER_NAME_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); manufacturerName.setValue("Test Manufacturer"); service.addCharacteristic(manufacturerName); // Model Number String [R] - BluetoothGattCharacteristic modelNumber = new BluetoothGattCharacteristic( - MODEL_NUMBER_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); + BluetoothGattCharacteristic modelNumber = + new BluetoothGattCharacteristic( + MODEL_NUMBER_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); modelNumber.setValue("Test Model Number"); service.addCharacteristic(modelNumber); // Serial Number String [R] - BluetoothGattCharacteristic serialNumber = new BluetoothGattCharacteristic( - SERIAL_NUMBER_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); + BluetoothGattCharacteristic serialNumber = + new BluetoothGattCharacteristic( + SERIAL_NUMBER_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); serialNumber.setValue(MOBILE_NAME); service.addCharacteristic(serialNumber); // Hardware Revision String [R] - BluetoothGattCharacteristic hardwareRevision = new BluetoothGattCharacteristic( - HARDWARE_REVISION_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); + BluetoothGattCharacteristic hardwareRevision = + new BluetoothGattCharacteristic( + HARDWARE_REVISION_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); hardwareRevision.setValue("HW-1.0"); service.addCharacteristic(hardwareRevision); // Firmware Revision String [R] - BluetoothGattCharacteristic firmwareRevision = new BluetoothGattCharacteristic( - FIRMWARE_REVISION_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); + BluetoothGattCharacteristic firmwareRevision = + new BluetoothGattCharacteristic( + FIRMWARE_REVISION_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); firmwareRevision.setValue("14587043"); service.addCharacteristic(firmwareRevision); // Software Revision String [R] - BluetoothGattCharacteristic softwareRevision = new BluetoothGattCharacteristic( - SOFTWARE_REVISION_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); - // softwareRevision.setValue("SW-3.2.1"); + BluetoothGattCharacteristic softwareRevision = + new BluetoothGattCharacteristic( + SOFTWARE_REVISION_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); + // softwareRevision.setValue("SW-3.2.1"); softwareRevision.setValue(FAKE_APP_VER); // APK version with git commit? service.addCharacteristic(softwareRevision); // System ID [R] - BluetoothGattCharacteristic systemId = new BluetoothGattCharacteristic( - SYSTEM_ID_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); - systemId.setValue(new byte[]{(byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0, (byte)0x0, (byte) 0x0, (byte) 0x0}); + BluetoothGattCharacteristic systemId = + new BluetoothGattCharacteristic( + SYSTEM_ID_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); + systemId.setValue( + new byte[] { + (byte) 0x0, + (byte) 0x0, + (byte) 0x0, + (byte) 0x0, + (byte) 0x0, + (byte) 0x0, + (byte) 0x0, + (byte) 0x0 + }); service.addCharacteristic(systemId); // PnP ID [R] - BluetoothGattCharacteristic pnpId = new BluetoothGattCharacteristic( - PNP_ID_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); - pnpId.setValue(new byte[]{0x0}); + BluetoothGattCharacteristic pnpId = + new BluetoothGattCharacteristic( + PNP_ID_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); + pnpId.setValue(new byte[] {0x0}); service.addCharacteristic(pnpId); // IEEE 11073-20601 Regulatory Certification Data List [R] - BluetoothGattCharacteristic regulatoryCert = new BluetoothGattCharacteristic( - REGULATORY_CERT_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ); - regulatoryCert.setValue(new byte[]{}); + BluetoothGattCharacteristic regulatoryCert = + new BluetoothGattCharacteristic( + REGULATORY_CERT_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ); + regulatoryCert.setValue(new byte[] {}); service.addCharacteristic(regulatoryCert); return service; } private BluetoothGattService createSakeService() { - BluetoothGattService service = new BluetoothGattService( - SAKE_SERVICE_UUID, - BluetoothGattService.SERVICE_TYPE_PRIMARY - ); + BluetoothGattService service = + new BluetoothGattService( + SAKE_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY); // Unknown Characteristic [N W] - BluetoothGattCharacteristic sakeChar = new BluetoothGattCharacteristic( - SAKE_CHARACTERISTIC_UUID, - BluetoothGattCharacteristic.PROPERTY_NOTIFY | BluetoothGattCharacteristic.PROPERTY_WRITE, - BluetoothGattCharacteristic.PERMISSION_WRITE - ); + BluetoothGattCharacteristic sakeChar = + new BluetoothGattCharacteristic( + SAKE_CHARACTERISTIC_UUID, + BluetoothGattCharacteristic.PROPERTY_NOTIFY + | BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE); // Add Client Characteristic Configuration descriptor - BluetoothGattDescriptor cccDescriptor = new BluetoothGattDescriptor( - CCC_DESCRIPTOR_UUID, - BluetoothGattDescriptor.PERMISSION_READ | BluetoothGattDescriptor.PERMISSION_WRITE - ); + BluetoothGattDescriptor cccDescriptor = + new BluetoothGattDescriptor( + CCC_DESCRIPTOR_UUID, + BluetoothGattDescriptor.PERMISSION_READ + | BluetoothGattDescriptor.PERMISSION_WRITE); sakeChar.addDescriptor(cccDescriptor); service.addCharacteristic(sakeChar); @@ -387,12 +411,13 @@ private void addNextService() { private void stopAdvertising() { if (advertiser != null) { try { - advertiser.stopAdvertising(new AdvertiseCallback() { - @Override - public void onStartSuccess(AdvertiseSettings settingsInEffect) { - // Not expected for stop - } - }); + advertiser.stopAdvertising( + new AdvertiseCallback() { + @Override + public void onStartSuccess(AdvertiseSettings settingsInEffect) { + // Not expected for stop + } + }); Log.d(TAG, "Advertising stopped"); } catch (SecurityException e) { Log.e(TAG, "Security exception when stopping advertising: " + e.getMessage()); @@ -413,203 +438,288 @@ private void stopGattServer() { } // GATT Server Callback - private final BluetoothGattServerCallback gattServerCallback = new BluetoothGattServerCallback() { - @Override - public void onConnectionStateChange(BluetoothDevice device, int status, int newState) { - super.onConnectionStateChange(device, status, newState); - - String deviceAddress = device != null ? device.getAddress() : "unknown"; - - if (newState == BluetoothProfile.STATE_CONNECTED) { - Log.i(TAG, "Device connected: " + deviceAddress); - Log.d(TAG, "Connection status: " + status); - - // Stop advertising when connected - stopAdvertising(); - - // Set security requirements - No Input No Output (Just Works) - /* - if (device != null) { - device.setPairingConfirmation(true); - Log.d(TAG, "Pairing confirmation set to true for: " + deviceAddress); + private final BluetoothGattServerCallback gattServerCallback = + new BluetoothGattServerCallback() { + @Override + public void onConnectionStateChange( + BluetoothDevice device, int status, int newState) { + super.onConnectionStateChange(device, status, newState); + + String deviceAddress = device != null ? device.getAddress() : "unknown"; + + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.i(TAG, "Device connected: " + deviceAddress); + Log.d(TAG, "Connection status: " + status); + + // Stop advertising when connected + stopAdvertising(); + + // Set security requirements - No Input No Output (Just Works) + /* + if (device != null) { + device.setPairingConfirmation(true); + Log.d(TAG, "Pairing confirmation set to true for: " + deviceAddress); + } + */ + + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.i(TAG, "Device disconnected: " + deviceAddress); + Log.d(TAG, "Disconnection status: " + status); + + // Restart advertising when disconnected + startAdvertising(); + } } - */ - - } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { - Log.i(TAG, "Device disconnected: " + deviceAddress); - Log.d(TAG, "Disconnection status: " + status); - // Restart advertising when disconnected - startAdvertising(); - } - } - - @Override - public void onServiceAdded(int status, BluetoothGattService service) { - super.onServiceAdded(status, service); - Log.d(TAG, "Service added: " + service.getUuid() + " status: " + status); - - if (status == BluetoothGatt.GATT_SUCCESS) { - Log.d(TAG, "Service added successfully: " + service.getUuid()); - addNextService(); - } else { - Log.e(TAG, "Failed to add service: " + service.getUuid() + " with status: " + status); - } - } - - @Override - public void onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattCharacteristic characteristic) { - super.onCharacteristicReadRequest(device, requestId, offset, characteristic); - - var uuid = characteristic.getUuid(); - Log.i(TAG, "Read request from: " + device.getAddress()); - Log.d(TAG, "Characteristic UUID: " + uuid); - Log.d(TAG, "Request ID: " + requestId + ", Offset: " + offset); - - try { - byte[] toSend = "DummyData".getBytes(StandardCharsets.UTF_8); - - if (uuid.equals(SOFTWARE_REVISION_UUID)) { - toSend = FAKE_APP_VER.getBytes(StandardCharsets.UTF_8); // might not be necessary? + @Override + public void onServiceAdded(int status, BluetoothGattService service) { + super.onServiceAdded(status, service); + Log.d(TAG, "Service added: " + service.getUuid() + " status: " + status); + + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, "Service added successfully: " + service.getUuid()); + addNextService(); + } else { + Log.e( + TAG, + "Failed to add service: " + + service.getUuid() + + " with status: " + + status); + } } - if (uuid.equals(SYSTEM_ID_UUID)) { - toSend = new byte[]{0, 0, 0, 0,0, 0, 0, 0}; + @Override + public void onCharacteristicReadRequest( + BluetoothDevice device, + int requestId, + int offset, + BluetoothGattCharacteristic characteristic) { + super.onCharacteristicReadRequest(device, requestId, offset, characteristic); + + var uuid = characteristic.getUuid(); + Log.i(TAG, "Read request from: " + device.getAddress()); + Log.d(TAG, "Characteristic UUID: " + uuid); + Log.d(TAG, "Request ID: " + requestId + ", Offset: " + offset); + + try { + byte[] toSend = "DummyData".getBytes(StandardCharsets.UTF_8); + + if (uuid.equals(SOFTWARE_REVISION_UUID)) { + toSend = + FAKE_APP_VER.getBytes( + StandardCharsets.UTF_8); // might not be necessary? + } + + if (uuid.equals(SYSTEM_ID_UUID)) { + toSend = new byte[] {0, 0, 0, 0, 0, 0, 0, 0}; + } + + if (uuid.equals(PNP_ID_UUID)) { + // https://btprodspecificationrefs.blob.core.windows.net/gatt-specification-supplement/GATT_Specification_Supplement.pdf + // vid source, vendor id, product id, product version, + toSend = + new byte[] { + 0, 0, 0, 0, 0, 0, 0, + }; + } + gattServer.sendResponse( + device, requestId, BluetoothGatt.GATT_SUCCESS, offset, toSend); + + Log.d(TAG, "Sent response: 0x " + bytesToHex(toSend)); + } catch (SecurityException e) { + Log.e(TAG, "Security exception in read request: " + e.getMessage()); + } } - if (uuid.equals(PNP_ID_UUID)) { - // https://btprodspecificationrefs.blob.core.windows.net/gatt-specification-supplement/GATT_Specification_Supplement.pdf - // vid source, vendor id, product id, product version, - toSend = new byte[]{0, 0, 0, 0,0, 0, 0, }; + @Override + public void onCharacteristicWriteRequest( + BluetoothDevice device, + int requestId, + BluetoothGattCharacteristic characteristic, + boolean preparedWrite, + boolean responseNeeded, + int offset, + byte[] value) { + super.onCharacteristicWriteRequest( + device, + requestId, + characteristic, + preparedWrite, + responseNeeded, + offset, + value); + + Log.i(TAG, "Write request from: " + device.getAddress()); + Log.d(TAG, "Characteristic UUID: " + characteristic.getUuid()); + Log.d(TAG, "Value: " + bytesToHex(value)); + Log.d( + TAG, + "Prepared write: " + + preparedWrite + + ", Response needed: " + + responseNeeded); + + if (characteristic.getUuid().equals(SAKE_CHARACTERISTIC_UUID)) { + sakeHandler.onWrite(value); + } + if (responseNeeded) { + try { + gattServer.sendResponse( + device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value); + } catch (SecurityException e) { + Log.e(TAG, "Security exception in write request: " + e.getMessage()); + } + } } - gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, toSend); - - Log.d(TAG, "Sent response: 0x " + bytesToHex(toSend)); - } catch (SecurityException e) { - Log.e(TAG, "Security exception in read request: " + e.getMessage()); - } - } - - @Override - public void onCharacteristicWriteRequest(BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) { - super.onCharacteristicWriteRequest(device, requestId, characteristic, preparedWrite, responseNeeded, offset, value); - Log.i(TAG, "Write request from: " + device.getAddress()); - Log.d(TAG, "Characteristic UUID: " + characteristic.getUuid()); - Log.d(TAG, "Value: " + bytesToHex(value)); - Log.d(TAG, "Prepared write: " + preparedWrite + ", Response needed: " + responseNeeded); - - if (characteristic.getUuid().equals(SAKE_CHARACTERISTIC_UUID)) { - sakeHandler.onWrite(value); - } - - if (responseNeeded) { - try { - gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value); - } catch (SecurityException e) { - Log.e(TAG, "Security exception in write request: " + e.getMessage()); + @Override + public void onDescriptorReadRequest( + BluetoothDevice device, + int requestId, + int offset, + BluetoothGattDescriptor descriptor) { + super.onDescriptorReadRequest(device, requestId, offset, descriptor); + + Log.i(TAG, "Descriptor read request from: " + device.getAddress()); + Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid()); + + try { + // For CCC descriptor, return current value (0 by default) + byte[] value = descriptor.getValue(); + if (value == null) { + value = new byte[] {0x00, 0x00}; // Default CCC value + } + gattServer.sendResponse( + device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value); + + Log.d(TAG, "Sent descriptor value: " + bytesToHex(value)); + } catch (SecurityException e) { + Log.e( + TAG, + "Security exception in descriptor read request: " + e.getMessage()); + } } - } - } - @Override - public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattDescriptor descriptor) { - super.onDescriptorReadRequest(device, requestId, offset, descriptor); - - Log.i(TAG, "Descriptor read request from: " + device.getAddress()); - Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid()); + @Override + public void onDescriptorWriteRequest( + BluetoothDevice device, + int requestId, + BluetoothGattDescriptor descriptor, + boolean preparedWrite, + boolean responseNeeded, + int offset, + byte[] value) { + super.onDescriptorWriteRequest( + device, + requestId, + descriptor, + preparedWrite, + responseNeeded, + offset, + value); + + Log.i(TAG, "Descriptor write request from: " + device.getAddress()); + Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid()); + Log.d(TAG, "Value: " + bytesToHex(value)); + + if (descriptor.getUuid().equals(CCC_DESCRIPTOR_UUID)) { + int cccValue = (value[1] << 8) | (value[0] & 0xFF); + + if ((cccValue & 0x0001) != 0) { + Log.i(TAG, "Client subscribed to NOTIFICATIONS"); + } + if ((cccValue & 0x0002) != 0) { + Log.i(TAG, "Client subscribed to INDICATIONS"); + } + if (cccValue == 0x0000) { + Log.i(TAG, "Client unsubscribed from notifications/indications"); + } + + boolean isSakeChar = + descriptor.getCharacteristic() != null + && SAKE_CHARACTERISTIC_UUID.equals( + descriptor.getCharacteristic().getUuid()); + if (isSakeChar) { + if ((cccValue & 0x0001) != 0) { + sakeHandler.onNotificationsEnabled(device); + } else if (cccValue == 0x0000) { + sakeHandler.onNotificationsDisabled(); + } + } + } - try { - // For CCC descriptor, return current value (0 by default) - byte[] value = descriptor.getValue(); - if (value == null) { - value = new byte[]{0x00, 0x00}; // Default CCC value + // Update descriptor value + descriptor.setValue(value); + + if (responseNeeded) { + try { + gattServer.sendResponse( + device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value); + } catch (SecurityException e) { + Log.e( + TAG, + "Security exception in descriptor write request: " + + e.getMessage()); + } + } } - gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value); - - Log.d(TAG, "Sent descriptor value: " + bytesToHex(value)); - } catch (SecurityException e) { - Log.e(TAG, "Security exception in descriptor read request: " + e.getMessage()); - } - } - - @Override - public void onDescriptorWriteRequest(BluetoothDevice device, int requestId, BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) { - super.onDescriptorWriteRequest(device, requestId, descriptor, preparedWrite, responseNeeded, offset, value); - - Log.i(TAG, "Descriptor write request from: " + device.getAddress()); - Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid()); - Log.d(TAG, "Value: " + bytesToHex(value)); - if (descriptor.getUuid().equals(CCC_DESCRIPTOR_UUID)) { - int cccValue = (value[1] << 8) | (value[0] & 0xFF); - - if ((cccValue & 0x0001) != 0) { - Log.i(TAG, "Client subscribed to NOTIFICATIONS"); - } - if ((cccValue & 0x0002) != 0) { - Log.i(TAG, "Client subscribed to INDICATIONS"); - } - if (cccValue == 0x0000) { - Log.i(TAG, "Client unsubscribed from notifications/indications"); + @Override + public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) { + super.onExecuteWrite(device, requestId, execute); + Log.d( + TAG, + "Execute write from: " + device.getAddress() + ", Execute: " + execute); } - boolean isSakeChar = descriptor.getCharacteristic() != null - && SAKE_CHARACTERISTIC_UUID.equals(descriptor.getCharacteristic().getUuid()); - if (isSakeChar) { - if ((cccValue & 0x0001) != 0) { - sakeHandler.onNotificationsEnabled(device); - } else if (cccValue == 0x0000) { - sakeHandler.onNotificationsDisabled(); - } + @Override + public void onNotificationSent(BluetoothDevice device, int status) { + super.onNotificationSent(device, status); + Log.d( + TAG, + "Notification sent to: " + device.getAddress() + ", Status: " + status); } - } - // Update descriptor value - descriptor.setValue(value); - - if (responseNeeded) { - try { - gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value); - } catch (SecurityException e) { - Log.e(TAG, "Security exception in descriptor write request: " + e.getMessage()); + @Override + public void onMtuChanged(BluetoothDevice device, int mtu) { + super.onMtuChanged(device, mtu); + Log.i( + TAG, + "MTU changed for device: " + device.getAddress() + ", New MTU: " + mtu); } - } - } - - @Override - public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) { - super.onExecuteWrite(device, requestId, execute); - Log.d(TAG, "Execute write from: " + device.getAddress() + ", Execute: " + execute); - } - @Override - public void onNotificationSent(BluetoothDevice device, int status) { - super.onNotificationSent(device, status); - Log.d(TAG, "Notification sent to: " + device.getAddress() + ", Status: " + status); - } - - @Override - public void onMtuChanged(BluetoothDevice device, int mtu) { - super.onMtuChanged(device, mtu); - Log.i(TAG, "MTU changed for device: " + device.getAddress() + ", New MTU: " + mtu); - } - - @Override - public void onPhyUpdate(BluetoothDevice device, int txPhy, int rxPhy, int status) { - super.onPhyUpdate(device, txPhy, rxPhy, status); - Log.d(TAG, "PHY update for device: " + device.getAddress() + - ", TX PHY: " + txPhy + ", RX PHY: " + rxPhy + ", Status: " + status); - } + @Override + public void onPhyUpdate(BluetoothDevice device, int txPhy, int rxPhy, int status) { + super.onPhyUpdate(device, txPhy, rxPhy, status); + Log.d( + TAG, + "PHY update for device: " + + device.getAddress() + + ", TX PHY: " + + txPhy + + ", RX PHY: " + + rxPhy + + ", Status: " + + status); + } - @Override - public void onPhyRead(BluetoothDevice device, int txPhy, int rxPhy, int status) { - super.onPhyRead(device, txPhy, rxPhy, status); - Log.d(TAG, "PHY read for device: " + device.getAddress() + - ", TX PHY: " + txPhy + ", RX PHY: " + rxPhy + ", Status: " + status); - } - }; + @Override + public void onPhyRead(BluetoothDevice device, int txPhy, int rxPhy, int status) { + super.onPhyRead(device, txPhy, rxPhy, status); + Log.d( + TAG, + "PHY read for device: " + + device.getAddress() + + ", TX PHY: " + + txPhy + + ", RX PHY: " + + rxPhy + + ", Status: " + + status); + } + }; // Utility method to convert bytes to hex string private static String bytesToHex(byte[] bytes) { @@ -625,10 +735,11 @@ private static String bytesToHex(byte[] bytes) { } // Security callback for pairing/bonding - private final BluetoothAdapter.LeScanCallback leScanCallback = new BluetoothAdapter.LeScanCallback() { - @Override - public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { - // Not used in peripheral mode, but kept for reference - } - }; + private final BluetoothAdapter.LeScanCallback leScanCallback = + new BluetoothAdapter.LeScanCallback() { + @Override + public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { + // Not used in peripheral mode, but kept for reference + } + }; } diff --git a/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java b/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java index 4f00a2f..5fa4553 100644 --- a/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java +++ b/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java @@ -1,17 +1,14 @@ package org.openminimed.pumpconnector; import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.Toast; import androidx.activity.EdgeToEdge; import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; -import android.view.View; -import android.widget.Button; -import android.widget.Toast; - -import org.openminimed.pumpconnector.BlePeripheralDevice; - public class MainActivity extends AppCompatActivity implements View.OnClickListener { @@ -22,17 +19,19 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); EdgeToEdge.enable(this); setContentView(R.layout.activity_main); - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { - Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); - return insets; - }); + ViewCompat.setOnApplyWindowInsetsListener( + findViewById(R.id.main), + (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding( + systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); this.ble = new BlePeripheralDevice(MainActivity.this); Button btn = (Button) findViewById(R.id.start_gatt); btn.setOnClickListener(MainActivity.this); - } @Override @@ -45,7 +44,5 @@ public void onClick(View v) { this.ble.start(); Toast.makeText(this, "Peripheral started!", Toast.LENGTH_SHORT).show(); } - } - } diff --git a/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java index 9224bc2..25b7e1d 100644 --- a/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java +++ b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java @@ -7,27 +7,26 @@ import android.os.Handler; import android.os.HandlerThread; import android.util.Log; - import org.openminimed.sake.Constants; import org.openminimed.sake.SakeServer; /** * BLE glue layer for the SAKE handshake. * - *

Owns a {@link SakeServer} pinned to the extracted-from-pump key database - * and translates between BLE GATT server events and handshake step calls. - * All SAKE work is serialized onto a dedicated {@link HandlerThread} so the - * binder thread that fires BLE callbacks is never blocked by crypto work.

+ *

Owns a {@link SakeServer} pinned to the extracted-from-pump key database and translates + * between BLE GATT server events and handshake step calls. All SAKE work is serialized onto a + * dedicated {@link HandlerThread} so the binder thread that fires BLE callbacks is never blocked by + * crypto work. + * + *

Wire protocol the pump expects, in order: * - *

Wire protocol the pump expects, in order:

*
    - *
  1. Pump subscribes to notifications on the SAKE characteristic. - * Handler emits twenty zero bytes as a wake-up notification.
  2. - *
  3. Pump writes its own twenty zero bytes as the matching wake-up. - * Handler feeds that to {@link SakeServer#handshake(byte[])}, gets - * msg0 back, notifies it to the pump.
  4. - *
  5. Pump writes msg1, msg3, msg5 in turn. Handler responds with - * msg2, msg4, and finally completes at stage 6.
  6. + *
  7. Pump subscribes to notifications on the SAKE characteristic. Handler emits twenty zero + * bytes as a wake-up notification. + *
  8. Pump writes its own twenty zero bytes as the matching wake-up. Handler feeds that to {@link + * SakeServer#handshake(byte[])}, gets msg0 back, notifies it to the pump. + *
  9. Pump writes msg1, msg3, msg5 in turn. Handler responds with msg2, msg4, and finally + * completes at stage 6. *
*/ public final class SakeHandler { @@ -53,64 +52,68 @@ public SakeHandler() { } /** - * Bind the GATT server and characteristic the handler will use to send - * SAKE notifications back to the pump. + * Bind the GATT server and characteristic the handler will use to send SAKE notifications back + * to the pump. */ - public void attach(BluetoothGattServer gattServer, - BluetoothGattCharacteristic characteristic) { + public void attach(BluetoothGattServer gattServer, BluetoothGattCharacteristic characteristic) { this.gattServer = gattServer; this.characteristic = characteristic; } /** - * Called from the GATT server callback when the pump subscribes to - * notifications on the SAKE characteristic. Emits a 20-byte wake-up frame. + * Called from the GATT server callback when the pump subscribes to notifications on the SAKE + * characteristic. Emits a 20-byte wake-up frame. */ public void onNotificationsEnabled(BluetoothDevice device) { - handler.post(() -> { - peer = device; - if (pumpSubscribed) { - return; - } - pumpSubscribed = true; - Log.i(TAG, "Pump subscribed to SAKE notifications; sending wake-up"); - sendNotification(WAKE_UP.clone()); - }); + handler.post( + () -> { + peer = device; + if (pumpSubscribed) { + return; + } + pumpSubscribed = true; + Log.i(TAG, "Pump subscribed to SAKE notifications; sending wake-up"); + sendNotification(WAKE_UP.clone()); + }); } /** Called from the GATT server callback when the pump unsubscribes. */ public void onNotificationsDisabled() { - handler.post(() -> { - pumpSubscribed = false; - Log.w(TAG, "Pump unsubscribed from SAKE notifications"); - }); + handler.post( + () -> { + pumpSubscribed = false; + Log.w(TAG, "Pump unsubscribed from SAKE notifications"); + }); } /** - * Called from the GATT server callback for every write on the SAKE - * characteristic. Drives the next handshake step and emits the response. + * Called from the GATT server callback for every write on the SAKE characteristic. Drives the + * next handshake step and emits the response. */ public void onWrite(byte[] value) { byte[] copy = value.clone(); - handler.post(() -> { - if (server.getStage() == HANDSHAKE_COMPLETE_STAGE) { - Log.w(TAG, "Ignoring write after handshake completion"); - return; - } - try { - byte[] response = server.handshake(copy); - if (response != null) { - sendNotification(response); - } else { - Log.i(TAG, "SAKE handshake complete"); - } - } catch (Exception e) { - Log.e(TAG, "SAKE handshake failed", e); - } - }); + handler.post( + () -> { + if (server.getStage() == HANDSHAKE_COMPLETE_STAGE) { + Log.w(TAG, "Ignoring write after handshake completion"); + return; + } + try { + byte[] response = server.handshake(copy); + if (response != null) { + sendNotification(response); + } else { + Log.i(TAG, "SAKE handshake complete"); + } + } catch (Exception e) { + Log.e(TAG, "SAKE handshake failed", e); + } + }); } - /** @return true once the handshake has reached stage 6. */ + /** + * @return true once the handshake has reached stage 6. + */ public boolean isHandshakeComplete() { return server.getStage() == HANDSHAKE_COMPLETE_STAGE; } diff --git a/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java b/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java index e6142ac..145359e 100644 --- a/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java +++ b/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java @@ -1,9 +1,9 @@ package org.openminimed.pumpconnector; -import org.junit.Test; - import static org.junit.Assert.*; +import org.junit.Test; + /** * Example local unit test, which will execute on the development machine (host). * diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 273bbb9..757c4c3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,8 @@ appcompat = "1.7.1" material = "1.13.0" activity = "1.12.2" constraintlayout = "2.2.1" +spotless = "6.25.0" +googleJavaFormat = "1.22.0" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -19,4 +21,5 @@ constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayo [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } From 8dc48f13f2980df7bc7f0e2ce2aba6fccb09120f Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 12:07:05 -0400 Subject: [PATCH 6/6] Address CodeRabbit findings on the Android wiring SakeHandler: - handshakeComplete is now a volatile flag updated on the handler thread, so isHandshakeComplete() reads consistent state across threads instead of touching SakeServer directly from the caller's thread. - closed is a volatile flag set by close(); all on* hooks short-circuit after close so callers cannot post work to a stopped looper. - close() now drains the handler queue, posts a final cleanup runnable that drops the gattServer / characteristic / peer references, and then quitSafely()s the worker thread. - attach() requires non-null arguments. Failing fast is preferable to the previous behaviour where sendNotification would silently log and return. - A re-subscribe to notifications mid-handshake now rebuilds the SakeServer so the new wake-up starts a fresh handshake instead of pushing zeros into a stale stage. - sendNotification logs the frame length only. The full payload was unnecessary at debug level and could leak protocol traffic into bug reports captured from production builds. BlePeripheralDevice (three pre-existing bugs surfaced while CodeRabbit was reviewing the SAKE wiring): - AdvertiseCallback is now stored as a field and reused by stopAdvertising(). The previous code passed a fresh anonymous callback to stopAdvertising(), which the Android BluetoothLeAdvertiser API silently rejects because it requires the same instance used to start advertising. Result: stopAdvertising() was a no-op. - onCharacteristicReadRequest defaults to the characteristic's stored value rather than "DummyData". The values set in createDeviceInfoService (manufacturer / model / serial / hardware revision / regulatory cert) were being silently discarded. - The CCC descriptor value parser bounds-checks before reading and masks the high byte with 0xFF so that a value like 0x80 doesn't sign-extend through (value[1] << 8). On malformed inputs the server now responds with GATT_INVALID_ATTRIBUTE_LENGTH instead of crashing with ArrayIndexOutOfBoundsException. app/build.gradle.kts: - The org.openminimed:lib:0.1.0-SNAPSHOT coordinate now carries a comment explaining that it is a placeholder substituted by Gradle's composite-build mechanism. The artifact is never resolved against a Maven repository so SNAPSHOT-vs-release semantics do not apply. --- app/build.gradle.kts | 3 + .../pumpconnector/BlePeripheralDevice.java | 56 +++++++++++++------ .../pumpconnector/SakeHandler.java | 55 +++++++++++++----- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b917f13..87b923f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,9 @@ spotless { } dependencies { + // Placeholder coordinates for the JavaSake :lib subproject. Gradle substitutes the local + // composite build (declared in settings.gradle.kts) at configuration time, so this never + // actually resolves against a Maven repository. implementation("org.openminimed:lib:0.1.0-SNAPSHOT") implementation(libs.appcompat) diff --git a/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java b/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java index 07eaa67..f82b32d 100644 --- a/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java +++ b/app/src/main/java/org/openminimed/pumpconnector/BlePeripheralDevice.java @@ -67,6 +67,7 @@ public class BlePeripheralDevice { private BluetoothGattServer gattServer; private Queue addServiceQueue; private BluetoothGattCharacteristic sakeCharacteristic; + private AdvertiseCallback advertiseCallback; private final SakeHandler sakeHandler = new SakeHandler(); // Permission check method @@ -178,7 +179,7 @@ private void startAdvertising() { AdvertiseData data = createAdvertisementData(); - AdvertiseCallback callback = + advertiseCallback = new AdvertiseCallback() { @Override public void onStartSuccess(AdvertiseSettings settingsInEffect) { @@ -192,7 +193,7 @@ public void onStartFailure(int errorCode) { } }; - advertiser.startAdvertising(settings, data, callback); + advertiser.startAdvertising(settings, data, advertiseCallback); Log.d(TAG, "Advertising started"); } @@ -409,19 +410,17 @@ private void addNextService() { } private void stopAdvertising() { - if (advertiser != null) { - try { - advertiser.stopAdvertising( - new AdvertiseCallback() { - @Override - public void onStartSuccess(AdvertiseSettings settingsInEffect) { - // Not expected for stop - } - }); - Log.d(TAG, "Advertising stopped"); - } catch (SecurityException e) { - Log.e(TAG, "Security exception when stopping advertising: " + e.getMessage()); - } + if (advertiser == null || advertiseCallback == null) { + return; + } + try { + // Must pass the same callback instance startAdvertising() used. + advertiser.stopAdvertising(advertiseCallback); + Log.d(TAG, "Advertising stopped"); + } catch (SecurityException e) { + Log.e(TAG, "Security exception when stopping advertising: " + e.getMessage()); + } finally { + advertiseCallback = null; } } @@ -503,7 +502,11 @@ public void onCharacteristicReadRequest( Log.d(TAG, "Request ID: " + requestId + ", Offset: " + offset); try { - byte[] toSend = "DummyData".getBytes(StandardCharsets.UTF_8); + byte[] stored = characteristic.getValue(); + byte[] toSend = + stored != null + ? stored + : "DummyData".getBytes(StandardCharsets.UTF_8); if (uuid.equals(SOFTWARE_REVISION_UUID)) { toSend = @@ -625,7 +628,26 @@ public void onDescriptorWriteRequest( Log.d(TAG, "Value: " + bytesToHex(value)); if (descriptor.getUuid().equals(CCC_DESCRIPTOR_UUID)) { - int cccValue = (value[1] << 8) | (value[0] & 0xFF); + if (value == null || value.length < 2) { + Log.w(TAG, "CCC write with malformed value, ignoring"); + if (responseNeeded) { + try { + gattServer.sendResponse( + device, + requestId, + BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH, + offset, + null); + } catch (SecurityException e) { + Log.e( + TAG, + "Security exception in CCC malformed-value response: " + + e.getMessage()); + } + } + return; + } + int cccValue = ((value[1] & 0xFF) << 8) | (value[0] & 0xFF); if ((cccValue & 0x0001) != 0) { Log.i(TAG, "Client subscribed to NOTIFICATIONS"); diff --git a/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java index 25b7e1d..6476c5b 100644 --- a/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java +++ b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java @@ -7,6 +7,7 @@ import android.os.Handler; import android.os.HandlerThread; import android.util.Log; +import java.util.Objects; import org.openminimed.sake.Constants; import org.openminimed.sake.SakeServer; @@ -28,6 +29,10 @@ *
  • Pump writes msg1, msg3, msg5 in turn. Handler responds with msg2, msg4, and finally * completes at stage 6. * + * + *

    The handler is reusable across pump disconnect/reconnect cycles. A re-subscribe before + * completion is treated as an abort: the {@link SakeServer} is rebuilt from scratch and the + * handshake starts fresh with a new wake-up frame. */ public final class SakeHandler { @@ -35,15 +40,18 @@ public final class SakeHandler { private static final int HANDSHAKE_COMPLETE_STAGE = 6; private static final byte[] WAKE_UP = new byte[20]; - private final SakeServer server; private final HandlerThread thread; private final Handler handler; + private SakeServer server; private BluetoothGattServer gattServer; private BluetoothGattCharacteristic characteristic; private BluetoothDevice peer; private boolean pumpSubscribed; + private volatile boolean handshakeComplete; + private volatile boolean closed; + public SakeHandler() { this.server = new SakeServer(Constants.KEYDB_PUMP_EXTRACTED); this.thread = new HandlerThread("sake-handler"); @@ -53,9 +61,11 @@ public SakeHandler() { /** * Bind the GATT server and characteristic the handler will use to send SAKE notifications back - * to the pump. + * to the pump. Must be called before any of the {@code on*} hooks. */ public void attach(BluetoothGattServer gattServer, BluetoothGattCharacteristic characteristic) { + Objects.requireNonNull(gattServer, "gattServer"); + Objects.requireNonNull(characteristic, "characteristic"); this.gattServer = gattServer; this.characteristic = characteristic; } @@ -63,14 +73,24 @@ public void attach(BluetoothGattServer gattServer, BluetoothGattCharacteristic c /** * Called from the GATT server callback when the pump subscribes to notifications on the SAKE * characteristic. Emits a 20-byte wake-up frame. + * + *

    If the handshake has not completed and the pump resubscribes (e.g. after a transient + * disconnect), the {@link SakeServer} is reset so the new wake-up starts a fresh handshake. */ public void onNotificationsEnabled(BluetoothDevice device) { + if (closed) { + return; + } handler.post( () -> { peer = device; if (pumpSubscribed) { return; } + if (!handshakeComplete && server.getStage() != 0) { + Log.w(TAG, "Pump resubscribed mid-handshake; restarting SAKE state"); + server = new SakeServer(Constants.KEYDB_PUMP_EXTRACTED); + } pumpSubscribed = true; Log.i(TAG, "Pump subscribed to SAKE notifications; sending wake-up"); sendNotification(WAKE_UP.clone()); @@ -79,6 +99,9 @@ public void onNotificationsEnabled(BluetoothDevice device) { /** Called from the GATT server callback when the pump unsubscribes. */ public void onNotificationsDisabled() { + if (closed) { + return; + } handler.post( () -> { pumpSubscribed = false; @@ -91,10 +114,13 @@ public void onNotificationsDisabled() { * next handshake step and emits the response. */ public void onWrite(byte[] value) { + if (closed) { + return; + } byte[] copy = value.clone(); handler.post( () -> { - if (server.getStage() == HANDSHAKE_COMPLETE_STAGE) { + if (handshakeComplete) { Log.w(TAG, "Ignoring write after handshake completion"); return; } @@ -103,6 +129,7 @@ public void onWrite(byte[] value) { if (response != null) { sendNotification(response); } else { + handshakeComplete = true; Log.i(TAG, "SAKE handshake complete"); } } catch (Exception e) { @@ -115,11 +142,19 @@ public void onWrite(byte[] value) { * @return true once the handshake has reached stage 6. */ public boolean isHandshakeComplete() { - return server.getStage() == HANDSHAKE_COMPLETE_STAGE; + return handshakeComplete; } - /** Stop the worker thread and release resources. */ + /** Stop the worker thread and release resources. Subsequent {@code on*} calls become no-ops. */ public void close() { + closed = true; + handler.removeCallbacksAndMessages(null); + handler.post( + () -> { + gattServer = null; + characteristic = null; + peer = null; + }); thread.quitSafely(); } @@ -136,17 +171,9 @@ private void sendNotification(byte[] data) { characteristic.setValue(data); gattServer.notifyCharacteristicChanged(peer, characteristic, false); } - Log.d(TAG, "Notified SAKE bytes: " + bytesToHex(data)); + Log.d(TAG, "Notified SAKE frame (" + data.length + " bytes)"); } catch (SecurityException e) { Log.e(TAG, "Security exception sending SAKE notification", e); } } - - private static String bytesToHex(byte[] bytes) { - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) { - sb.append(String.format("%02x", b & 0xFF)); - } - return sb.toString(); - } }