diff --git a/.github/workflows/device-test.yml b/.github/workflows/device-test.yml index c5dd6ed..3ac824a 100644 --- a/.github/workflows/device-test.yml +++ b/.github/workflows/device-test.yml @@ -113,9 +113,23 @@ jobs: JAVA_HOME: ${{ env.JAVA_HOME_11_X64 }} run: | export JAVA_HOME="$JAVA_HOME_11_X64" + # CN1's cn1:build for Android forks a Gradle 8 build which + # requires Java 17. The plugin reads JAVA17_HOME (uppercase, + # no _X64 suffix); without it the step fails with + # "must set the JAVA17_HOME environment variable". + export JAVA17_HOME="$JAVA_HOME_17_X64" + # Bound the heap so the inner Maven Invoker fork inside cn1:build + # doesn't OOM in VM init on a memory-constrained runner. + export MAVEN_OPTS="-Xms256m -Xmx1g" export PATH="$JAVA_HOME/bin:$PATH" - mvn -DskipTests -Dcodename1.platform=android install - mvn -pl BTDemo -am cn1:build -DskipTests -Dcodename1.platform=android -Dcodename1.buildTarget=android-source -Dopen=false + mvn -DskipTests -DskipNativeBleHelper=true -Dcodename1.platform=android install + # Use the fully-qualified plugin coordinate instead of the cn1: + # prefix. After the install step writes to the local m2 group + # metadata for com.codenameone, Maven can resolve the cn1 + # prefix from the local cache without round-tripping to Central + # — and the local metadata may not list the codenameone-maven + # plugin's prefix, causing "No plugin found for prefix 'cn1'". + mvn -pl BTDemo -am com.codenameone:codenameone-maven-plugin:7.0.243:build -DskipTests -DskipNativeBleHelper=true -Dcodename1.platform=android -Dcodename1.buildTarget=android-source -Dopen=false - name: Inject DeviceTestRunner into the generated project env: diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index c33a528..bf39597 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -20,15 +20,18 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up JDK 11 - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: java-version: '11' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Codename One Build Client run: | mkdir -p ~/.codenameone wget https://github.com/codenameone/CodenameOne/raw/refs/heads/master/maven/CodeNameOneBuildClient.jar -O ~/.codenameone/CodeNameOneBuildClient.jar - name: Build with Maven - run: mvn install + # -DskipNativeBleHelper=true skips the optional Rust helper build on + # this generic Linux job; the dedicated native-ble-helper matrix in + # native-bluetooth-tests.yml is what verifies it. + run: mvn -B -DskipNativeBleHelper=true install diff --git a/.github/workflows/native-bluetooth-tests.yml b/.github/workflows/native-bluetooth-tests.yml index 793ec71..5e9f0ae 100644 --- a/.github/workflows/native-bluetooth-tests.yml +++ b/.github/workflows/native-bluetooth-tests.yml @@ -39,11 +39,17 @@ jobs: - name: Install Xvfb (CN1 TestRunner triggers AWT screen device init) run: sudo apt-get update && sudo apt-get install -y xvfb - - name: Install library artifacts locally - run: mvn -DskipTests -Dcodename1.platform=javase install + - name: Install cn1lib artifacts locally + # -DskipNativeBleHelper=true: simulator-tests exercise the in-memory + # backend, not the Rust helper, so we skip the cargo build (this + # runner doesn't have libdbus-1-dev or rust installed anyway). + run: mvn -B -DskipTests -DskipNativeBleHelper=true -Dcodename1.platform=javase install - name: Run BTDemo CN1 UnitTest suite against the JavaSE simulator - run: xvfb-run --auto-servernum mvn -pl BTDemo cn1:test -Dcodename1.platform=javase + # Fully-qualified coordinate avoids intermittent "No plugin found + # for prefix 'cn1'" after the install step writes local m2 group + # metadata for com.codenameone (see run-android-native-tests.sh). + run: xvfb-run --auto-servernum mvn -B -pl BTDemo com.codenameone:codenameone-maven-plugin:7.0.243:test -Dcodename1.platform=javase - name: Upload simulator JUnit reports if: always() @@ -57,6 +63,12 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install ripgrep + # macos-14 runner image doesn't ship ripgrep by default and + # run-ios-native-tests.sh uses `rg` for pbxproj edits + the + # Cordova guard. + run: brew install ripgrep + - name: Set up Java 11 uses: actions/setup-java@v4 with: @@ -83,6 +95,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Enable KVM for android-emulator-runner + # ubuntu-latest stopped granting /dev/kvm to the runner user by + # default; the emulator-runner action aborts with "This user + # doesn't have permissions to use KVM" without this udev rule. + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Set up Java 17 (required for Android SDK cmdline tools) uses: actions/setup-java@v4 with: @@ -127,7 +148,89 @@ jobs: profile: pixel_6 disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim - script: JAVA_HOME="$JAVA_HOME_11_X64" ./scripts/native-tests/run-android-native-tests.sh + # MAVEN_OPTS is bounded because cn1:build forks a sub-Maven for + # gradle; on a hosted runner with the Android emulator already + # taking ~3-4GB of RAM, the fork's default JVM heap exceeds + # what's free and the sub-process dies in VM init. + script: JAVA_HOME="$JAVA_HOME_11_X64" JAVA17_HOME="$JAVA_HOME_17_X64" MAVEN_OPTS="-Xms256m -Xmx1g" ./scripts/native-tests/run-android-native-tests.sh + + native-ble-helper: + # Compile-and-smoke coverage for the desktop NativeBleBackend on macOS, + # Linux and Windows. Hosted runners typically don't expose a Bluetooth + # adapter, so we cannot exercise real BLE I/O — what we verify is: + # 1. The Rust helper builds with the runner's stable toolchain. + # 2. The matching Maven profile packages it under + # com/codename1/bluetoothle/native// inside cn1-bluetooth-javase-*.jar. + # 3. The helper starts, emits a `stateChanged` event (most likely + # `unsupported` on a runner with no adapter, or `poweredOn` if one + # somehow surfaces), and exits cleanly on shutdown. + # Physical scan/connect/read/write coverage lives in maintainer-driven + # workflows triggered on real hardware (see scripts/native-tests/). + strategy: + fail-fast: false + matrix: + os: [macos-14, ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install BlueZ headers (Linux only) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev pkg-config + + - name: Set up Java 11 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '11' + + - name: Setup Codename One Build Client + shell: bash + run: | + mkdir -p "$HOME/.codenameone" + curl -fsSL "https://github.com/codenameone/CodenameOne/raw/refs/heads/master/maven/CodeNameOneBuildClient.jar" \ + -o "$HOME/.codenameone/CodeNameOneBuildClient.jar" + + - name: Build cn1lib (triggers OS-matching native-ble-helper profile) + shell: bash + run: mvn -B -DskipTests -Dcodename1.platform=javase install + + - name: Verify helper binary was packaged into the javase jar + shell: bash + run: | + set -euo pipefail + case "${{ runner.os }}" in + macOS) PATTERN='^com/codename1/bluetoothle/native/macos/cn1-ble-helper$' ;; + Linux) PATTERN='^com/codename1/bluetoothle/native/linux/cn1-ble-helper$' ;; + Windows) PATTERN='^com/codename1/bluetoothle/native/windows/cn1-ble-helper\.exe$' ;; + esac + # Filter out -sources / -javadoc classifiers — `cn1-bluetooth-javase-*.jar` + # would otherwise expand to three jars and `jar tf` would silently read + # only the first one (alphabetically the javadoc jar, which lacks the + # native helper resource), making this check fail spuriously. + JAR=$(ls javase/target/cn1-bluetooth-javase-*.jar 2>/dev/null \ + | grep -Ev -- '-(sources|javadoc)\.jar$' \ + | head -n1) + if [ -z "$JAR" ]; then + echo "No main javase jar found under javase/target." >&2 + ls -la javase/target/ || true + exit 1 + fi + echo "Inspecting $JAR for pattern: $PATTERN" + if ! jar tf "$JAR" | grep -qE "$PATTERN"; then + echo "Helper resource not found in jar. Contents (filtered):" >&2 + jar tf "$JAR" | grep -E 'native|simulator-hooks' >&2 || true + exit 1 + fi + + - name: Smoke-test helper executable + shell: bash + run: ./scripts/native-tests/run-native-ble-helper-smoke.sh # Layer 3 (real Android end-to-end against an actual GATT peripheral) lives # in .github/workflows/device-test.yml. We tried Bumble + netsim on hosted diff --git a/BTDemo/src/test/java/com/codename1/btle/BluetoothSimulatorHooksTest.java b/BTDemo/src/test/java/com/codename1/btle/BluetoothSimulatorHooksTest.java new file mode 100644 index 0000000..8dda92f --- /dev/null +++ b/BTDemo/src/test/java/com/codename1/btle/BluetoothSimulatorHooksTest.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.btle; + +import com.codename1.bluetoothle.Bluetooth; +import com.codename1.bluetoothle.BluetoothSimulator; +import com.codename1.testing.TestUtils; +import com.codename1.ui.CN; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Exercises the cn1-bluetooth simulator hooks through the cross-platform + * {@code CN.execute("bluetooth:itemN")} URL-style entry point — i.e., the + * same way any CN1 UnitTest living in a cn1-bluetooth-using app's + * {@code common/} project would drive them. The test imports nothing from + * the JavaSE port and does no reflection; if it ever needs to reach into + * {@code com.codename1.impl.javase.*} that's a regression. + * + *

Item indices correspond to the position in + * {@code simulator-hooks.properties}: + *

+ *   item1 = toggleAdapter
+ *   item2 = addDemoPeripheral
+ *   item3 = disconnectAll
+ *   item4 = pushDemoNotification
+ *   item5 = clearPeripherals
+ *   item6 = switchToNativeBle    (UI-only here)
+ *   item7 = switchToSimulator    (UI-only here)
+ *   item8 = primeReadFailure     (API-only — no menu label)
+ * 
+ */ +public class BluetoothSimulatorHooksTest extends AbstractBluetoothSimulatorTest { + + @Override + public boolean runTest() throws Exception { + // Hooks are only callable through CN.execute when the JavaSE port + // intercepts the URL — a feature that ships in CN1 8.x. On the + // released 7.x line CN.canExecute returns false/null for these + // urls and the cn1lib's simulator-hooks.properties is dormant. + // Skip the suite cleanly in that case so the test passes on both + // the current release and future versions that activate the + // mechanism. The label-bearing hooks still appear in the + // simulator's menu once the framework support lands; the + // properties file ships ready. + if (!Boolean.TRUE.equals(CN.canExecute("bluetooth:item1"))) { + return true; + } + + verifyToggleAdapterFlipsState(); + verifyClearPeripheralsRemovesAll(); + verifyAddDemoPeripheralRegistersPeripheral(); + verifyDisconnectAllClosesActiveConnection(); + verifyPushDemoNotificationDeliversToSubscriber(); + verifyApiOnlyHookPrimesScriptedFailure(); + return true; + } + + private void verifyToggleAdapterFlipsState() { + BluetoothSimulator.setEnabled(false); + CN.execute("bluetooth:item1"); // toggleAdapter + TestUtils.assertTrue(BluetoothSimulator.isEnabled(), "first toggle should turn adapter ON"); + CN.execute("bluetooth:item1"); + TestUtils.assertFalse(BluetoothSimulator.isEnabled(), "second toggle should turn adapter OFF"); + } + + private void verifyClearPeripheralsRemovesAll() { + // prepare() registers the default peripheral, so the simulator + // starts non-empty. + TestUtils.assertTrue(BluetoothSimulator.registeredPeripheralCount() >= 1, + "prepare() should have registered the default peripheral"); + CN.execute("bluetooth:item5"); // clearPeripherals + TestUtils.assertEqual(0, BluetoothSimulator.registeredPeripheralCount(), + "clearPeripherals should leave the simulator empty"); + } + + private void verifyAddDemoPeripheralRegistersPeripheral() { + BluetoothSimulator.clearPeripherals(); + CN.execute("bluetooth:item2"); // addDemoPeripheral + TestUtils.assertTrue( + BluetoothSimulator.isPeripheralRegistered(DEVICE_ADDRESS), + "addDemoPeripheral should register the demo MAC"); + TestUtils.assertEqual(1, BluetoothSimulator.registeredPeripheralCount(), + "exactly one peripheral after addDemoPeripheral on a cleared simulator"); + } + + private void verifyDisconnectAllClosesActiveConnection() throws Exception { + initEnabled(); + connectAndDiscover(); + + TestUtils.assertTrue(bt.isConnected(DEVICE_ADDRESS), + "precondition: connectAndDiscover should leave the peripheral connected"); + + CN.execute("bluetooth:item3"); // disconnectAll + + // Disconnect is dispatched asynchronously via the simulator's scheduler; + // poll isConnected() (which IS synchronous) instead of racing a listener. + long deadline = System.currentTimeMillis() + 2000; + while (System.currentTimeMillis() < deadline && bt.isConnected(DEVICE_ADDRESS)) { + Thread.sleep(20); + } + TestUtils.assertFalse(bt.isConnected(DEVICE_ADDRESS), + "disconnectAll should drop the active connection within 2s"); + } + + private void verifyPushDemoNotificationDeliversToSubscriber() throws Exception { + // Fresh state: enable, add demo peripheral, connect, discover, subscribe. + BluetoothSimulator.reset(); + BluetoothSimulator.setCallbackLatencyMillis(2); + BluetoothSimulator.setHasPermission(true); + BluetoothSimulator.setEnabled(true); + CN.execute("bluetooth:item2"); // addDemoPeripheral + bt.initialize(true, false, "test"); + if (!bt.isEnabled()) { + bt.enable(); + } + + final CountDownLatch connected = new CountDownLatch(1); + bt.connect(evt -> { + Map m = (Map) evt.getSource(); + if ("connected".equals(m.get("status"))) { + connected.countDown(); + } + }, DEVICE_ADDRESS); + TestUtils.assertTrue(connected.await(2, TimeUnit.SECONDS), "connect callback should fire"); + + final CountDownLatch discovered = new CountDownLatch(1); + bt.discover(evt -> discovered.countDown(), DEVICE_ADDRESS); + TestUtils.assertTrue(discovered.await(2, TimeUnit.SECONDS), "discover callback should fire"); + + final CountDownLatch notified = new CountDownLatch(1); + bt.subscribe(evt -> { + Map m = (Map) evt.getSource(); + // Subscribe listeners receive both the initial confirm and each + // subsequent notification; we only count the notification with a + // value payload. + if (m.get("value") != null) { + notified.countDown(); + } + }, DEVICE_ADDRESS, SERVICE_UUID, CHAR_NOTIFY_UUID); + + // Give the subscribe handshake a moment to land before pushing. + Thread.sleep(50); + + CN.execute("bluetooth:item4"); // pushDemoNotification + + TestUtils.assertTrue(notified.await(2, TimeUnit.SECONDS), + "pushDemoNotification should deliver a payload to the subscriber"); + } + + /** + * Covers the label-less hook path. {@code item8} is declared in + * {@code simulator-hooks.properties} without a {@code label8}, so it's + * invisible in the menu but still callable via {@code CN.execute}. + * After it fires, the next read against the demo peripheral's read + * characteristic surfaces a scripted error to the Bluetooth API listener. + */ + private void verifyApiOnlyHookPrimesScriptedFailure() throws Exception { + BluetoothSimulator.reset(); + BluetoothSimulator.setCallbackLatencyMillis(2); + BluetoothSimulator.setHasPermission(true); + BluetoothSimulator.setEnabled(true); + CN.execute("bluetooth:item2"); // addDemoPeripheral + + Bluetooth bt = new Bluetooth(); + bt.initialize(true, false, "test"); + if (!bt.isEnabled()) { + bt.enable(); + } + + final CountDownLatch connected = new CountDownLatch(1); + bt.connect(evt -> { + Map m = (Map) evt.getSource(); + if ("connected".equals(m.get("status"))) { + connected.countDown(); + } + }, DEVICE_ADDRESS); + TestUtils.assertTrue(connected.await(2, TimeUnit.SECONDS)); + + // Prime via the label-less API hook (item8). + CN.execute("bluetooth:item8"); + + final CountDownLatch readDone = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + bt.read(evt -> { + Map m = (Map) evt.getSource(); + Object err = m.get("error"); + if (err != null) { + errorRef.set(err.toString()); + } + readDone.countDown(); + }, DEVICE_ADDRESS, SERVICE_UUID, CHAR_READ_UUID); + + TestUtils.assertTrue(readDone.await(2, TimeUnit.SECONDS), + "read callback should fire even on scripted failure"); + TestUtils.assertNotNull(errorRef.get(), + "primed read should produce an error payload"); + } +} diff --git a/ios/src/main/objectivec/BluetoothLePlugin.h b/ios/src/main/objectivec/BluetoothLePlugin.h index 789cc08..e14cb08 100644 --- a/ios/src/main/objectivec/BluetoothLePlugin.h +++ b/ios/src/main/objectivec/BluetoothLePlugin.h @@ -25,53 +25,53 @@ CBCharacteristic *currentWriteCharacteristic; } -- (void)initialize:(CDVInvokedUrlCommand *)command; -- (void)enable:(CDVInvokedUrlCommand *)command; -- (void)disable:(CDVInvokedUrlCommand *)command; -- (void)startScan:(CDVInvokedUrlCommand *)command; -- (void)stopScan:(CDVInvokedUrlCommand *)command; -- (void)retrieveConnected:(CDVInvokedUrlCommand *)command; -- (void)bond:(CDVInvokedUrlCommand *)command; -- (void)unbond:(CDVInvokedUrlCommand *)command; -- (void)connect:(CDVInvokedUrlCommand *)command; -- (void)reconnect:(CDVInvokedUrlCommand *)command; -- (void)disconnect:(CDVInvokedUrlCommand *)command; -- (void)close:(CDVInvokedUrlCommand *)command; -- (void)discover:(CDVInvokedUrlCommand *)command; -- (void)services:(CDVInvokedUrlCommand *)command; -- (void)characteristics:(CDVInvokedUrlCommand *)command; -- (void)descriptors:(CDVInvokedUrlCommand *)command; -- (void)read:(CDVInvokedUrlCommand *)command; -- (void)subscribe:(CDVInvokedUrlCommand *)command; -- (void)unsubscribe:(CDVInvokedUrlCommand *)command; -- (void)write:(CDVInvokedUrlCommand *)command; -- (void)writeQ:(CDVInvokedUrlCommand *)command; -- (void)readDescriptor:(CDVInvokedUrlCommand *)command; -- (void)writeDescriptor:(CDVInvokedUrlCommand *)command; -- (void)rssi:(CDVInvokedUrlCommand *)command; -- (void)mtu:(CDVInvokedUrlCommand *)command; -- (void)requestConnectionPriority:(CDVInvokedUrlCommand *)command; -- (void)isInitialized:(CDVInvokedUrlCommand *)command; -- (void)isEnabled:(CDVInvokedUrlCommand *)command; -- (void)isScanning:(CDVInvokedUrlCommand *)command; -- (void)isBonded:(CDVInvokedUrlCommand *)command; -- (void)wasConnected:(CDVInvokedUrlCommand *)command; -- (void)isConnected:(CDVInvokedUrlCommand *)command; -- (void)isDiscovered:(CDVInvokedUrlCommand *)command; -- (void)hasPermission:(CDVInvokedUrlCommand *)command; -- (void)requestPermission:(CDVInvokedUrlCommand *)command; -- (void)isLocationEnabled:(CDVInvokedUrlCommand *)command; -- (void)requestLocation:(CDVInvokedUrlCommand *)command; -- (void)retrievePeripheralsByAddress:(CDVInvokedUrlCommand *)command; +- (void)cn1_initialize:(CDVInvokedUrlCommand *)command; +- (void)cn1_enable:(CDVInvokedUrlCommand *)command; +- (void)cn1_disable:(CDVInvokedUrlCommand *)command; +- (void)cn1_startScan:(CDVInvokedUrlCommand *)command; +- (void)cn1_stopScan:(CDVInvokedUrlCommand *)command; +- (void)cn1_retrieveConnected:(CDVInvokedUrlCommand *)command; +- (void)cn1_bond:(CDVInvokedUrlCommand *)command; +- (void)cn1_unbond:(CDVInvokedUrlCommand *)command; +- (void)cn1_connect:(CDVInvokedUrlCommand *)command; +- (void)cn1_reconnect:(CDVInvokedUrlCommand *)command; +- (void)cn1_disconnect:(CDVInvokedUrlCommand *)command; +- (void)cn1_close:(CDVInvokedUrlCommand *)command; +- (void)cn1_discover:(CDVInvokedUrlCommand *)command; +- (void)cn1_services:(CDVInvokedUrlCommand *)command; +- (void)cn1_characteristics:(CDVInvokedUrlCommand *)command; +- (void)cn1_descriptors:(CDVInvokedUrlCommand *)command; +- (void)cn1_read:(CDVInvokedUrlCommand *)command; +- (void)cn1_subscribe:(CDVInvokedUrlCommand *)command; +- (void)cn1_unsubscribe:(CDVInvokedUrlCommand *)command; +- (void)cn1_write:(CDVInvokedUrlCommand *)command; +- (void)cn1_writeQ:(CDVInvokedUrlCommand *)command; +- (void)cn1_readDescriptor:(CDVInvokedUrlCommand *)command; +- (void)cn1_writeDescriptor:(CDVInvokedUrlCommand *)command; +- (void)cn1_rssi:(CDVInvokedUrlCommand *)command; +- (void)cn1_mtu:(CDVInvokedUrlCommand *)command; +- (void)cn1_requestConnectionPriority:(CDVInvokedUrlCommand *)command; +- (void)cn1_isInitialized:(CDVInvokedUrlCommand *)command; +- (void)cn1_isEnabled:(CDVInvokedUrlCommand *)command; +- (void)cn1_isScanning:(CDVInvokedUrlCommand *)command; +- (void)cn1_isBonded:(CDVInvokedUrlCommand *)command; +- (void)cn1_wasConnected:(CDVInvokedUrlCommand *)command; +- (void)cn1_isConnected:(CDVInvokedUrlCommand *)command; +- (void)cn1_isDiscovered:(CDVInvokedUrlCommand *)command; +- (void)cn1_hasPermission:(CDVInvokedUrlCommand *)command; +- (void)cn1_requestPermission:(CDVInvokedUrlCommand *)command; +- (void)cn1_isLocationEnabled:(CDVInvokedUrlCommand *)command; +- (void)cn1_requestLocation:(CDVInvokedUrlCommand *)command; +- (void)cn1_retrievePeripheralsByAddress:(CDVInvokedUrlCommand *)command; -- (void)initializePeripheral:(CDVInvokedUrlCommand *)command; -- (void)addService:(CDVInvokedUrlCommand *)command; -- (void)removeService:(CDVInvokedUrlCommand *)command; -- (void)removeAllServices:(CDVInvokedUrlCommand *)command; -- (void)startAdvertising:(CDVInvokedUrlCommand *)command; -- (void)stopAdvertising:(CDVInvokedUrlCommand *)command; -- (void)isAdvertising:(CDVInvokedUrlCommand *)command; -- (void)respond:(CDVInvokedUrlCommand *)command; -- (void)notify:(CDVInvokedUrlCommand *)command; +- (void)cn1_initializePeripheral:(CDVInvokedUrlCommand *)command; +- (void)cn1_addService:(CDVInvokedUrlCommand *)command; +- (void)cn1_removeService:(CDVInvokedUrlCommand *)command; +- (void)cn1_removeAllServices:(CDVInvokedUrlCommand *)command; +- (void)cn1_startAdvertising:(CDVInvokedUrlCommand *)command; +- (void)cn1_stopAdvertising:(CDVInvokedUrlCommand *)command; +- (void)cn1_isAdvertising:(CDVInvokedUrlCommand *)command; +- (void)cn1_respond:(CDVInvokedUrlCommand *)command; +- (void)cn1_notify:(CDVInvokedUrlCommand *)command; @end diff --git a/ios/src/main/objectivec/BluetoothLePlugin.m b/ios/src/main/objectivec/BluetoothLePlugin.m index 5e33f40..fc89c4e 100644 --- a/ios/src/main/objectivec/BluetoothLePlugin.m +++ b/ios/src/main/objectivec/BluetoothLePlugin.m @@ -147,7 +147,7 @@ @implementation BluetoothLePlugin //Peripheral Manager Functions -- (void)initializePeripheral:(CDVInvokedUrlCommand *)command { +- (void)cn1_initializePeripheral:(CDVInvokedUrlCommand *)command { initPeripheralCallback = command.callbackId; requestId = 0; @@ -171,7 +171,7 @@ - (void)initializePeripheral:(CDVInvokedUrlCommand *)command { peripheralManager = [[CBPeripheralManager alloc] initWithDelegate:self queue:nil options:options]; } -- (void)addService:(CDVInvokedUrlCommand *)command { +- (void)cn1_addService:(CDVInvokedUrlCommand *)command { NSDictionary* obj = (NSDictionary *)[command.arguments objectAtIndex:0]; CBUUID* serviceUuid = [CBUUID UUIDWithString:[obj valueForKey:@"service"]]; @@ -253,7 +253,7 @@ - (void)addService:(CDVInvokedUrlCommand *)command { [peripheralManager addService:service]; } -- (void)removeService:(CDVInvokedUrlCommand *)command { +- (void)cn1_removeService:(CDVInvokedUrlCommand *)command { NSDictionary* obj = (NSDictionary *)[command.arguments objectAtIndex:0]; CBUUID* serviceUuid = [CBUUID UUIDWithString:[obj valueForKey:@"service"]]; @@ -282,7 +282,7 @@ - (void)removeService:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)removeAllServices:(CDVInvokedUrlCommand *)command { +- (void)cn1_removeAllServices:(CDVInvokedUrlCommand *)command { [peripheralManager removeAllServices]; servicesHash = [[NSMutableDictionary alloc] init]; @@ -295,7 +295,7 @@ - (void)removeAllServices:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)startAdvertising:(CDVInvokedUrlCommand *)command { +- (void)cn1_startAdvertising:(CDVInvokedUrlCommand *)command { if (peripheralManager.isAdvertising) { NSMutableDictionary* returnObj = [NSMutableDictionary dictionary]; [returnObj setValue:@"startAdvertising" forKey:@"error"]; @@ -321,7 +321,7 @@ - (void)startAdvertising:(CDVInvokedUrlCommand *)command { [peripheralManager startAdvertising:advertData]; } -- (void)stopAdvertising:(CDVInvokedUrlCommand *)command { +- (void)cn1_stopAdvertising:(CDVInvokedUrlCommand *)command { if (!peripheralManager.isAdvertising) { NSMutableDictionary* returnObj = [NSMutableDictionary dictionary]; [returnObj setValue:@"stopAdvertising" forKey:@"error"]; @@ -341,7 +341,7 @@ - (void)stopAdvertising:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isAdvertising:(CDVInvokedUrlCommand *)command { +- (void)cn1_isAdvertising:(CDVInvokedUrlCommand *)command { NSMutableDictionary* returnObj = [NSMutableDictionary dictionary]; [returnObj setValue:[NSNumber numberWithBool:peripheralManager.isAdvertising] forKey:@"isAdvertising"]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:returnObj]; @@ -349,7 +349,7 @@ - (void)isAdvertising:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)respond:(CDVInvokedUrlCommand *)command { +- (void)cn1_respond:(CDVInvokedUrlCommand *)command { NSDictionary* obj = (NSDictionary *)[command.arguments objectAtIndex:0]; NSNumber* checkRequestId = [obj valueForKey:@"requestId"]; @@ -421,7 +421,7 @@ - (void)respond:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)notify:(CDVInvokedUrlCommand *)command { +- (void)cn1_notify:(CDVInvokedUrlCommand *)command { NSDictionary* obj = (NSDictionary *)[command.arguments objectAtIndex:0]; CBUUID* serviceUuid = [CBUUID UUIDWithString:[obj valueForKey:@"service"]]; @@ -671,7 +671,7 @@ - (void)peripheralManager:(CBPeripheralManager *)peripheral willRestoreState:(NS } //Actions -- (void)initialize:(CDVInvokedUrlCommand *)command { +- (void)cn1_initialize:(CDVInvokedUrlCommand *)command { //Save the callback initCallback = command.callbackId; @@ -726,21 +726,21 @@ - (void)initialize:(CDVInvokedUrlCommand *)command { connections = [NSMutableDictionary dictionary]; } -- (void)enable:(CDVInvokedUrlCommand *)command { +- (void)cn1_enable:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorEnable, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)disable:(CDVInvokedUrlCommand *)command { +- (void)cn1_disable:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorDisable, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)startScan:(CDVInvokedUrlCommand *)command { +- (void)cn1_startScan:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -780,7 +780,7 @@ - (void)startScan:(CDVInvokedUrlCommand *)command { [centralManager scanForPeripheralsWithServices:serviceUuids options:@{ CBCentralManagerScanOptionAllowDuplicatesKey:allowDuplicates }]; } -- (void)stopScan:(CDVInvokedUrlCommand *)command { +- (void)cn1_stopScan:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -808,7 +808,7 @@ - (void)stopScan:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)retrieveConnected:(CDVInvokedUrlCommand *)command { +- (void)cn1_retrieveConnected:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -845,7 +845,7 @@ - (void)retrieveConnected:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)retrievePeripheralsByAddress:(CDVInvokedUrlCommand *)command { +- (void)cn1_retrievePeripheralsByAddress:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -882,21 +882,21 @@ - (void)retrievePeripheralsByAddress:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)bond:(CDVInvokedUrlCommand *)command { +- (void)cn1_bond:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorBond, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)unbond:(CDVInvokedUrlCommand *)command { +- (void)cn1_unbond:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorUnbond, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)connect:(CDVInvokedUrlCommand *)command { +- (void)cn1_connect:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -950,7 +950,7 @@ - (void)connect:(CDVInvokedUrlCommand *)command { [centralManager connectPeripheral:peripheral options:nil]; } -- (void)reconnect:(CDVInvokedUrlCommand *)command { +- (void)cn1_reconnect:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -990,7 +990,7 @@ - (void)reconnect:(CDVInvokedUrlCommand *)command { [centralManager connectPeripheral:peripheral options:nil]; } -- (void)disconnect:(CDVInvokedUrlCommand *)command { +- (void)cn1_disconnect:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1043,7 +1043,7 @@ - (void)disconnect:(CDVInvokedUrlCommand *)command { [centralManager cancelPeripheralConnection:peripheral]; } -- (void)close:(CDVInvokedUrlCommand *)command { +- (void)cn1_close:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1093,7 +1093,7 @@ - (void)close:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)discover:(CDVInvokedUrlCommand *)command { +- (void)cn1_discover:(CDVInvokedUrlCommand *)command { /*NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorDiscover, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; @@ -1154,7 +1154,7 @@ - (void)discover:(CDVInvokedUrlCommand *)command { [peripheral discoverServices:serviceUuids]; } -- (void)services:(CDVInvokedUrlCommand *)command { +- (void)cn1_services:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1196,7 +1196,7 @@ - (void)services:(CDVInvokedUrlCommand *)command { [peripheral discoverServices:serviceUuids]; } -- (void)characteristics:(CDVInvokedUrlCommand *)command { +- (void)cn1_characteristics:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1245,7 +1245,7 @@ - (void)characteristics:(CDVInvokedUrlCommand *)command { [peripheral discoverCharacteristics:characteristicUuids forService:service]; } -- (void)descriptors:(CDVInvokedUrlCommand *)command { +- (void)cn1_descriptors:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1296,7 +1296,7 @@ - (void)descriptors:(CDVInvokedUrlCommand *)command { [peripheral discoverDescriptorsForCharacteristic:characteristic]; } -- (void)read:(CDVInvokedUrlCommand *)command { +- (void)cn1_read:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1347,7 +1347,7 @@ - (void)read:(CDVInvokedUrlCommand *)command { [peripheral readValueForCharacteristic:characteristic]; } -- (void)subscribe:(CDVInvokedUrlCommand *)command { +- (void)cn1_subscribe:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1406,7 +1406,7 @@ - (void)subscribe:(CDVInvokedUrlCommand *)command { [peripheral setNotifyValue:true forCharacteristic:characteristic]; } -- (void)unsubscribe:(CDVInvokedUrlCommand *)command { +- (void)cn1_unsubscribe:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { @@ -1472,7 +1472,7 @@ - (void)unsubscribe:(CDVInvokedUrlCommand *)command { [peripheral setNotifyValue:false forCharacteristic:characteristic]; } -- (void)write:(CDVInvokedUrlCommand *)command { +- (void)cn1_write:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1560,7 +1560,7 @@ - (void)write:(CDVInvokedUrlCommand *)command { } } -- (void)writeQ:(CDVInvokedUrlCommand *)command { +- (void)cn1_writeQ:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1637,7 +1637,7 @@ - (void)writeQ:(CDVInvokedUrlCommand *)command { [self writeDataToCharacteristic:characteristic toPeripheral:peripheral]; } -- (void)readDescriptor:(CDVInvokedUrlCommand *)command { +- (void)cn1_readDescriptor:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1694,7 +1694,7 @@ - (void)readDescriptor:(CDVInvokedUrlCommand *)command { [peripheral readValueForDescriptor:descriptor]; } -- (void)writeDescriptor:(CDVInvokedUrlCommand *)command { +- (void)cn1_writeDescriptor:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1782,7 +1782,7 @@ - (void)writeDescriptor:(CDVInvokedUrlCommand *)command { [peripheral writeValue:value forDescriptor:descriptor]; } -- (void)rssi:(CDVInvokedUrlCommand *)command { +- (void)cn1_rssi:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1821,21 +1821,21 @@ - (void)rssi:(CDVInvokedUrlCommand *)command { [peripheral readRSSI]; } -- (void)mtu:(CDVInvokedUrlCommand *)command { +- (void)cn1_mtu:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorMtu, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)requestConnectionPriority:(CDVInvokedUrlCommand *)command { +- (void)cn1_requestConnectionPriority:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorRequestConnectionPriority, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isInitialized:(CDVInvokedUrlCommand *)command { +- (void)cn1_isInitialized:(CDVInvokedUrlCommand *)command { //See if Bluetooth has been initialized NSNumber* result = [NSNumber numberWithBool:(centralManager != nil)]; @@ -1845,7 +1845,7 @@ - (void)isInitialized:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isEnabled:(CDVInvokedUrlCommand *)command { +- (void)cn1_isEnabled:(CDVInvokedUrlCommand *)command { //See if Bluetooth is currently enabled NSNumber* result = [NSNumber numberWithBool:(centralManager != nil && centralManager.state == CBManagerStatePoweredOn)]; @@ -1855,7 +1855,7 @@ - (void)isEnabled:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isScanning:(CDVInvokedUrlCommand *)command { +- (void)cn1_isScanning:(CDVInvokedUrlCommand *)command { //See if Bluetooth is scanning NSNumber* result = [NSNumber numberWithBool:(scanCallback != nil)]; @@ -1865,14 +1865,14 @@ - (void)isScanning:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isBonded:(CDVInvokedUrlCommand *)command { +- (void)cn1_isBonded:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: errorIsBonded, keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)wasConnected:(CDVInvokedUrlCommand *)command { +- (void)cn1_wasConnected:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1921,7 +1921,7 @@ - (void)wasConnected:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isConnected:(CDVInvokedUrlCommand *)command { +- (void)cn1_isConnected:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -1960,7 +1960,7 @@ - (void)isConnected:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isDiscovered:(CDVInvokedUrlCommand *)command { +- (void)cn1_isDiscovered:(CDVInvokedUrlCommand *)command { //Ensure Bluetooth is enabled if ([self isNotInitialized:command]) { return; @@ -2008,28 +2008,28 @@ - (void)isDiscovered:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)hasPermission:(CDVInvokedUrlCommand *)command { +- (void)cn1_hasPermission:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: @"hasPermission", keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)requestPermission:(CDVInvokedUrlCommand *)command { +- (void)cn1_requestPermission:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: @"requestPermission", keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)isLocationEnabled:(CDVInvokedUrlCommand *)command { +- (void)cn1_isLocationEnabled:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: @"isLocationEnabled", keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } -- (void)requestLocation:(CDVInvokedUrlCommand *)command { +- (void)cn1_requestLocation:(CDVInvokedUrlCommand *)command { NSDictionary* returnObj = [NSDictionary dictionaryWithObjectsAndKeys: @"requestLocation", keyError, logOperationUnsupported, keyMessage, nil]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:returnObj]; [pluginResult setKeepCallbackAsBool:false]; diff --git a/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.h b/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.h index bd9dbcb..65dd904 100644 --- a/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.h +++ b/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.h @@ -1,5 +1,19 @@ #import -#import "BluetoothLePlugin.h" + +// Forward declaration only: importing BluetoothLePlugin.h here would also +// pull in and +// transitively, exposing Apple's `-(void)stopScan` (CBCentralManager) and +// `-(void)requestLocation` (CLLocationManager) to every file that includes +// this header. The CN1 ParparVM dispatch shim +// (native_com_codename1_bluetoothle_BluetoothNativeBridgeImplCodenameOne.m) +// is exactly such a consumer, and with an `id`-typed receiver clang then +// can't disambiguate between Apple's void overloads and the bridge's +// `-(BOOL)stopScan` / `-(BOOL)requestLocation`, picking the void one and +// failing with "initializing 'JAVA_BOOLEAN' with an expression of +// incompatible type 'void'". Forward-declaring keeps the ivar type +// visible to the .m file (which does import BluetoothLePlugin.h) without +// leaking CoreBluetooth into the public header. +@class BluetoothLePlugin; @interface com_codename1_bluetoothle_BluetoothNativeBridgeImpl : NSObject { BluetoothLePlugin* _bluetoothPlugin; diff --git a/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.m b/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.m index e7e1a6b..071edc9 100644 --- a/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.m +++ b/ios/src/main/objectivec/com_codename1_bluetoothle_BluetoothNativeBridgeImpl.m @@ -1,5 +1,9 @@ #import #import "com_codename1_bluetoothle_BluetoothNativeBridgeImpl.h" +// Full plugin header lives here, not in the .h, so CoreBluetooth / +// CoreLocation don't leak into the codegen shim that consumes the .h. +// See the comment in the .h for the full rationale. +#import "BluetoothLePlugin.h" #import "BluetoothLeCommandDelegateImpl.h" #import @@ -43,7 +47,18 @@ -(BOOL)executeAction:(NSString*)action args:(NSDictionary*)args { - (BOOL)executeCommand:(CDVInvokedUrlCommand*)command { BOOL retVal = YES; CDVPlugin* plugin = [self getBluetoothPlugin]; - NSString* methodName = [NSString stringWithFormat:@"%@:", command.methodName]; + // The bridge's no-arg `-(BOOL)enable`, `-(BOOL)stopScan`, + // `-(BOOL)requestLocation`, etc. share their first-word name with + // BluetoothLePlugin's Cordova-style `-(void)enable:(command)` etc. + // clang's -Wobjc-multiple-method-names tracks "first word", so the + // CN1 ParparVM-generated dispatch shim ends up with ambiguous + // selector resolution and clang errors with "initializing + // 'JAVA_BOOLEAN' with an expression of incompatible type 'void'". + // Renamed the underlying Cordova methods in BluetoothLePlugin.{h,m} + // to a unique `cn1_` prefix; build the matching selector + // here so the action-name dispatch from the Java side still routes + // correctly. + NSString* methodName = [NSString stringWithFormat:@"cn1_%@:", command.methodName]; SEL selector = NSSelectorFromString(methodName); if ([plugin respondsToSelector:selector]) { ((void (*)(id, SEL, id))objc_msgSend)(plugin, selector, command); diff --git a/javase/pom.xml b/javase/pom.xml index f8c97a1..eb0d95b 100644 --- a/javase/pom.xml +++ b/javase/pom.xml @@ -34,5 +34,138 @@ + + + + + macos-native-ble-helper + + mac + !skipNativeBleHelper + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + build-cn1-ble-helper-macos + process-classes + run + + + + + + + + + + + + + + + + + + + + + linux-native-ble-helper + + unixLinux + !skipNativeBleHelper + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + build-cn1-ble-helper-linux + process-classes + run + + + + + + + + + + + + + + + + + + + + + windows-native-ble-helper + + windows + !skipNativeBleHelper + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + build-cn1-ble-helper-windows + process-classes + run + + + + + + + + + + + + + + + + + + + diff --git a/javase/src/main/java/com/codename1/bluetoothle/BluetoothNativeBridgeImpl.java b/javase/src/main/java/com/codename1/bluetoothle/BluetoothNativeBridgeImpl.java index 3a75efd..e32b8ac 100644 --- a/javase/src/main/java/com/codename1/bluetoothle/BluetoothNativeBridgeImpl.java +++ b/javase/src/main/java/com/codename1/bluetoothle/BluetoothNativeBridgeImpl.java @@ -1,648 +1,144 @@ package com.codename1.bluetoothle; -import com.codename1.ui.Display; - -import java.util.List; -import java.util.Map; - /// JavaSE / Codename One simulator implementation of the BLE native bridge. /// -/// Backed by [BluetoothSimulator]: a scriptable in-memory virtual peripheral -/// stack. This lets the public [Bluetooth] API run end-to-end inside the CN1 -/// simulator and CN1 UnitTest suite, with the same callback payload shapes -/// the Android and iOS bridges produce. +/// Thin dispatcher that picks one of two real implementations at construction +/// time: +/// +///
    +///
  • {@link SimulatorBluetoothBackend} — default; in-memory scriptable +/// peripherals, drivable from tests and the simulator's Bluetooth menu;
  • +///
  • {@link NativeBleBackend} — talks to real BLE hardware via a +/// bundled Rust helper that wraps the btleplug crate. Supports macOS +/// (CoreBluetooth), Linux (BlueZ) and Windows (WinRT).
  • +///
/// -/// All bridge methods return true (action accepted for dispatch) and deliver -/// real success/error payloads asynchronously through -/// [BluetoothCallbackRegistry.sendResult] — exactly the contract the iOS and -/// Android implementations honor. +/// Selection priority: +///
    +///
  1. System property {@code cn1.bluetoothle.javase.backend} ({@code simulator} +/// or {@code nativeBle});
  2. +///
  3. Environment variable {@code CN1_BLUETOOTHLE_BACKEND};
  4. +///
  5. Default {@code simulator}.
  6. +///
+/// +/// {@code coreBluetooth} is accepted as a backward-compatible alias for +/// {@code nativeBle}. Tests always pin {@code simulator} for predictability. public class BluetoothNativeBridgeImpl implements BluetoothNativeBridge { - private boolean bluetoothUsageDescriptionChecked; - - @Override - public boolean isSupported() { - installBuildHints(); - return true; - } - - @Override - public boolean initialize(boolean request, boolean statusReceiver, String restoreKey) { - installBuildHints(); - SimulatorState s = BluetoothSimulator.state(); - s.setInitialized(true); - if (request && !s.isEnabled()) { - s.setEnabled(true); - } - final String status = s.isEnabled() ? "enabled" : "disabled"; - // keepCallback=true so subsequent state-change broadcasts (eg - // simulator-driven enable/disable) can deliver to the same listener. - s.runSync(() -> BluetoothCallbackRegistry.sendResult("initialize", - JsonBuilder.start().put("status", status).end(), true, true)); - return true; - } - - @Override - public boolean enable() { - SimulatorState s = BluetoothSimulator.state(); - s.setEnabled(true); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("enable", - JsonBuilder.start().put("status", "enabled").end(), true)); - return true; - } - - @Override - public boolean disable() { - SimulatorState s = BluetoothSimulator.state(); - s.setEnabled(false); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("disable", - JsonBuilder.start().put("status", "disabled").end(), true)); - return true; - } - - @Override - public boolean startScan(String servicesJson, boolean allowDuplicates, int scanMode, int matchMode, int matchNum, int callbackType) { - SimulatorState s = BluetoothSimulator.state(); - if (failIfPresent("startScan")) return true; - if (!s.isEnabled()) { - return errorAsync("startScan", "isDisabled", "Bluetooth not enabled"); - } - s.setScanning(true); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("startScan", - JsonBuilder.start().put("status", "scanStarted").end(), true, true)); - int delay = 0; - for (SimulatedPeripheral p : s.snapshotPeripherals()) { - final int extra = delay; - s.scheduleAfter(extra, () -> { - if (!s.isScanning()) return; - String json = JsonBuilder.start() - .put("status", "scanResult") - .put("address", p.getAddress()) - .put("name", p.getName()) - .put("rssi", p.getRssi()) - .put("advertisement", Base64Util.encode(p.getAdvertisementData() == null ? new byte[0] : p.getAdvertisementData())) - .end(); - BluetoothCallbackRegistry.sendResult("startScan", json, true, true); - }); - delay += 5; - } - return true; - } - - @Override - public boolean stopScan() { - SimulatorState s = BluetoothSimulator.state(); - s.setScanning(false); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("stopScan", - JsonBuilder.start().put("status", "scanStopped").end(), true)); - return true; - } - - @Override - public boolean retrieveConnected(String servicesJson) { - SimulatorState s = BluetoothSimulator.state(); - s.schedule(() -> { - StringBuilder arr = new StringBuilder("["); - boolean firstP = true; - for (SimulatedPeripheral p : s.snapshotPeripherals()) { - SimulatorState.ConnectionState cs = s.connectionFor(p.getAddress(), false); - if (cs == null || !cs.connected) continue; - if (!firstP) arr.append(','); - firstP = false; - arr.append(JsonBuilder.start() - .put("address", p.getAddress()) - .put("name", p.getName()) - .end()); - } - arr.append(']'); - String json = JsonBuilder.start() - .put("status", "retrieveConnected") - .putRaw("devices", arr.toString()) - .end(); - BluetoothCallbackRegistry.sendResult("retrieveConnected", json, true); - }); - return true; - } - - @Override - public boolean connect(String address) { - SimulatorState s = BluetoothSimulator.state(); - if (failIfPresent("connect")) return true; - if (!s.isEnabled()) return errorAsync("connect", "isDisabled", "Bluetooth not enabled"); - SimulatedPeripheral p = s.getPeripheral(address); - if (p == null) return errorAsync("connect", "neverConnected", "No such device: " + address); - SimulatorState.ConnectionState cs = s.connectionFor(address, true); - if (cs.connected) return errorAsync("connect", "isNotDisconnected", "Device isn't disconnected"); - cs.connected = true; - cs.wasConnected = true; - s.schedule(() -> BluetoothCallbackRegistry.sendResult("connect", - JsonBuilder.start() - .put("status", "connected") - .put("address", p.getAddress()) - .put("name", p.getName()) - .end(), true, true)); - return true; - } - - @Override - public boolean reconnect(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - if (p == null) return errorAsync("reconnect", "neverConnected", "Never connected to device"); - SimulatorState.ConnectionState cs = s.connectionFor(address, true); - if (!cs.wasConnected) return errorAsync("reconnect", "neverConnected", "Never connected to device"); - cs.connected = true; - s.schedule(() -> BluetoothCallbackRegistry.sendResult("reconnect", - JsonBuilder.start() - .put("status", "connected") - .put("address", p.getAddress()) - .put("name", p.getName()) - .end(), true, true)); - return true; - } - - @Override - public boolean disconnect(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - SimulatorState.ConnectionState cs = s.connectionFor(address, false); - if (cs == null || !cs.connected) { - return errorAsync("disconnect", "isDisconnected", "Device is disconnected"); - } - cs.connected = false; - cs.subscriptions.clear(); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("disconnect", - JsonBuilder.start() - .put("status", "disconnected") - .put("address", address) - .put("name", p == null ? "" : p.getName()) - .end(), true)); - return true; - } - - @Override - public boolean close(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatorState.ConnectionState cs = s.connectionFor(address, false); - if (cs != null) { - cs.connected = false; - cs.discovered = false; - cs.subscriptions.clear(); - } - s.schedule(() -> BluetoothCallbackRegistry.sendResult("close", - JsonBuilder.start() - .put("status", "closed") - .put("address", address) - .end(), true)); - return true; - } - - @Override - public boolean discover(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - SimulatorState.ConnectionState cs = s.connectionFor(address, false); - if (p == null || cs == null || !cs.connected) { - return errorAsync("discover", "isDisconnected", "Device is disconnected"); - } - cs.discovered = true; - s.schedule(() -> BluetoothCallbackRegistry.sendResult("discover", - buildDiscoveredJson(p), true)); - return true; - } - - @Override - public boolean services(String address, String servicesJson) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - if (p == null) return errorAsync("services", "services", "Unknown device"); - s.schedule(() -> { - StringBuilder arr = new StringBuilder("["); - boolean firstS = true; - for (SimulatedService svc : p.getServices()) { - if (!firstS) arr.append(','); - firstS = false; - arr.append('"').append(svc.getUuid()).append('"'); - } - arr.append(']'); - String json = JsonBuilder.start() - .put("status", "services") - .put("address", address) - .putRaw("services", arr.toString()) - .end(); - BluetoothCallbackRegistry.sendResult("services", json, true); - }); - return true; - } - - @Override - public boolean characteristics(String address, String service, String characteristicsJson) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - SimulatedService svc = p == null ? null : p.findService(service); - if (svc == null) return errorAsync("characteristics", "characteristics", "Unknown service"); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("characteristics", - JsonBuilder.start() - .put("status", "characteristics") - .put("address", address) - .put("service", service) - .putRaw("characteristics", buildCharacteristicsArray(svc)) - .end(), true)); - return true; - } - - @Override - public boolean descriptors(String address, String service, String characteristic) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - SimulatedService svc = p == null ? null : p.findService(service); - SimulatedCharacteristic ch = svc == null ? null : svc.findCharacteristic(characteristic); - if (ch == null) return errorAsync("descriptors", "descriptors", "Unknown characteristic"); - s.schedule(() -> { - StringBuilder arr = new StringBuilder("["); - boolean firstD = true; - for (Map.Entry e : ch.getDescriptors().entrySet()) { - if (!firstD) arr.append(','); - firstD = false; - arr.append('"').append(e.getKey()).append('"'); - } - arr.append(']'); - BluetoothCallbackRegistry.sendResult("descriptors", - JsonBuilder.start() - .put("status", "descriptors") - .put("address", address) - .put("service", service) - .put("characteristic", characteristic) - .putRaw("descriptors", arr.toString()) - .end(), true); - }); - return true; - } - - @Override - public boolean read(String address, String service, String characteristic) { - SimulatorState s = BluetoothSimulator.state(); - if (failIfPresent("read")) return true; - SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); - if (ch == null) return errorAsync("read", "read", "Unknown characteristic"); - byte[] value = ch.getValue(); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("read", - JsonBuilder.start() - .put("status", "read") - .put("address", address) - .put("service", service) - .put("characteristic", characteristic) - .put("value", Base64Util.encode(value == null ? new byte[0] : value)) - .end(), true)); - return true; - } - - @Override - public boolean subscribe(String address, String service, String characteristic) { - SimulatorState s = BluetoothSimulator.state(); - if (failIfPresent("subscribe")) return true; - SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); - if (ch == null) return errorAsync("subscribe", "subscribe", "Unknown characteristic"); - SimulatorState.ConnectionState cs = s.connectionFor(address, true); - cs.subscriptions.add(SimulatorState.subscriptionKey(service, characteristic)); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("subscribe", - JsonBuilder.start() - .put("status", "subscribed") - .put("address", address) - .put("service", service) - .put("characteristic", characteristic) - .end(), true, true)); - return true; - } - - @Override - public boolean unsubscribe(String address, String service, String characteristic) { - SimulatorState s = BluetoothSimulator.state(); - SimulatorState.ConnectionState cs = s.connectionFor(address, false); - if (cs != null) { - cs.subscriptions.remove(SimulatorState.subscriptionKey(service, characteristic)); - } - s.schedule(() -> BluetoothCallbackRegistry.sendResult("unsubscribe", - JsonBuilder.start() - .put("status", "unsubscribed") - .put("address", address) - .put("service", service) - .put("characteristic", characteristic) - .end(), true)); - return true; - } + static final String BACKEND_PROPERTY = "cn1.bluetoothle.javase.backend"; + static final String BACKEND_ENV = "CN1_BLUETOOTHLE_BACKEND"; + static final String BACKEND_SIMULATOR = "simulator"; + static final String BACKEND_NATIVE_BLE = "nativeBle"; + /** Pre-rename alias kept so users with `coreBluetooth` flags don't break. */ + static final String BACKEND_NATIVE_BLE_LEGACY = "coreBluetooth"; - @Override - public boolean write(String address, String service, String characteristic, String value, boolean noResponse) { - SimulatorState s = BluetoothSimulator.state(); - if (failIfPresent("write")) return true; - SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); - if (ch == null) return errorAsync("write", "write", "Unknown characteristic"); - ch.setValueInternal(Base64Util.decode(value)); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("write", - JsonBuilder.start() - .put("status", "written") - .put("address", address) - .put("service", service) - .put("characteristic", characteristic) - .put("value", value == null ? "" : value) - .end(), true)); - return true; - } + /// Volatile static so a runtime backend swap is visible to every + /// previously-constructed {@code BluetoothNativeBridgeImpl} instance + /// (CN1's {@code NativeLookup} caches one). The Bluetooth menu's + /// "Switch backend" items mutate this through {@link #switchBackend}. + private static volatile BluetoothNativeBridge currentBackend; - @Override - public boolean writeQ(String address, String service, String characteristic, String value, boolean noResponse) { - return write(address, service, characteristic, value, noResponse); + public BluetoothNativeBridgeImpl() { + ensureBackend(); } - @Override - public boolean readDescriptor(String address, String service, String characteristic, String descriptor) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); - if (ch == null || !ch.hasDescriptor(descriptor)) { - return errorAsync("readDescriptor", "readDescriptor", "Unknown descriptor"); - } - byte[] dv = ch.getDescriptorValue(descriptor); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("readDescriptor", - JsonBuilder.start() - .put("status", "readDescriptor") - .put("address", address) - .put("service", service) - .put("characteristic", characteristic) - .put("descriptor", descriptor) - .put("value", Base64Util.encode(dv == null ? new byte[0] : dv)) - .end(), true)); - return true; + /// Test seam: pin a specific backend instance. + BluetoothNativeBridgeImpl(BluetoothNativeBridge backend) { + currentBackend = backend; } - @Override - public boolean writeDescriptor(String address, String service, String characteristic, String descriptor, String value) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); - if (ch == null || !ch.hasDescriptor(descriptor)) { - return errorAsync("writeDescriptor", "writeDescriptor", "Unknown descriptor"); + private static synchronized void ensureBackend() { + if (currentBackend == null) { + currentBackend = selectBackend(); } - ch.setDescriptorValue(descriptor, Base64Util.decode(value)); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("writeDescriptor", - JsonBuilder.start() - .put("status", "writtenDescriptor") - .put("address", address) - .put("service", service) - .put("characteristic", characteristic) - .put("descriptor", descriptor) - .end(), true)); - return true; - } - - @Override - public boolean rssi(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - int r = p == null ? -127 : p.getRssi(); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("rssi", - JsonBuilder.start() - .put("status", "rssi") - .put("address", address) - .put("rssi", r) - .end(), true)); - return true; - } - - @Override - public boolean mtu(String address, int mtu) { - SimulatorState s = BluetoothSimulator.state(); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("mtu", - JsonBuilder.start() - .put("status", "mtu") - .put("address", address) - .put("mtu", mtu) - .end(), true)); - return true; - } - - @Override - public boolean requestConnectionPriority(String address, String priority) { - SimulatorState s = BluetoothSimulator.state(); - s.schedule(() -> BluetoothCallbackRegistry.sendResult("requestConnectionPriority", - JsonBuilder.start() - .put("status", "connectionPriorityRequested") - .put("address", address) - .put("connectionPriority", priority == null ? "" : priority) - .end(), true)); - return true; - } - - // Pure state queries are delivered synchronously: the Android plugin - // implements them with callbackContext.success(...) inside the action - // handler, so by the time the dispatch call returns the callback has - // already received its payload. Keeping them sync here removes a - // scheduler hop and matches the contract the public Bluetooth API - // relies on (callback.getResponseAndWait(500) sees a complete callback - // immediately). - - @Override - public boolean isInitialized() { - SimulatorState s = BluetoothSimulator.state(); - boolean v = s.isInitialized(); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("isInitialized", - JsonBuilder.start().put("isInitialized", v).end(), true)); - return true; - } - - @Override - public boolean isEnabled() { - SimulatorState s = BluetoothSimulator.state(); - boolean v = s.isEnabled(); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("isEnabled", - JsonBuilder.start().put("isEnabled", v).end(), true)); - return true; - } - - @Override - public boolean isScanning() { - SimulatorState s = BluetoothSimulator.state(); - boolean v = s.isScanning(); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("isScanning", - JsonBuilder.start().put("isScanning", v).end(), true)); - return true; - } - - @Override - public boolean wasConnected(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatorState.ConnectionState cs = s.connectionFor(address, false); - boolean v = cs != null && cs.wasConnected; - s.runSync(() -> BluetoothCallbackRegistry.sendResult("wasConnected", - JsonBuilder.start() - .put("address", address) - .put("wasConnected", v) - .end(), true)); - return true; - } - - @Override - public boolean isConnected(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatorState.ConnectionState cs = s.connectionFor(address, false); - boolean v = cs != null && cs.connected; - s.runSync(() -> BluetoothCallbackRegistry.sendResult("isConnected", - JsonBuilder.start() - .put("address", address) - .put("isConnected", v) - .end(), true)); - return true; } - @Override - public boolean isDiscovered(String address) { - SimulatorState s = BluetoothSimulator.state(); - SimulatorState.ConnectionState cs = s.connectionFor(address, false); - boolean v = cs != null && cs.discovered; - s.runSync(() -> BluetoothCallbackRegistry.sendResult("isDiscovered", - JsonBuilder.start() - .put("address", address) - .put("isDiscovered", v) - .end(), true)); - return true; - } - - @Override - public boolean hasPermission() { - SimulatorState s = BluetoothSimulator.state(); - boolean v = s.hasPermission(); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("hasPermission", - JsonBuilder.start().put("hasPermission", v).end(), true)); - return true; - } - - @Override - public boolean requestPermission() { - SimulatorState s = BluetoothSimulator.state(); - s.setHasPermission(true); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("requestPermission", - JsonBuilder.start().put("requestPermission", true).end(), true)); - return true; - } - - @Override - public boolean isLocationEnabled() { - SimulatorState s = BluetoothSimulator.state(); - boolean v = s.isLocationEnabled(); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("isLocationEnabled", - JsonBuilder.start().put("isLocationEnabled", v).end(), true)); - return true; - } - - @Override - public boolean requestLocation() { - SimulatorState s = BluetoothSimulator.state(); - s.setLocationEnabled(true); - s.runSync(() -> BluetoothCallbackRegistry.sendResult("requestLocation", - JsonBuilder.start().put("requestLocation", true).end(), true)); - return true; - } - - private SimulatedCharacteristic lookupCharacteristic(String address, String service, String characteristic) { - SimulatorState s = BluetoothSimulator.state(); - SimulatedPeripheral p = s.getPeripheral(address); - if (p == null) return null; - SimulatedService svc = p.findService(service); - if (svc == null) return null; - return svc.findCharacteristic(characteristic); - } - - private boolean errorAsync(final String operation, final String error, final String message) { - SimulatorState s = BluetoothSimulator.state(); - s.schedule(() -> BluetoothCallbackRegistry.sendResult(operation, - JsonBuilder.start() - .put("error", error) - .put("message", message == null ? "" : message) - .end(), false)); - return true; - } - - private boolean failIfPresent(String operation) { - SimulatorState.Failure f = BluetoothSimulator.state().consumeFailure(operation); - if (f == null) return false; - errorAsync(operation, f.error, f.message); - return true; - } - - private static String buildDiscoveredJson(SimulatedPeripheral p) { - StringBuilder services = new StringBuilder("["); - boolean firstS = true; - for (SimulatedService svc : p.getServices()) { - if (!firstS) services.append(','); - firstS = false; - services.append(JsonBuilder.start() - .put("service", svc.getUuid()) - .putRaw("characteristics", buildCharacteristicsArray(svc)) - .end()); + static BluetoothNativeBridge selectBackend() { + String name = System.getProperty(BACKEND_PROPERTY); + if (name == null || name.isEmpty()) { + name = System.getenv(BACKEND_ENV); } - services.append(']'); - return JsonBuilder.start() - .put("status", "discovered") - .put("address", p.getAddress()) - .put("name", p.getName()) - .putRaw("services", services.toString()) - .end(); - } - - private static String buildCharacteristicsArray(SimulatedService svc) { - StringBuilder arr = new StringBuilder("["); - boolean firstC = true; - for (SimulatedCharacteristic ch : svc.getCharacteristics()) { - if (!firstC) arr.append(','); - firstC = false; - StringBuilder props = new StringBuilder("["); - boolean firstP = true; - for (String p : ch.getProperties()) { - if (!firstP) props.append(','); - firstP = false; - props.append('"').append(p).append('"'); - } - props.append(']'); - arr.append(JsonBuilder.start() - .put("characteristic", ch.getUuid()) - .putRaw("properties", props.toString()) - .end()); + if (name == null || name.isEmpty()) { + name = BACKEND_SIMULATOR; } - arr.append(']'); - return arr.toString(); + return buildBackend(name); } - private void checkBluetoothUsageDescription() { - if (!bluetoothUsageDescriptionChecked) { - bluetoothUsageDescriptionChecked = true; - Map m = Display.getInstance().getProjectBuildHints(); - if (m != null) { - if (!m.containsKey("ios.NSBluetoothPeripheralUsageDescription")) { - Display.getInstance().setProjectBuildHint("ios.NSBluetoothPeripheralUsageDescription", "Some functionality of the application requires Bluetooth functionality"); - } - if (!m.containsKey("ios.NSBluetoothAlwaysUsageDescription")) { - Display.getInstance().setProjectBuildHint("ios.NSBluetoothAlwaysUsageDescription", "Some functionality of the application requires Bluetooth functionality"); - } + private static BluetoothNativeBridge buildBackend(String name) { + boolean wantsNative = BACKEND_NATIVE_BLE.equalsIgnoreCase(name) + || BACKEND_NATIVE_BLE_LEGACY.equalsIgnoreCase(name); + if (wantsNative) { + if (!NativeBleBackend.isAvailable()) { + System.err.println("BluetoothNativeBridgeImpl: '" + name + + "' requested but unavailable (host OS not supported by btleplug or helper not packaged); falling back to simulator"); + return new SimulatorBluetoothBackend(); } + return new NativeBleBackend(); } - } - - private void installBuildHints() { - try { - checkBluetoothUsageDescription(); - } catch (Throwable t) { - // Display may not be initialized when called from non-CN1 contexts - // (eg pure JUnit). Fall through silently — the build hints are only - // needed for actual native builds, not for simulator/test runs. + return new SimulatorBluetoothBackend(); + } + + /// Hot-swap to a different backend at runtime. The previous backend's + /// resources (e.g., {@link NativeBleBackend}'s helper process) are + /// released. Any cached {@code Bluetooth} instance in the app + /// immediately sees the new backend on its next call. State carried by + /// the public {@code Bluetooth} API (initialized / enabled / connections) + /// resets — callers should re-run {@code bt.initialize()} after switching. + public static synchronized void switchBackend(String name) { + BluetoothNativeBridge prev = currentBackend; + System.setProperty(BACKEND_PROPERTY, name); + currentBackend = buildBackend(name); + if (prev != null && prev != currentBackend && prev instanceof NativeBleBackend) { + ((NativeBleBackend) prev).shutdown(); } } - @SuppressWarnings("unused") - private static String join(List parts) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < parts.size(); i++) { - if (i > 0) sb.append(','); - sb.append(parts.get(i)); - } - return sb.toString(); - } + /// Which backend is currently active. Reported as the lowercase short + /// name ({@code simulator} or {@code nativeBle}). Used by the simulator + /// menu's status indicator and by tests asserting selection. + public String activeBackendName() { + ensureBackend(); + return currentBackend instanceof NativeBleBackend ? BACKEND_NATIVE_BLE : BACKEND_SIMULATOR; + } + + private static BluetoothNativeBridge backend() { + ensureBackend(); + return currentBackend; + } + + @Override public boolean isSupported() { return backend().isSupported(); } + @Override public boolean initialize(boolean request, boolean statusReceiver, String restoreKey) { return backend().initialize(request, statusReceiver, restoreKey); } + @Override public boolean enable() { return backend().enable(); } + @Override public boolean disable() { return backend().disable(); } + @Override public boolean startScan(String servicesJson, boolean allowDuplicates, int scanMode, int matchMode, int matchNum, int callbackType) { return backend().startScan(servicesJson, allowDuplicates, scanMode, matchMode, matchNum, callbackType); } + @Override public boolean stopScan() { return backend().stopScan(); } + @Override public boolean retrieveConnected(String servicesJson) { return backend().retrieveConnected(servicesJson); } + @Override public boolean connect(String address) { return backend().connect(address); } + @Override public boolean reconnect(String address) { return backend().reconnect(address); } + @Override public boolean disconnect(String address) { return backend().disconnect(address); } + @Override public boolean close(String address) { return backend().close(address); } + @Override public boolean discover(String address) { return backend().discover(address); } + @Override public boolean services(String address, String servicesJson) { return backend().services(address, servicesJson); } + @Override public boolean characteristics(String address, String service, String characteristicsJson) { return backend().characteristics(address, service, characteristicsJson); } + @Override public boolean descriptors(String address, String service, String characteristic) { return backend().descriptors(address, service, characteristic); } + @Override public boolean read(String address, String service, String characteristic) { return backend().read(address, service, characteristic); } + @Override public boolean subscribe(String address, String service, String characteristic) { return backend().subscribe(address, service, characteristic); } + @Override public boolean unsubscribe(String address, String service, String characteristic) { return backend().unsubscribe(address, service, characteristic); } + @Override public boolean write(String address, String service, String characteristic, String value, boolean noResponse) { return backend().write(address, service, characteristic, value, noResponse); } + @Override public boolean writeQ(String address, String service, String characteristic, String value, boolean noResponse) { return backend().writeQ(address, service, characteristic, value, noResponse); } + @Override public boolean readDescriptor(String address, String service, String characteristic, String descriptor) { return backend().readDescriptor(address, service, characteristic, descriptor); } + @Override public boolean writeDescriptor(String address, String service, String characteristic, String descriptor, String value) { return backend().writeDescriptor(address, service, characteristic, descriptor, value); } + @Override public boolean rssi(String address) { return backend().rssi(address); } + @Override public boolean mtu(String address, int mtu) { return backend().mtu(address, mtu); } + @Override public boolean requestConnectionPriority(String address, String priority) { return backend().requestConnectionPriority(address, priority); } + @Override public boolean isInitialized() { return backend().isInitialized(); } + @Override public boolean isEnabled() { return backend().isEnabled(); } + @Override public boolean isScanning() { return backend().isScanning(); } + @Override public boolean wasConnected(String address) { return backend().wasConnected(address); } + @Override public boolean isConnected(String address) { return backend().isConnected(address); } + @Override public boolean isDiscovered(String address) { return backend().isDiscovered(address); } + @Override public boolean hasPermission() { return backend().hasPermission(); } + @Override public boolean requestPermission() { return backend().requestPermission(); } + @Override public boolean isLocationEnabled() { return backend().isLocationEnabled(); } + @Override public boolean requestLocation() { return backend().requestLocation(); } } diff --git a/javase/src/main/java/com/codename1/bluetoothle/BluetoothSimulator.java b/javase/src/main/java/com/codename1/bluetoothle/BluetoothSimulator.java index 26ee514..262497a 100644 --- a/javase/src/main/java/com/codename1/bluetoothle/BluetoothSimulator.java +++ b/javase/src/main/java/com/codename1/bluetoothle/BluetoothSimulator.java @@ -47,6 +47,27 @@ public static void clearPeripherals() { state.clearPeripherals(); } + /// Number of peripherals currently registered with the simulator. Useful + /// for tests asserting the effect of [#addPeripheral] / [#clearPeripherals]. + public static int registeredPeripheralCount() { + return state.snapshotPeripherals().size(); + } + + /// Whether a peripheral with the given MAC-style address is registered + /// (case-insensitive). Lets tests avoid running a scan just to check + /// whether [#addPeripheral] landed. + public static boolean isPeripheralRegistered(String address) { + if (address == null) { + return false; + } + for (SimulatedPeripheral p : state.snapshotPeripherals()) { + if (address.equalsIgnoreCase(p.getAddress())) { + return true; + } + } + return false; + } + /// When true, the simulator behaves as if the user has already toggled /// Bluetooth on. When false, [Bluetooth#isEnabled()] returns false and /// most operations fail with an `isDisabled` error until [Bluetooth#enable()] diff --git a/javase/src/main/java/com/codename1/bluetoothle/BluetoothSimulatorHooks.java b/javase/src/main/java/com/codename1/bluetoothle/BluetoothSimulatorHooks.java new file mode 100644 index 0000000..6bd99fb --- /dev/null +++ b/javase/src/main/java/com/codename1/bluetoothle/BluetoothSimulatorHooks.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) Codename One 2008-2026 - GPL v2 with Classpath Exception. + */ +package com.codename1.bluetoothle; + +import com.codename1.components.ToastBar; +import com.codename1.ui.Display; +import java.util.Map; + +/** + * Static actions wired to the simulator menu via + * {@code META-INF/codenameone/simulator-hooks.properties}. Each method: + * + *
    + *
  • is invoked on the CN1 EDT by the framework's + * {@code SimulatorHookLoader}, so it may call CN1 UI APIs directly;
  • + *
  • mutates the shared {@link BluetoothSimulator} state so a running + * {@code BTDemo} (or any app under test) observes the change immediately;
  • + *
  • is callable from unit tests without going through the menu — + * the action is the API, the menu is just one way to trigger it.
  • + *
+ * + * The "demo" peripheral mirrors the one used by + * {@code AbstractBluetoothSimulatorTest} so the manual simulator UX exposes + * the same shape developers see in tests. + */ +public final class BluetoothSimulatorHooks { + + public static final String DEMO_DEVICE_ADDRESS = "AA:BB:CC:DD:EE:01"; + public static final String DEMO_DEVICE_NAME = "SimulatedSensor"; + public static final String DEMO_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb"; + public static final String DEMO_CHAR_READ_UUID = "00002a29-0000-1000-8000-00805f9b34fb"; + public static final String DEMO_CHAR_WRITE_UUID = "00002a30-0000-1000-8000-00805f9b34fb"; + public static final String DEMO_CHAR_NOTIFY_UUID = "00002a31-0000-1000-8000-00805f9b34fb"; + public static final String DEMO_CCCD_DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805f9b34fb"; + + private BluetoothSimulatorHooks() {} + + /// Switch the JavaSE bridge to the real BLE backend (Rust + btleplug: + /// CoreBluetooth/BlueZ/WinRT). Falls back to the simulator backend with + /// a warning toast if the host OS or packaged helper isn't available. + /// After switching, the public {@code Bluetooth} API resets — call + /// {@code bt.initialize()} again. + public static void switchToNativeBle() { + if (!NativeBleBackend.isAvailable()) { + toast("Native BLE unavailable: helper not packaged for " + System.getProperty("os.name")); + return; + } + BluetoothNativeBridgeImpl.switchBackend(BluetoothNativeBridgeImpl.BACKEND_NATIVE_BLE); + toast("Switched to native BLE backend — call Initialize again"); + } + + /// Switch the JavaSE bridge back to the in-memory simulator backend. + /// Useful for returning to scripted tests after exercising real hardware. + public static void switchToSimulator() { + BluetoothNativeBridgeImpl.switchBackend(BluetoothNativeBridgeImpl.BACKEND_SIMULATOR); + toast("Switched to simulator backend — call Initialize again"); + } + + /** Flips the simulated adapter between enabled and disabled. */ + public static void toggleAdapter() { + boolean next = !BluetoothSimulator.isEnabled(); + BluetoothSimulator.setEnabled(next); + toast("Bluetooth adapter " + (next ? "ON" : "OFF")); + } + + /** + * Adds (or replaces) the standard demo peripheral. Same UUIDs and + * properties as the AbstractBluetoothSimulatorTest fixture so what you + * scan in BTDemo matches what tests assert against. + */ + public static void addDemoPeripheral() { + BluetoothSimulator.addPeripheral(buildDemoPeripheral()); + toast("Added demo peripheral " + DEMO_DEVICE_NAME); + } + + /** Removes every registered peripheral. */ + public static void clearPeripherals() { + BluetoothSimulator.clearPeripherals(); + toast("Cleared peripherals"); + } + + /** Disconnects every currently-connected peripheral as if the device went away. */ + public static void disconnectAll() { + Map connections = + BluetoothSimulator.state().snapshotConnections(); + int count = 0; + for (String address : connections.keySet()) { + BluetoothSimulator.disconnectFromRemote(address); + count++; + } + toast("Disconnected " + count + " peripheral" + (count == 1 ? "" : "s")); + } + + /** + * Primes the simulator to fail the next read against the demo + * characteristic with a scripted "read" error. API-only hook (no menu + * label); used by CN1 UnitTests via + * {@code CN.executeHook("bluetooth:primeReadFailure")} to verify the + * cn1lib's error-propagation path without manipulating internal state + * from common/ test code. + */ + public static void primeReadFailure() { + BluetoothSimulator.failNext("read", "read", "primed by test hook"); + } + + /** + * Pushes a single notification on the demo peripheral's notify + * characteristic. The byte value is a 1-byte rolling counter so repeated + * triggers produce visibly different payloads on the receiving side. + */ + public static void pushDemoNotification() { + byte[] payload = new byte[]{(byte) (System.currentTimeMillis() & 0xFF)}; + BluetoothSimulator.pushNotification( + DEMO_DEVICE_ADDRESS, DEMO_SERVICE_UUID, DEMO_CHAR_NOTIFY_UUID, payload); + toast("Pushed notification to " + DEMO_DEVICE_NAME); + } + + static SimulatedPeripheral buildDemoPeripheral() { + return new SimulatedPeripheral(DEMO_DEVICE_ADDRESS, DEMO_DEVICE_NAME) + .withRssi(-55) + .withService(new SimulatedService(DEMO_SERVICE_UUID) + .withCharacteristic(new SimulatedCharacteristic(DEMO_CHAR_READ_UUID) + .withProperty(SimulatedCharacteristic.PROPERTY_READ) + .withValue(new byte[]{0x42, 0x43, 0x44})) + .withCharacteristic(new SimulatedCharacteristic(DEMO_CHAR_WRITE_UUID) + .withProperty(SimulatedCharacteristic.PROPERTY_WRITE)) + .withCharacteristic(new SimulatedCharacteristic(DEMO_CHAR_NOTIFY_UUID) + .withProperty(SimulatedCharacteristic.PROPERTY_NOTIFY) + .withDescriptor(DEMO_CCCD_DESCRIPTOR_UUID, new byte[]{0, 0}))); + } + + /** + * Best-effort user feedback. Swallowed if no Display is available (e.g., + * inside a JUnit unit test that calls a hook method directly). + */ + private static void toast(String message) { + try { + if (Display.isInitialized()) { + ToastBar.showInfoMessage(message); + } + } catch (Throwable ignored) { + } + } +} diff --git a/javase/src/main/java/com/codename1/bluetoothle/NativeBleBackend.java b/javase/src/main/java/com/codename1/bluetoothle/NativeBleBackend.java new file mode 100644 index 0000000..5bfb87a --- /dev/null +++ b/javase/src/main/java/com/codename1/bluetoothle/NativeBleBackend.java @@ -0,0 +1,781 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.bluetoothle; + +import com.codename1.io.JSONParser; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/// Real BLE backend for the JavaSE port on macOS, Linux and Windows. +/// +/// Spawns a Rust helper executable (built from `javase/src/main/rust/`) that +/// wraps the [btleplug](https://github.com/deviceplug/btleplug) crate — +/// CoreBluetooth on macOS, BlueZ via D-Bus on Linux, WinRT on Windows — and +/// exchanges JSON-line commands and events with it over its stdin/stdout. +/// The helper is packaged as a classpath resource keyed by OS (see +/// {@link #helperResourcePath()}) and extracted to a temp file at first use. +/// +/// Each bridge method translates to one JSON command; helper events translate +/// back into the same {@link BluetoothCallbackRegistry#sendResult} calls the +/// {@link SimulatorBluetoothBackend} makes, so the public {@link Bluetooth} +/// API behaves identically regardless of which backend is active. +final class NativeBleBackend implements BluetoothNativeBridge { + + private static final String HELPER_RESOURCE_DIR = "/com/codename1/bluetoothle/native/"; + + private Process helper; + private BufferedWriter helperIn; + private Thread eventReader; + private final Object writerLock = new Object(); + private final AtomicLong nextCommandId = new AtomicLong(1); + + private volatile boolean initialized; + private volatile boolean enabled; + private volatile boolean scanning; + private final Set connected = Collections.newSetFromMap(new ConcurrentHashMap()); + private final Set everConnected = Collections.newSetFromMap(new ConcurrentHashMap()); + private final Set discovered = Collections.newSetFromMap(new ConcurrentHashMap()); + + /// Returns true iff this backend can run in the current process — i.e., the + /// packaged Rust helper for this host OS is present on the classpath. The + /// matching binary only ships when the cn1lib was built on the same OS + /// (or in a fat-jar published by the maintainer), so a JVM running on + /// Windows against a Mac-built jar will see this return false and fall + /// back to the simulator via {@link BluetoothNativeBridgeImpl}. + static boolean isAvailable() { + String path = helperResourcePath(); + if (path == null) { + return false; + } + InputStream in = NativeBleBackend.class.getResourceAsStream(path); + if (in == null) { + return false; + } + try { in.close(); } catch (IOException ignored) {} + return true; + } + + /// Classpath location of the helper binary appropriate for the current OS, + /// or null if this OS isn't supported by btleplug. + static String helperResourcePath() { + String os = System.getProperty("os.name", "").toLowerCase(); + if (os.contains("mac") || os.contains("darwin")) { + return HELPER_RESOURCE_DIR + "macos/cn1-ble-helper"; + } + if (os.contains("linux")) { + return HELPER_RESOURCE_DIR + "linux/cn1-ble-helper"; + } + if (os.contains("windows")) { + return HELPER_RESOURCE_DIR + "windows/cn1-ble-helper.exe"; + } + return null; + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("windows"); + } + + private synchronized void ensureStarted() { + if (helper != null && helper.isAlive()) { + return; + } + try { + File binary = extractHelper(); + ProcessBuilder pb = new ProcessBuilder(binary.getAbsolutePath()); + // Keep stderr visible to the JVM operator — that's where the + // helper logs malformed-line / decoding errors. + pb.redirectErrorStream(false); + helper = pb.start(); + helperIn = new BufferedWriter(new OutputStreamWriter(helper.getOutputStream(), StandardCharsets.UTF_8)); + startEventReader(helper); + startStderrPump(helper); + // The helper's stateChanged event will land on the reader thread + // shortly and flip `enabled`. + } catch (IOException ex) { + System.err.println("NativeBleBackend: failed to start helper: " + ex.getMessage()); + ex.printStackTrace(); + } + } + + /// Best-effort teardown. Called by {@code BluetoothNativeBridgeImpl} when + /// the user switches away from this backend at runtime so the helper + /// process and its OS BLE handles don't outlive the switch. Safe to call + /// when the helper was never started. + synchronized void shutdown() { + if (helper == null) { + return; + } + try { + if (helperIn != null) { + synchronized (writerLock) { + try { + helperIn.write("{\"cmd\":\"shutdown\"}\n"); + helperIn.flush(); + } catch (IOException ignored) { + } + try { helperIn.close(); } catch (IOException ignored) {} + } + } + } finally { + helper.destroy(); + helper = null; + helperIn = null; + initialized = false; + enabled = false; + scanning = false; + connected.clear(); + discovered.clear(); + } + } + + private File extractHelper() throws IOException { + String resourcePath = helperResourcePath(); + if (resourcePath == null) { + throw new IOException("OS not supported by btleplug: " + System.getProperty("os.name")); + } + InputStream src = NativeBleBackend.class.getResourceAsStream(resourcePath); + if (src == null) { + throw new IOException("helper resource not on classpath: " + resourcePath); + } + String filename = isWindows() ? "cn1-ble-helper.exe" : "cn1-ble-helper"; + File out = new File(System.getProperty("java.io.tmpdir"), filename); + // Always rewrite — cheap and avoids stale binaries from older builds. + try (FileOutputStream sink = new FileOutputStream(out)) { + byte[] buf = new byte[8192]; + int n; + while ((n = src.read(buf)) >= 0) { + sink.write(buf, 0, n); + } + } finally { + try { src.close(); } catch (IOException ignored) {} + } + // POSIX execute bit — no-op on Windows where .exe is recognized. + Set perms = new HashSet<>(); + perms.add(PosixFilePermission.OWNER_READ); + perms.add(PosixFilePermission.OWNER_WRITE); + perms.add(PosixFilePermission.OWNER_EXECUTE); + try { + Files.setPosixFilePermissions(out.toPath(), perms); + } catch (IOException | UnsupportedOperationException ignored) { + out.setExecutable(true, true); + } + return out; + } + + private void startEventReader(Process p) { + eventReader = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + handleHelperLine(line); + } + } catch (IOException ex) { + // Process likely died; nothing to do. + } + }, "cn1ble-helper-stdout"); + eventReader.setDaemon(true); + eventReader.start(); + } + + private void startStderrPump(Process p) { + Thread t = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + System.err.println("[Cn1BleHelper] " + line); + } + } catch (IOException ignored) { + } + }, "cn1ble-helper-stderr"); + t.setDaemon(true); + t.start(); + } + + @SuppressWarnings("unchecked") + private void handleHelperLine(String line) { + if (line.isEmpty()) return; + Map obj; + try { + obj = new JSONParser().parseJSON(new StringReader(line)); + } catch (Throwable t) { + System.err.println("NativeBleBackend: malformed helper output: " + line); + return; + } + Object ev = obj.get("event"); + if (ev instanceof String) { + handleEvent((String) ev, obj); + return; + } + // Acks are informational — the cn1lib's contract is event-driven, not + // request/response — but they let us spot rejected commands at debug time. + Object ack = obj.get("ack"); + Object ok = obj.get("ok"); + if (ack != null && Boolean.FALSE.equals(ok)) { + System.err.println("NativeBleBackend: helper rejected ack=" + ack + " error=" + obj.get("error")); + } + } + + private void handleEvent(String event, Map obj) { + switch (event) { + case "stateChanged": + String state = stringOr(obj, "state", "unknown"); + enabled = "poweredOn".equals(state); + if (initialized) { + BluetoothCallbackRegistry.sendResult("initialize", + JsonBuilder.start().put("status", enabled ? "enabled" : "disabled").end(), true, true); + } + break; + case "scanResult": { + if (!scanning) break; + BluetoothCallbackRegistry.sendResult("startScan", + JsonBuilder.start() + .put("status", "scanResult") + .put("address", stringOr(obj, "address", "")) + .put("name", stringOr(obj, "name", "")) + .put("rssi", intOr(obj, "rssi", -127)) + .put("advertisement", stringOr(obj, "advertisement", "")) + .end(), true, true); + break; + } + case "scanStopped": + scanning = false; + BluetoothCallbackRegistry.sendResult("stopScan", + JsonBuilder.start().put("status", "scanStopped").end(), true); + break; + case "connected": { + String address = stringOr(obj, "address", ""); + connected.add(address); + everConnected.add(address); + BluetoothCallbackRegistry.sendResult("connect", + JsonBuilder.start() + .put("status", "connected") + .put("address", address) + .put("name", stringOr(obj, "name", "")) + .end(), true, true); + break; + } + case "disconnected": { + String address = stringOr(obj, "address", ""); + connected.remove(address); + discovered.remove(address); + BluetoothCallbackRegistry.sendResult("disconnect", + JsonBuilder.start() + .put("status", "disconnected") + .put("address", address) + .put("name", "") + .end(), true); + break; + } + case "discovered": { + String address = stringOr(obj, "address", ""); + discovered.add(address); + BluetoothCallbackRegistry.sendResult("discover", + buildDiscoveredJson(obj), true); + break; + } + case "readResult": + BluetoothCallbackRegistry.sendResult("read", + JsonBuilder.start() + .put("status", "read") + .put("address", stringOr(obj, "address", "")) + .put("service", stringOr(obj, "service", "")) + .put("characteristic", stringOr(obj, "characteristic", "")) + .put("value", stringOr(obj, "value", "")) + .end(), true); + break; + case "writeResult": + BluetoothCallbackRegistry.sendResult("write", + JsonBuilder.start() + .put("status", "written") + .put("address", stringOr(obj, "address", "")) + .put("service", stringOr(obj, "service", "")) + .put("characteristic", stringOr(obj, "characteristic", "")) + .put("value", stringOr(obj, "value", "")) + .end(), true); + break; + case "subscribed": + BluetoothCallbackRegistry.sendResult("subscribe", + JsonBuilder.start() + .put("status", "subscribed") + .put("address", stringOr(obj, "address", "")) + .put("service", stringOr(obj, "service", "")) + .put("characteristic", stringOr(obj, "characteristic", "")) + .end(), true, true); + break; + case "notification": + BluetoothCallbackRegistry.sendResult("subscribe", + JsonBuilder.start() + .put("status", "subscribedResult") + .put("address", stringOr(obj, "address", "")) + .put("service", stringOr(obj, "service", "")) + .put("characteristic", stringOr(obj, "characteristic", "")) + .put("value", stringOr(obj, "value", "")) + .end(), true, true); + break; + case "unsubscribed": + BluetoothCallbackRegistry.sendResult("unsubscribe", + JsonBuilder.start() + .put("status", "unsubscribed") + .put("address", stringOr(obj, "address", "")) + .put("service", stringOr(obj, "service", "")) + .put("characteristic", stringOr(obj, "characteristic", "")) + .end(), true); + break; + case "error": + String command = stringOr(obj, "command", ""); + if (!command.isEmpty()) { + BluetoothCallbackRegistry.sendResult(command, + JsonBuilder.start() + .put("error", command) + .put("message", stringOr(obj, "message", "")) + .end(), false); + } + break; + default: + // Unknown event — log but don't crash. + System.err.println("NativeBleBackend: unknown event '" + event + "'"); + } + } + + @SuppressWarnings("unchecked") + private String buildDiscoveredJson(Map obj) { + StringBuilder services = new StringBuilder("["); + boolean firstS = true; + Object svcArr = obj.get("services"); + if (svcArr instanceof List) { + for (Object svcObj : (List) svcArr) { + if (!(svcObj instanceof Map)) continue; + Map svc = (Map) svcObj; + if (!firstS) services.append(','); + firstS = false; + StringBuilder chars = new StringBuilder("["); + boolean firstC = true; + Object chArr = svc.get("characteristics"); + if (chArr instanceof List) { + for (Object chObj : (List) chArr) { + if (!(chObj instanceof Map)) continue; + Map ch = (Map) chObj; + if (!firstC) chars.append(','); + firstC = false; + StringBuilder props = new StringBuilder("["); + boolean firstP = true; + Object pArr = ch.get("properties"); + if (pArr instanceof List) { + for (Object p : (List) pArr) { + if (!firstP) props.append(','); + firstP = false; + props.append('"').append(String.valueOf(p)).append('"'); + } + } + props.append(']'); + chars.append(JsonBuilder.start() + .put("characteristic", stringOr(ch, "uuid", "")) + .putRaw("properties", props.toString()) + .end()); + } + } + chars.append(']'); + services.append(JsonBuilder.start() + .put("service", stringOr(svc, "uuid", "")) + .putRaw("characteristics", chars.toString()) + .end()); + } + } + services.append(']'); + return JsonBuilder.start() + .put("status", "discovered") + .put("address", stringOr(obj, "address", "")) + .put("name", stringOr(obj, "name", "")) + .putRaw("services", services.toString()) + .end(); + } + + private static String stringOr(Map m, String key, String def) { + Object v = m.get(key); + return v == null ? def : v.toString(); + } + + private static int intOr(Map m, String key, int def) { + Object v = m.get(key); + if (v instanceof Number) return ((Number) v).intValue(); + if (v instanceof String) { + try { return Integer.parseInt((String) v); } catch (NumberFormatException ignored) {} + } + return def; + } + + private void sendCommand(String json) { + ensureStarted(); + if (helperIn == null) return; + synchronized (writerLock) { + try { + helperIn.write(json); + helperIn.write('\n'); + helperIn.flush(); + } catch (IOException ex) { + System.err.println("NativeBleBackend: write failed: " + ex.getMessage()); + } + } + } + + private long nextId() { + return nextCommandId.getAndIncrement(); + } + + // ------------ BluetoothNativeBridge methods ------------ + + @Override public boolean isSupported() { + return true; + } + + @Override public boolean initialize(boolean request, boolean statusReceiver, String restoreKey) { + // Set the flag BEFORE ensureStarted so that the helper's startup + // stateChanged event (which can arrive before this method returns) + // doesn't lose the race with the initialize listener. + initialized = true; + ensureStarted(); + sendCommand(JsonBuilder.start().put("cmd", "initialize").put("id", (int) nextId()).end()); + // The helper's stateChanged event will fire and propagate the + // initialize callback (see handleEvent("stateChanged")). + return true; + } + + @Override public boolean enable() { + // CoreBluetooth doesn't expose programmatic Bluetooth toggling; the + // best we can do is surface a clear "user must enable" status to the + // listener so the public API doesn't deadlock waiting on an enable + // event that never lands. + BluetoothCallbackRegistry.sendResult("enable", + JsonBuilder.start() + .put("status", enabled ? "enabled" : "disabled") + .put("message", "Open System Settings → Bluetooth to toggle the adapter") + .end(), enabled); + return true; + } + + @Override public boolean disable() { + BluetoothCallbackRegistry.sendResult("disable", + JsonBuilder.start() + .put("status", enabled ? "enabled" : "disabled") + .put("message", "Open System Settings → Bluetooth to toggle the adapter") + .end(), !enabled); + return true; + } + + @Override public boolean startScan(String servicesJson, boolean allowDuplicates, + int scanMode, int matchMode, int matchNum, int callbackType) { + if (!enabled) { + BluetoothCallbackRegistry.sendResult("startScan", + JsonBuilder.start() + .put("error", "isDisabled") + .put("message", "Bluetooth not enabled") + .end(), false); + return true; + } + scanning = true; + String servicesArray = (servicesJson == null || servicesJson.isEmpty()) ? "[]" : servicesJson; + sendCommand("{\"cmd\":\"startScan\",\"id\":" + nextId() + ",\"services\":" + servicesArray + + ",\"allowDuplicates\":" + allowDuplicates + "}"); + BluetoothCallbackRegistry.sendResult("startScan", + JsonBuilder.start().put("status", "scanStarted").end(), true, true); + return true; + } + + @Override public boolean stopScan() { + scanning = false; + sendCommand(JsonBuilder.start().put("cmd", "stopScan").put("id", (int) nextId()).end()); + return true; + } + + @Override public boolean retrieveConnected(String servicesJson) { + // CoreBluetooth has retrieveConnectedPeripherals; not wired through + // yet. Emit empty list so callers don't hang. + BluetoothCallbackRegistry.sendResult("retrieveConnected", + JsonBuilder.start() + .put("status", "retrieveConnected") + .putRaw("devices", "[]") + .end(), true); + return true; + } + + @Override public boolean connect(String address) { + sendCommand("{\"cmd\":\"connect\",\"id\":" + nextId() + ",\"address\":\"" + escape(address) + "\"}"); + return true; + } + + @Override public boolean reconnect(String address) { + return connect(address); + } + + @Override public boolean disconnect(String address) { + sendCommand("{\"cmd\":\"disconnect\",\"id\":" + nextId() + ",\"address\":\"" + escape(address) + "\"}"); + return true; + } + + @Override public boolean close(String address) { + // Treat like disconnect; helper has no separate "close" concept. + return disconnect(address); + } + + @Override public boolean discover(String address) { + sendCommand("{\"cmd\":\"discover\",\"id\":" + nextId() + ",\"address\":\"" + escape(address) + "\"}"); + return true; + } + + @Override public boolean services(String address, String servicesJson) { + // The discover() event already includes services; no additional helper roundtrip. + BluetoothCallbackRegistry.sendResult("services", + JsonBuilder.start() + .put("status", "services") + .put("address", address) + .putRaw("services", "[]") + .end(), true); + return true; + } + + @Override public boolean characteristics(String address, String service, String characteristicsJson) { + BluetoothCallbackRegistry.sendResult("characteristics", + JsonBuilder.start() + .put("status", "characteristics") + .put("address", address) + .put("service", service) + .putRaw("characteristics", "[]") + .end(), true); + return true; + } + + @Override public boolean descriptors(String address, String service, String characteristic) { + BluetoothCallbackRegistry.sendResult("descriptors", + JsonBuilder.start() + .put("status", "descriptors") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .putRaw("descriptors", "[]") + .end(), true); + return true; + } + + @Override public boolean read(String address, String service, String characteristic) { + sendCommand("{\"cmd\":\"read\",\"id\":" + nextId() + + ",\"address\":\"" + escape(address) + + "\",\"service\":\"" + escape(service) + + "\",\"characteristic\":\"" + escape(characteristic) + "\"}"); + return true; + } + + @Override public boolean subscribe(String address, String service, String characteristic) { + sendCommand("{\"cmd\":\"subscribe\",\"id\":" + nextId() + + ",\"address\":\"" + escape(address) + + "\",\"service\":\"" + escape(service) + + "\",\"characteristic\":\"" + escape(characteristic) + "\"}"); + return true; + } + + @Override public boolean unsubscribe(String address, String service, String characteristic) { + sendCommand("{\"cmd\":\"unsubscribe\",\"id\":" + nextId() + + ",\"address\":\"" + escape(address) + + "\",\"service\":\"" + escape(service) + + "\",\"characteristic\":\"" + escape(characteristic) + "\"}"); + return true; + } + + @Override public boolean write(String address, String service, String characteristic, String value, boolean noResponse) { + sendCommand("{\"cmd\":\"write\",\"id\":" + nextId() + + ",\"address\":\"" + escape(address) + + "\",\"service\":\"" + escape(service) + + "\",\"characteristic\":\"" + escape(characteristic) + + "\",\"value\":\"" + escape(value == null ? "" : value) + + "\",\"noResponse\":" + noResponse + "}"); + return true; + } + + @Override public boolean writeQ(String address, String service, String characteristic, String value, boolean noResponse) { + return write(address, service, characteristic, value, noResponse); + } + + @Override public boolean readDescriptor(String address, String service, String characteristic, String descriptor) { + BluetoothCallbackRegistry.sendResult("readDescriptor", + JsonBuilder.start() + .put("error", "readDescriptor") + .put("message", "Descriptor I/O not yet wired in CoreBluetooth backend") + .end(), false); + return true; + } + + @Override public boolean writeDescriptor(String address, String service, String characteristic, String descriptor, String value) { + BluetoothCallbackRegistry.sendResult("writeDescriptor", + JsonBuilder.start() + .put("error", "writeDescriptor") + .put("message", "Descriptor I/O not yet wired in CoreBluetooth backend") + .end(), false); + return true; + } + + @Override public boolean rssi(String address) { + // CoreBluetooth gives RSSI via peripheral.readRSSI(); not wired yet. + BluetoothCallbackRegistry.sendResult("rssi", + JsonBuilder.start() + .put("status", "rssi") + .put("address", address) + .put("rssi", -127) + .end(), true); + return true; + } + + @Override public boolean mtu(String address, int mtu) { + BluetoothCallbackRegistry.sendResult("mtu", + JsonBuilder.start() + .put("status", "mtu") + .put("address", address) + .put("mtu", 185) // CoreBluetooth's typical default + .end(), true); + return true; + } + + @Override public boolean requestConnectionPriority(String address, String priority) { + // Not applicable to CoreBluetooth; ack so the listener doesn't hang. + BluetoothCallbackRegistry.sendResult("requestConnectionPriority", + JsonBuilder.start() + .put("status", "connectionPriorityRequested") + .put("address", address) + .put("connectionPriority", priority == null ? "" : priority) + .end(), true); + return true; + } + + @Override public boolean isInitialized() { + BluetoothCallbackRegistry.sendResult("isInitialized", + JsonBuilder.start().put("isInitialized", initialized).end(), true); + return true; + } + + @Override public boolean isEnabled() { + BluetoothCallbackRegistry.sendResult("isEnabled", + JsonBuilder.start().put("isEnabled", enabled).end(), true); + return true; + } + + @Override public boolean isScanning() { + BluetoothCallbackRegistry.sendResult("isScanning", + JsonBuilder.start().put("isScanning", scanning).end(), true); + return true; + } + + @Override public boolean wasConnected(String address) { + BluetoothCallbackRegistry.sendResult("wasConnected", + JsonBuilder.start() + .put("address", address) + .put("wasConnected", everConnected.contains(address)) + .end(), true); + return true; + } + + @Override public boolean isConnected(String address) { + BluetoothCallbackRegistry.sendResult("isConnected", + JsonBuilder.start() + .put("address", address) + .put("isConnected", connected.contains(address)) + .end(), true); + return true; + } + + @Override public boolean isDiscovered(String address) { + BluetoothCallbackRegistry.sendResult("isDiscovered", + JsonBuilder.start() + .put("address", address) + .put("isDiscovered", discovered.contains(address)) + .end(), true); + return true; + } + + @Override public boolean hasPermission() { + // macOS handles BT permission via TCC; we treat "enabled" as "permitted" + // since reaching poweredOn requires both the adapter on and TCC granted. + BluetoothCallbackRegistry.sendResult("hasPermission", + JsonBuilder.start().put("hasPermission", true).end(), true); + return true; + } + + @Override public boolean requestPermission() { + BluetoothCallbackRegistry.sendResult("requestPermission", + JsonBuilder.start().put("requestPermission", true).end(), true); + return true; + } + + @Override public boolean isLocationEnabled() { + // Location not required for BLE scanning on macOS, unlike Android. + BluetoothCallbackRegistry.sendResult("isLocationEnabled", + JsonBuilder.start().put("isLocationEnabled", true).end(), true); + return true; + } + + @Override public boolean requestLocation() { + BluetoothCallbackRegistry.sendResult("requestLocation", + JsonBuilder.start().put("requestLocation", true).end(), true); + return true; + } + + private static String escape(String s) { + if (s == null) return ""; + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 0x20) { + String t = "000" + Integer.toHexString(c); + sb.append("\\u").append(t.substring(t.length() - 4)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } +} diff --git a/javase/src/main/java/com/codename1/bluetoothle/SimulatorBluetoothBackend.java b/javase/src/main/java/com/codename1/bluetoothle/SimulatorBluetoothBackend.java new file mode 100644 index 0000000..7e43f28 --- /dev/null +++ b/javase/src/main/java/com/codename1/bluetoothle/SimulatorBluetoothBackend.java @@ -0,0 +1,670 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.bluetoothle; + +import com.codename1.ui.Display; + +import java.util.List; +import java.util.Map; + +/// Scriptable in-memory backend that powers the cn1lib's JavaSE port. +/// +/// Backed by [BluetoothSimulator]: tests and the simulator menu drive its +/// state directly. All bridge methods return true (action accepted for +/// dispatch) and deliver real success/error payloads asynchronously through +/// [BluetoothCallbackRegistry.sendResult] — exactly the contract the iOS and +/// Android implementations honor. +/// +/// Selected by [BluetoothNativeBridgeImpl] when +/// `cn1.bluetoothle.javase.backend=simulator` (the default). The +/// [CoreBluetoothBackend] is the alternative for real BLE on macOS. +final class SimulatorBluetoothBackend implements BluetoothNativeBridge { + + private boolean bluetoothUsageDescriptionChecked; + + @Override + public boolean isSupported() { + installBuildHints(); + return true; + } + + @Override + public boolean initialize(boolean request, boolean statusReceiver, String restoreKey) { + installBuildHints(); + SimulatorState s = BluetoothSimulator.state(); + s.setInitialized(true); + if (request && !s.isEnabled()) { + s.setEnabled(true); + } + final String status = s.isEnabled() ? "enabled" : "disabled"; + // keepCallback=true so subsequent state-change broadcasts (eg + // simulator-driven enable/disable) can deliver to the same listener. + s.runSync(() -> BluetoothCallbackRegistry.sendResult("initialize", + JsonBuilder.start().put("status", status).end(), true, true)); + return true; + } + + @Override + public boolean enable() { + SimulatorState s = BluetoothSimulator.state(); + s.setEnabled(true); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("enable", + JsonBuilder.start().put("status", "enabled").end(), true)); + return true; + } + + @Override + public boolean disable() { + SimulatorState s = BluetoothSimulator.state(); + s.setEnabled(false); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("disable", + JsonBuilder.start().put("status", "disabled").end(), true)); + return true; + } + + @Override + public boolean startScan(String servicesJson, boolean allowDuplicates, int scanMode, int matchMode, int matchNum, int callbackType) { + SimulatorState s = BluetoothSimulator.state(); + if (failIfPresent("startScan")) return true; + if (!s.isEnabled()) { + return errorAsync("startScan", "isDisabled", "Bluetooth not enabled"); + } + s.setScanning(true); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("startScan", + JsonBuilder.start().put("status", "scanStarted").end(), true, true)); + int delay = 0; + for (SimulatedPeripheral p : s.snapshotPeripherals()) { + final int extra = delay; + s.scheduleAfter(extra, () -> { + if (!s.isScanning()) return; + String json = JsonBuilder.start() + .put("status", "scanResult") + .put("address", p.getAddress()) + .put("name", p.getName()) + .put("rssi", p.getRssi()) + .put("advertisement", Base64Util.encode(p.getAdvertisementData() == null ? new byte[0] : p.getAdvertisementData())) + .end(); + BluetoothCallbackRegistry.sendResult("startScan", json, true, true); + }); + delay += 5; + } + return true; + } + + @Override + public boolean stopScan() { + SimulatorState s = BluetoothSimulator.state(); + s.setScanning(false); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("stopScan", + JsonBuilder.start().put("status", "scanStopped").end(), true)); + return true; + } + + @Override + public boolean retrieveConnected(String servicesJson) { + SimulatorState s = BluetoothSimulator.state(); + s.schedule(() -> { + StringBuilder arr = new StringBuilder("["); + boolean firstP = true; + for (SimulatedPeripheral p : s.snapshotPeripherals()) { + SimulatorState.ConnectionState cs = s.connectionFor(p.getAddress(), false); + if (cs == null || !cs.connected) continue; + if (!firstP) arr.append(','); + firstP = false; + arr.append(JsonBuilder.start() + .put("address", p.getAddress()) + .put("name", p.getName()) + .end()); + } + arr.append(']'); + String json = JsonBuilder.start() + .put("status", "retrieveConnected") + .putRaw("devices", arr.toString()) + .end(); + BluetoothCallbackRegistry.sendResult("retrieveConnected", json, true); + }); + return true; + } + + @Override + public boolean connect(String address) { + SimulatorState s = BluetoothSimulator.state(); + if (failIfPresent("connect")) return true; + if (!s.isEnabled()) return errorAsync("connect", "isDisabled", "Bluetooth not enabled"); + SimulatedPeripheral p = s.getPeripheral(address); + if (p == null) return errorAsync("connect", "neverConnected", "No such device: " + address); + SimulatorState.ConnectionState cs = s.connectionFor(address, true); + if (cs.connected) return errorAsync("connect", "isNotDisconnected", "Device isn't disconnected"); + cs.connected = true; + cs.wasConnected = true; + s.schedule(() -> BluetoothCallbackRegistry.sendResult("connect", + JsonBuilder.start() + .put("status", "connected") + .put("address", p.getAddress()) + .put("name", p.getName()) + .end(), true, true)); + return true; + } + + @Override + public boolean reconnect(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + if (p == null) return errorAsync("reconnect", "neverConnected", "Never connected to device"); + SimulatorState.ConnectionState cs = s.connectionFor(address, true); + if (!cs.wasConnected) return errorAsync("reconnect", "neverConnected", "Never connected to device"); + cs.connected = true; + s.schedule(() -> BluetoothCallbackRegistry.sendResult("reconnect", + JsonBuilder.start() + .put("status", "connected") + .put("address", p.getAddress()) + .put("name", p.getName()) + .end(), true, true)); + return true; + } + + @Override + public boolean disconnect(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + SimulatorState.ConnectionState cs = s.connectionFor(address, false); + if (cs == null || !cs.connected) { + return errorAsync("disconnect", "isDisconnected", "Device is disconnected"); + } + cs.connected = false; + cs.subscriptions.clear(); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("disconnect", + JsonBuilder.start() + .put("status", "disconnected") + .put("address", address) + .put("name", p == null ? "" : p.getName()) + .end(), true)); + return true; + } + + @Override + public boolean close(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatorState.ConnectionState cs = s.connectionFor(address, false); + if (cs != null) { + cs.connected = false; + cs.discovered = false; + cs.subscriptions.clear(); + } + s.schedule(() -> BluetoothCallbackRegistry.sendResult("close", + JsonBuilder.start() + .put("status", "closed") + .put("address", address) + .end(), true)); + return true; + } + + @Override + public boolean discover(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + SimulatorState.ConnectionState cs = s.connectionFor(address, false); + if (p == null || cs == null || !cs.connected) { + return errorAsync("discover", "isDisconnected", "Device is disconnected"); + } + cs.discovered = true; + s.schedule(() -> BluetoothCallbackRegistry.sendResult("discover", + buildDiscoveredJson(p), true)); + return true; + } + + @Override + public boolean services(String address, String servicesJson) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + if (p == null) return errorAsync("services", "services", "Unknown device"); + s.schedule(() -> { + StringBuilder arr = new StringBuilder("["); + boolean firstS = true; + for (SimulatedService svc : p.getServices()) { + if (!firstS) arr.append(','); + firstS = false; + arr.append('"').append(svc.getUuid()).append('"'); + } + arr.append(']'); + String json = JsonBuilder.start() + .put("status", "services") + .put("address", address) + .putRaw("services", arr.toString()) + .end(); + BluetoothCallbackRegistry.sendResult("services", json, true); + }); + return true; + } + + @Override + public boolean characteristics(String address, String service, String characteristicsJson) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + SimulatedService svc = p == null ? null : p.findService(service); + if (svc == null) return errorAsync("characteristics", "characteristics", "Unknown service"); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("characteristics", + JsonBuilder.start() + .put("status", "characteristics") + .put("address", address) + .put("service", service) + .putRaw("characteristics", buildCharacteristicsArray(svc)) + .end(), true)); + return true; + } + + @Override + public boolean descriptors(String address, String service, String characteristic) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + SimulatedService svc = p == null ? null : p.findService(service); + SimulatedCharacteristic ch = svc == null ? null : svc.findCharacteristic(characteristic); + if (ch == null) return errorAsync("descriptors", "descriptors", "Unknown characteristic"); + s.schedule(() -> { + StringBuilder arr = new StringBuilder("["); + boolean firstD = true; + for (Map.Entry e : ch.getDescriptors().entrySet()) { + if (!firstD) arr.append(','); + firstD = false; + arr.append('"').append(e.getKey()).append('"'); + } + arr.append(']'); + BluetoothCallbackRegistry.sendResult("descriptors", + JsonBuilder.start() + .put("status", "descriptors") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .putRaw("descriptors", arr.toString()) + .end(), true); + }); + return true; + } + + @Override + public boolean read(String address, String service, String characteristic) { + SimulatorState s = BluetoothSimulator.state(); + if (failIfPresent("read")) return true; + SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); + if (ch == null) return errorAsync("read", "read", "Unknown characteristic"); + byte[] value = ch.getValue(); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("read", + JsonBuilder.start() + .put("status", "read") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .put("value", Base64Util.encode(value == null ? new byte[0] : value)) + .end(), true)); + return true; + } + + @Override + public boolean subscribe(String address, String service, String characteristic) { + SimulatorState s = BluetoothSimulator.state(); + if (failIfPresent("subscribe")) return true; + SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); + if (ch == null) return errorAsync("subscribe", "subscribe", "Unknown characteristic"); + SimulatorState.ConnectionState cs = s.connectionFor(address, true); + cs.subscriptions.add(SimulatorState.subscriptionKey(service, characteristic)); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("subscribe", + JsonBuilder.start() + .put("status", "subscribed") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .end(), true, true)); + return true; + } + + @Override + public boolean unsubscribe(String address, String service, String characteristic) { + SimulatorState s = BluetoothSimulator.state(); + SimulatorState.ConnectionState cs = s.connectionFor(address, false); + if (cs != null) { + cs.subscriptions.remove(SimulatorState.subscriptionKey(service, characteristic)); + } + s.schedule(() -> BluetoothCallbackRegistry.sendResult("unsubscribe", + JsonBuilder.start() + .put("status", "unsubscribed") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .end(), true)); + return true; + } + + @Override + public boolean write(String address, String service, String characteristic, String value, boolean noResponse) { + SimulatorState s = BluetoothSimulator.state(); + if (failIfPresent("write")) return true; + SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); + if (ch == null) return errorAsync("write", "write", "Unknown characteristic"); + ch.setValueInternal(Base64Util.decode(value)); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("write", + JsonBuilder.start() + .put("status", "written") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .put("value", value == null ? "" : value) + .end(), true)); + return true; + } + + @Override + public boolean writeQ(String address, String service, String characteristic, String value, boolean noResponse) { + return write(address, service, characteristic, value, noResponse); + } + + @Override + public boolean readDescriptor(String address, String service, String characteristic, String descriptor) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); + if (ch == null || !ch.hasDescriptor(descriptor)) { + return errorAsync("readDescriptor", "readDescriptor", "Unknown descriptor"); + } + byte[] dv = ch.getDescriptorValue(descriptor); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("readDescriptor", + JsonBuilder.start() + .put("status", "readDescriptor") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .put("descriptor", descriptor) + .put("value", Base64Util.encode(dv == null ? new byte[0] : dv)) + .end(), true)); + return true; + } + + @Override + public boolean writeDescriptor(String address, String service, String characteristic, String descriptor, String value) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedCharacteristic ch = lookupCharacteristic(address, service, characteristic); + if (ch == null || !ch.hasDescriptor(descriptor)) { + return errorAsync("writeDescriptor", "writeDescriptor", "Unknown descriptor"); + } + ch.setDescriptorValue(descriptor, Base64Util.decode(value)); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("writeDescriptor", + JsonBuilder.start() + .put("status", "writtenDescriptor") + .put("address", address) + .put("service", service) + .put("characteristic", characteristic) + .put("descriptor", descriptor) + .end(), true)); + return true; + } + + @Override + public boolean rssi(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + int r = p == null ? -127 : p.getRssi(); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("rssi", + JsonBuilder.start() + .put("status", "rssi") + .put("address", address) + .put("rssi", r) + .end(), true)); + return true; + } + + @Override + public boolean mtu(String address, int mtu) { + SimulatorState s = BluetoothSimulator.state(); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("mtu", + JsonBuilder.start() + .put("status", "mtu") + .put("address", address) + .put("mtu", mtu) + .end(), true)); + return true; + } + + @Override + public boolean requestConnectionPriority(String address, String priority) { + SimulatorState s = BluetoothSimulator.state(); + s.schedule(() -> BluetoothCallbackRegistry.sendResult("requestConnectionPriority", + JsonBuilder.start() + .put("status", "connectionPriorityRequested") + .put("address", address) + .put("connectionPriority", priority == null ? "" : priority) + .end(), true)); + return true; + } + + // Pure state queries are delivered synchronously: the Android plugin + // implements them with callbackContext.success(...) inside the action + // handler, so by the time the dispatch call returns the callback has + // already received its payload. Keeping them sync here removes a + // scheduler hop and matches the contract the public Bluetooth API + // relies on (callback.getResponseAndWait(500) sees a complete callback + // immediately). + + @Override + public boolean isInitialized() { + SimulatorState s = BluetoothSimulator.state(); + boolean v = s.isInitialized(); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("isInitialized", + JsonBuilder.start().put("isInitialized", v).end(), true)); + return true; + } + + @Override + public boolean isEnabled() { + SimulatorState s = BluetoothSimulator.state(); + boolean v = s.isEnabled(); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("isEnabled", + JsonBuilder.start().put("isEnabled", v).end(), true)); + return true; + } + + @Override + public boolean isScanning() { + SimulatorState s = BluetoothSimulator.state(); + boolean v = s.isScanning(); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("isScanning", + JsonBuilder.start().put("isScanning", v).end(), true)); + return true; + } + + @Override + public boolean wasConnected(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatorState.ConnectionState cs = s.connectionFor(address, false); + boolean v = cs != null && cs.wasConnected; + s.runSync(() -> BluetoothCallbackRegistry.sendResult("wasConnected", + JsonBuilder.start() + .put("address", address) + .put("wasConnected", v) + .end(), true)); + return true; + } + + @Override + public boolean isConnected(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatorState.ConnectionState cs = s.connectionFor(address, false); + boolean v = cs != null && cs.connected; + s.runSync(() -> BluetoothCallbackRegistry.sendResult("isConnected", + JsonBuilder.start() + .put("address", address) + .put("isConnected", v) + .end(), true)); + return true; + } + + @Override + public boolean isDiscovered(String address) { + SimulatorState s = BluetoothSimulator.state(); + SimulatorState.ConnectionState cs = s.connectionFor(address, false); + boolean v = cs != null && cs.discovered; + s.runSync(() -> BluetoothCallbackRegistry.sendResult("isDiscovered", + JsonBuilder.start() + .put("address", address) + .put("isDiscovered", v) + .end(), true)); + return true; + } + + @Override + public boolean hasPermission() { + SimulatorState s = BluetoothSimulator.state(); + boolean v = s.hasPermission(); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("hasPermission", + JsonBuilder.start().put("hasPermission", v).end(), true)); + return true; + } + + @Override + public boolean requestPermission() { + SimulatorState s = BluetoothSimulator.state(); + s.setHasPermission(true); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("requestPermission", + JsonBuilder.start().put("requestPermission", true).end(), true)); + return true; + } + + @Override + public boolean isLocationEnabled() { + SimulatorState s = BluetoothSimulator.state(); + boolean v = s.isLocationEnabled(); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("isLocationEnabled", + JsonBuilder.start().put("isLocationEnabled", v).end(), true)); + return true; + } + + @Override + public boolean requestLocation() { + SimulatorState s = BluetoothSimulator.state(); + s.setLocationEnabled(true); + s.runSync(() -> BluetoothCallbackRegistry.sendResult("requestLocation", + JsonBuilder.start().put("requestLocation", true).end(), true)); + return true; + } + + private SimulatedCharacteristic lookupCharacteristic(String address, String service, String characteristic) { + SimulatorState s = BluetoothSimulator.state(); + SimulatedPeripheral p = s.getPeripheral(address); + if (p == null) return null; + SimulatedService svc = p.findService(service); + if (svc == null) return null; + return svc.findCharacteristic(characteristic); + } + + private boolean errorAsync(final String operation, final String error, final String message) { + SimulatorState s = BluetoothSimulator.state(); + s.schedule(() -> BluetoothCallbackRegistry.sendResult(operation, + JsonBuilder.start() + .put("error", error) + .put("message", message == null ? "" : message) + .end(), false)); + return true; + } + + private boolean failIfPresent(String operation) { + SimulatorState.Failure f = BluetoothSimulator.state().consumeFailure(operation); + if (f == null) return false; + errorAsync(operation, f.error, f.message); + return true; + } + + private static String buildDiscoveredJson(SimulatedPeripheral p) { + StringBuilder services = new StringBuilder("["); + boolean firstS = true; + for (SimulatedService svc : p.getServices()) { + if (!firstS) services.append(','); + firstS = false; + services.append(JsonBuilder.start() + .put("service", svc.getUuid()) + .putRaw("characteristics", buildCharacteristicsArray(svc)) + .end()); + } + services.append(']'); + return JsonBuilder.start() + .put("status", "discovered") + .put("address", p.getAddress()) + .put("name", p.getName()) + .putRaw("services", services.toString()) + .end(); + } + + private static String buildCharacteristicsArray(SimulatedService svc) { + StringBuilder arr = new StringBuilder("["); + boolean firstC = true; + for (SimulatedCharacteristic ch : svc.getCharacteristics()) { + if (!firstC) arr.append(','); + firstC = false; + StringBuilder props = new StringBuilder("["); + boolean firstP = true; + for (String p : ch.getProperties()) { + if (!firstP) props.append(','); + firstP = false; + props.append('"').append(p).append('"'); + } + props.append(']'); + arr.append(JsonBuilder.start() + .put("characteristic", ch.getUuid()) + .putRaw("properties", props.toString()) + .end()); + } + arr.append(']'); + return arr.toString(); + } + + private void checkBluetoothUsageDescription() { + if (!bluetoothUsageDescriptionChecked) { + bluetoothUsageDescriptionChecked = true; + Map m = Display.getInstance().getProjectBuildHints(); + if (m != null) { + if (!m.containsKey("ios.NSBluetoothPeripheralUsageDescription")) { + Display.getInstance().setProjectBuildHint("ios.NSBluetoothPeripheralUsageDescription", "Some functionality of the application requires Bluetooth functionality"); + } + if (!m.containsKey("ios.NSBluetoothAlwaysUsageDescription")) { + Display.getInstance().setProjectBuildHint("ios.NSBluetoothAlwaysUsageDescription", "Some functionality of the application requires Bluetooth functionality"); + } + } + } + } + + private void installBuildHints() { + try { + checkBluetoothUsageDescription(); + } catch (Throwable t) { + // Display may not be initialized when called from non-CN1 contexts + // (eg pure JUnit). Fall through silently — the build hints are only + // needed for actual native builds, not for simulator/test runs. + } + } + + @SuppressWarnings("unused") + private static String join(List parts) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.size(); i++) { + if (i > 0) sb.append(','); + sb.append(parts.get(i)); + } + return sb.toString(); + } +} diff --git a/javase/src/main/resources/META-INF/codenameone/simulator-hooks.properties b/javase/src/main/resources/META-INF/codenameone/simulator-hooks.properties new file mode 100644 index 0000000..4eada68 --- /dev/null +++ b/javase/src/main/resources/META-INF/codenameone/simulator-hooks.properties @@ -0,0 +1,36 @@ +# Simulator menu hooks for the cn1-bluetooth cn1lib. Discovered by the CN1 +# JavaSE port's SimulatorHookLoader when this jar is on the simulator +# classpath. Each itemN points at a public static no-arg method; the matching +# labelN becomes the menu text. Items are positional (item1 is first, item2 +# second, ...) and the loader stops at the first missing itemN. +# +# Each entry is also callable cross-platform via CN.execute("bluetooth:itemN"). +# Items without a labelN are API-only: registered with the executor but +# hidden from the menu — used by the test suite to prime scripted state. +name=Bluetooth +namespace=bluetooth + +item1=com.codename1.bluetoothle.BluetoothSimulatorHooks#toggleAdapter +label1=Toggle adapter on/off + +item2=com.codename1.bluetoothle.BluetoothSimulatorHooks#addDemoPeripheral +label2=Add demo peripheral + +item3=com.codename1.bluetoothle.BluetoothSimulatorHooks#disconnectAll +label3=Disconnect all peripherals + +item4=com.codename1.bluetoothle.BluetoothSimulatorHooks#pushDemoNotification +label4=Push demo notification + +item5=com.codename1.bluetoothle.BluetoothSimulatorHooks#clearPeripherals +label5=Clear peripherals + +item6=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToNativeBle +label6=Switch backend → native BLE (real hardware) + +item7=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToSimulator +label7=Switch backend → simulator + +# API-only: BluetoothSimulatorHooksTest fires this via CN.execute to +# verify the label-less branch + the cn1lib's error-propagation path. +item8=com.codename1.bluetoothle.BluetoothSimulatorHooks#primeReadFailure diff --git a/javase/src/main/rust/PROTOCOL.md b/javase/src/main/rust/PROTOCOL.md new file mode 100644 index 0000000..cc7b0b1 --- /dev/null +++ b/javase/src/main/rust/PROTOCOL.md @@ -0,0 +1,75 @@ +# cn1-ble-helper JSON Protocol + +Communication between the Java `NativeBleBackend` and the bundled Rust helper +executable (a btleplug-based binary built from `cn1-ble-helper/`). One JSON +object per line, UTF-8, on the helper's stdin (commands) / stdout (responses +and events). Helper diagnostics go to stderr. + +## Identifiers + +btleplug's `PeripheralId` is OS-specific (a UUID on macOS, a BDAddr on Linux, +a 64-bit address on Windows); the helper renders it as the lowercase string +form and the cn1lib treats that as the wire-protocol `address`. Service and +characteristic UUIDs are normalized to lowercase 128-bit form +(`0000180a-0000-1000-8000-00805f9b34fb`) before being sent in either direction. + +## Commands (Java → helper) + +Each command carries an `id` (numeric, monotonic) that the helper echoes back +in its `ack` and in `error` events tied to that command. + +``` +{"cmd":"initialize","id":1} +{"cmd":"startScan","id":2,"services":["uuid",...],"allowDuplicates":false} +{"cmd":"stopScan","id":3} +{"cmd":"connect","id":4,"address":"..."} +{"cmd":"disconnect","id":5,"address":"..."} +{"cmd":"discover","id":6,"address":"..."} +{"cmd":"read","id":7,"address":"...","service":"...","characteristic":"..."} +{"cmd":"write","id":8,"address":"...","service":"...","characteristic":"...","value":"","noResponse":false} +{"cmd":"subscribe","id":9,"address":"...","service":"...","characteristic":"..."} +{"cmd":"unsubscribe","id":10,"address":"...","service":"...","characteristic":"..."} +{"cmd":"shutdown"} +``` + +## Acknowledgements (helper → Java) + +Immediate "command accepted by helper" response. Does **not** mean the BLE +operation completed — those land as events. + +``` +{"ack":2,"ok":true} +{"ack":2,"ok":false,"error":"isDisabled"} +``` + +## Events (helper → Java, async) + +``` +{"event":"stateChanged","state":"poweredOn|unsupported"} +{"event":"scanResult","address":"...","name":"...","rssi":-45,"advertisement":""} +{"event":"scanStopped"} +{"event":"connected","address":"...","name":"..."} +{"event":"disconnected","address":"...","reason":"..."} +{"event":"discovered","address":"...","name":"...","services":[{"uuid":"...","characteristics":[{"uuid":"...","properties":["read","write","notify"]}]}]} +{"event":"readResult","address":"...","service":"...","characteristic":"...","value":""} +{"event":"writeResult","address":"...","service":"...","characteristic":"...","value":""} +{"event":"notification","address":"...","service":"...","characteristic":"...","value":""} +{"event":"subscribed","address":"...","service":"...","characteristic":"..."} +{"event":"unsubscribed","address":"...","service":"...","characteristic":"..."} +{"event":"error","command":"read","address":"...","message":"unknown characteristic"} +``` + +`stateChanged` fires exactly once at startup with either `poweredOn` (helper +got an adapter from btleplug) or `unsupported` (no adapter present). btleplug +itself doesn't expose runtime state changes on most platforms, so the Java +side caches the startup state and treats `poweredOn` as enabled. + +`error` events carry the originating command name in `command` so the Java +side can route the failure to the correct callback. + +## Shutdown + +When the JVM exits or `NativeBleBackend` is closed, Java sends +`{"cmd":"shutdown"}` and closes stdin. The helper exits with status 0. If the +JVM crashes, the helper detects the closed stdin (EOF) and exits with +status 0. diff --git a/javase/src/main/rust/cn1-ble-helper/Cargo.lock b/javase/src/main/rust/cn1-ble-helper/Cargo.lock new file mode 100644 index 0000000..431564c --- /dev/null +++ b/javase/src/main/rust/cn1-ble-helper/Cargo.lock @@ -0,0 +1,1210 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bluez-async" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" +dependencies = [ + "bitflags", + "bluez-generated", + "dbus", + "dbus-tokio", + "futures", + "itertools", + "log", + "serde", + "serde-xml-rs", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "bluez-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9676783265eadd6f11829982792c6f303f3854d014edfba384685dcf237dd062" +dependencies = [ + "dbus", +] + +[[package]] +name = "btleplug" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a11621cb2c8c024e444734292482b1ad86fb50ded066cf46252e46643c8748" +dependencies = [ + "async-trait", + "bitflags", + "bluez-async", + "dashmap 6.2.1", + "dbus", + "futures", + "jni", + "jni-utils", + "log", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "once_cell", + "static_assertions", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "uuid", + "windows", + "windows-future", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cn1-ble-helper" +version = "0.1.0" +dependencies = [ + "base64", + "btleplug", + "futures", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jni-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "259e9f2c3ead61de911f147000660511f07ab00adeed1d84f5ac4d0386e7a6c4" +dependencies = [ + "dashmap 5.5.3", + "futures", + "jni", + "log", + "once_cell", + "static_assertions", + "uuid", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-core-bluetooth" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-xml-rs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2215ce3e6a77550b80a1c37251b7d294febaf42e36e21b7b411e0bf54d540d" +dependencies = [ + "log", + "serde", + "thiserror 2.0.18", + "xml", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xml" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/javase/src/main/rust/cn1-ble-helper/Cargo.toml b/javase/src/main/rust/cn1-ble-helper/Cargo.toml new file mode 100644 index 0000000..2d58952 --- /dev/null +++ b/javase/src/main/rust/cn1-ble-helper/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cn1-ble-helper" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "cn1-ble-helper" +path = "src/main.rs" + +[dependencies] +btleplug = "0.11" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "sync", "time"] } +futures = "0.3" +serde_json = "1" +base64 = "0.22" +uuid = { version = "1", features = ["v4"] } + +[profile.release] +# Smaller helper = smaller cn1lib jar shipped to apps. +opt-level = "z" +lto = true +codegen-units = 1 +strip = true diff --git a/javase/src/main/rust/cn1-ble-helper/src/main.rs b/javase/src/main/rust/cn1-ble-helper/src/main.rs new file mode 100644 index 0000000..956e724 --- /dev/null +++ b/javase/src/main/rust/cn1-ble-helper/src/main.rs @@ -0,0 +1,709 @@ +// cn1-ble-helper — cross-platform BLE bridge for the Codename One bluetoothle +// cn1lib's JavaSE backend. See ../PROTOCOL.md for the exact JSON-line +// command/event format we share with the Java side. +// +// Architecture +// ------------ +// One tokio runtime, three concurrent tasks: +// • stdin reader → parses JSON-lines into Command values, hands them to the +// command dispatcher; +// • adapter event listener → translates btleplug's CentralEvent stream into +// stateChanged / scanResult / connected / disconnected wire events; +// • per-peripheral notification listeners (one per active subscription) → +// translate ValueNotification streams into "notification" events. +// +// All wire output flows through a single mpsc channel drained by a writer +// task; that keeps every line written atomically without locking stdout. + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use btleplug::api::{ + BDAddr, Central, CentralEvent, CharPropFlags, Manager as _, Peripheral as _, ScanFilter, + WriteType, +}; +use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; +use futures::stream::StreamExt; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, Mutex}; +use tokio::task::JoinHandle; +use uuid::Uuid; + +// ---------------- protocol helpers ---------------- + +/// Single sink for everything the helper writes. Wire writes happen through +/// the corresponding receiver task so no two events ever interleave. +type EventSink = mpsc::UnboundedSender; + +fn emit(sink: &EventSink, event: Value) { + // If the channel is closed the writer task has exited and we're tearing + // down; dropping a stray event is harmless. + let _ = sink.send(event); +} + +fn emit_event(sink: &EventSink, name: &str, payload: Value) { + let mut obj = serde_json::Map::new(); + obj.insert("event".into(), Value::String(name.into())); + if let Value::Object(extra) = payload { + for (k, v) in extra { + obj.insert(k, v); + } + } + emit(sink, Value::Object(obj)); +} + +fn ack(sink: &EventSink, id: Option, ok: bool, error: Option<&str>) { + let Some(id) = id else { return }; + let mut obj = serde_json::Map::new(); + obj.insert("ack".into(), Value::from(id)); + obj.insert("ok".into(), Value::from(ok)); + if let Some(e) = error { + obj.insert("error".into(), Value::from(e)); + } + emit(sink, Value::Object(obj)); +} + +/// btleplug exposes a [`Uuid`]; the cn1lib's wire format is always lowercase +/// dashed 128-bit, which is exactly what `Uuid::to_string` produces. Kept as +/// a helper so the call sites read uniformly. +fn fmt_uuid(u: &Uuid) -> String { + u.to_string() +} + +/// Normalize whatever string the Java side sends — could be the 128-bit +/// dashed form ("0000180a-…"), the bare 16-bit form ("180a"), or the BlueZ +/// MAC-with-dashes that some examples use — into a [`Uuid`] btleplug accepts. +fn parse_uuid(raw: &str) -> Option { + let s = raw.trim(); + if let Ok(u) = Uuid::parse_str(s) { + return Some(u); + } + // 16-bit assigned number: expand using the Bluetooth Base UUID. + if s.len() == 4 { + let padded = format!("0000{}-0000-1000-8000-00805f9b34fb", s.to_lowercase()); + return Uuid::parse_str(&padded).ok(); + } + None +} + +/// On macOS btleplug's `PeripheralId` is a UUID; on Linux it's a BDAddr; on +/// Windows it's a `BluetoothAddress`. The `Debug`/`Display` impls all yield +/// canonical strings, which is what we use as the wire-protocol address. +fn id_to_address(id: &PeripheralId) -> String { + format!("{}", id).to_lowercase() +} + +// ---------------- state ---------------- + +struct State { + adapter: Adapter, + peripherals: HashMap, + /// Subscribers per `address|service|char` so unsubscribe can cancel them. + notif_listeners: HashMap>, + scanning: bool, +} + +impl State { + fn lookup_peripheral(&self, address: &str) -> Option<&Peripheral> { + self.peripherals.get(address) + } +} + +type SharedState = Arc>; + +// ---------------- command dispatch ---------------- + +async fn handle_command(state: SharedState, sink: EventSink, cmd: Value) { + let cmd_name = cmd.get("cmd").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let id = cmd.get("id").and_then(|v| v.as_u64()); + + match cmd_name.as_str() { + "initialize" => { + // No state re-broadcast on initialize — the startup-time + // stateChanged is the single source of truth. The Java side sets + // initialized=true before launching the helper, so the startup + // event already lands with full context. + ack(&sink, id, true, None); + } + "startScan" => { + let filter = parse_scan_filter(&cmd); + let mut s = state.lock().await; + match s.adapter.start_scan(filter).await { + Ok(()) => { + s.scanning = true; + drop(s); + ack(&sink, id, true, None); + } + Err(e) => { + drop(s); + ack(&sink, id, false, Some(&e.to_string())); + } + } + } + "stopScan" => { + let mut s = state.lock().await; + let res = s.adapter.stop_scan().await; + s.scanning = false; + drop(s); + match res { + Ok(()) => { + ack(&sink, id, true, None); + emit_event(&sink, "scanStopped", json!({})); + } + Err(e) => ack(&sink, id, false, Some(&e.to_string())), + } + } + "connect" => { + let Some(address) = cmd.get("address").and_then(|v| v.as_str()) else { + ack(&sink, id, false, Some("missingAddress")); + return; + }; + let s = state.lock().await; + let Some(p) = s.lookup_peripheral(address).cloned() else { + drop(s); + ack(&sink, id, false, Some("unknownPeripheral")); + return; + }; + drop(s); + match p.connect().await { + Ok(()) => ack(&sink, id, true, None), + Err(e) => { + ack(&sink, id, false, Some(&e.to_string())); + emit_event( + &sink, + "error", + json!({"command": "connect", "address": address, "message": e.to_string()}), + ); + } + } + } + "disconnect" => { + let Some(address) = cmd.get("address").and_then(|v| v.as_str()) else { + ack(&sink, id, false, Some("missingAddress")); + return; + }; + let s = state.lock().await; + let Some(p) = s.lookup_peripheral(address).cloned() else { + drop(s); + ack(&sink, id, false, Some("unknownPeripheral")); + return; + }; + drop(s); + match p.disconnect().await { + Ok(()) => ack(&sink, id, true, None), + Err(e) => ack(&sink, id, false, Some(&e.to_string())), + } + } + "discover" => { + let Some(address) = cmd.get("address").and_then(|v| v.as_str()) else { + ack(&sink, id, false, Some("missingAddress")); + return; + }; + let s = state.lock().await; + let Some(p) = s.lookup_peripheral(address).cloned() else { + drop(s); + ack(&sink, id, false, Some("unknownPeripheral")); + return; + }; + drop(s); + ack(&sink, id, true, None); + if let Err(e) = p.discover_services().await { + emit_event( + &sink, + "error", + json!({"command": "discover", "address": address, "message": e.to_string()}), + ); + return; + } + emit_event(&sink, "discovered", build_discovered_payload(&p, address).await); + } + "read" => { + let Some((p, ch, addr, svc_uuid, ch_uuid)) = + resolve_characteristic(&state, &cmd).await + else { + ack(&sink, id, false, Some("unknownCharacteristic")); + return; + }; + ack(&sink, id, true, None); + match p.read(&ch).await { + Ok(value) => emit_event( + &sink, + "readResult", + json!({ + "address": addr, + "service": svc_uuid, + "characteristic": ch_uuid, + "value": B64.encode(&value), + }), + ), + Err(e) => emit_event( + &sink, + "error", + json!({"command": "read", "address": addr, "message": e.to_string()}), + ), + } + } + "write" => { + let Some((p, ch, addr, svc_uuid, ch_uuid)) = + resolve_characteristic(&state, &cmd).await + else { + ack(&sink, id, false, Some("unknownCharacteristic")); + return; + }; + let value_b64 = cmd.get("value").and_then(|v| v.as_str()).unwrap_or(""); + let Ok(value) = B64.decode(value_b64) else { + ack(&sink, id, false, Some("badBase64")); + return; + }; + let no_response = cmd.get("noResponse").and_then(|v| v.as_bool()).unwrap_or(false); + let write_type = if no_response { + WriteType::WithoutResponse + } else { + WriteType::WithResponse + }; + ack(&sink, id, true, None); + match p.write(&ch, &value, write_type).await { + Ok(()) => emit_event( + &sink, + "writeResult", + json!({ + "address": addr, + "service": svc_uuid, + "characteristic": ch_uuid, + "value": value_b64, + }), + ), + Err(e) => emit_event( + &sink, + "error", + json!({"command": "write", "address": addr, "message": e.to_string()}), + ), + } + } + "subscribe" => { + let Some((p, ch, addr, svc_uuid, ch_uuid)) = + resolve_characteristic(&state, &cmd).await + else { + ack(&sink, id, false, Some("unknownCharacteristic")); + return; + }; + ack(&sink, id, true, None); + match p.subscribe(&ch).await { + Ok(()) => { + start_notification_listener( + state.clone(), + sink.clone(), + p.clone(), + addr.clone(), + svc_uuid.clone(), + ch_uuid.clone(), + ) + .await; + emit_event( + &sink, + "subscribed", + json!({ + "address": addr, + "service": svc_uuid, + "characteristic": ch_uuid, + }), + ); + } + Err(e) => emit_event( + &sink, + "error", + json!({"command": "subscribe", "address": addr, "message": e.to_string()}), + ), + } + } + "unsubscribe" => { + let Some((p, ch, addr, svc_uuid, ch_uuid)) = + resolve_characteristic(&state, &cmd).await + else { + ack(&sink, id, false, Some("unknownCharacteristic")); + return; + }; + let key = subscription_key(&addr, &svc_uuid, &ch_uuid); + // Stop the listener task before the unsubscribe round-trip; the + // stream we cancel is the one peripheral.notifications() handed + // out, and dropping its handle ends the per-subscription task. + let mut s = state.lock().await; + if let Some(task) = s.notif_listeners.remove(&key) { + task.abort(); + } + drop(s); + ack(&sink, id, true, None); + match p.unsubscribe(&ch).await { + Ok(()) => emit_event( + &sink, + "unsubscribed", + json!({ + "address": addr, + "service": svc_uuid, + "characteristic": ch_uuid, + }), + ), + Err(e) => emit_event( + &sink, + "error", + json!({"command": "unsubscribe", "address": addr, "message": e.to_string()}), + ), + } + } + "shutdown" => { + std::process::exit(0); + } + other => { + eprintln!("cn1-ble-helper: unknown command '{}'", other); + ack(&sink, id, false, Some("unknownCommand")); + } + } +} + +fn parse_scan_filter(cmd: &Value) -> ScanFilter { + let mut services = Vec::new(); + if let Some(arr) = cmd.get("services").and_then(|v| v.as_array()) { + for v in arr { + if let Some(s) = v.as_str() { + if let Some(u) = parse_uuid(s) { + services.push(u); + } + } + } + } + ScanFilter { services } +} + +async fn resolve_characteristic( + state: &SharedState, + cmd: &Value, +) -> Option<(Peripheral, btleplug::api::Characteristic, String, String, String)> { + let address = cmd.get("address").and_then(|v| v.as_str())?.to_string(); + let svc_raw = cmd.get("service").and_then(|v| v.as_str())?; + let ch_raw = cmd.get("characteristic").and_then(|v| v.as_str())?; + let svc_uuid = parse_uuid(svc_raw)?; + let ch_uuid = parse_uuid(ch_raw)?; + + let s = state.lock().await; + let peripheral = s.lookup_peripheral(&address)?.clone(); + drop(s); + + let characteristic = peripheral + .characteristics() + .into_iter() + .find(|c| c.service_uuid == svc_uuid && c.uuid == ch_uuid)?; + Some(( + peripheral, + characteristic, + address, + fmt_uuid(&svc_uuid), + fmt_uuid(&ch_uuid), + )) +} + +fn subscription_key(address: &str, service: &str, characteristic: &str) -> String { + format!("{}|{}|{}", address, service, characteristic) +} + +async fn start_notification_listener( + state: SharedState, + sink: EventSink, + peripheral: Peripheral, + address: String, + service: String, + characteristic: String, +) { + let key = subscription_key(&address, &service, &characteristic); + let mut s = state.lock().await; + if let Some(prev) = s.notif_listeners.remove(&key) { + prev.abort(); + } + let task = tokio::spawn(async move { + let mut notif_stream = match peripheral.notifications().await { + Ok(stream) => stream, + Err(e) => { + emit_event( + &sink, + "error", + json!({"command": "subscribe", "address": address, "message": e.to_string()}), + ); + return; + } + }; + while let Some(notif) = notif_stream.next().await { + // notifications() yields ALL chars on the peripheral; filter to + // the one we subscribed to. + if fmt_uuid(¬if.uuid) != characteristic { + continue; + } + emit_event( + &sink, + "notification", + json!({ + "address": address, + "service": service, + "characteristic": characteristic, + "value": B64.encode(¬if.value), + }), + ); + } + }); + s.notif_listeners.insert(key, task); +} + +async fn build_discovered_payload(p: &Peripheral, address: &str) -> Value { + let props = p.properties().await.ok().flatten(); + let name = props.and_then(|p| p.local_name).unwrap_or_default(); + let mut svc_arr: Vec = Vec::new(); + for svc in p.services() { + let mut ch_arr: Vec = Vec::new(); + for ch in svc.characteristics { + let mut props = Vec::new(); + let flags = ch.properties; + if flags.contains(CharPropFlags::READ) { + props.push("read"); + } + if flags.contains(CharPropFlags::WRITE) { + props.push("write"); + } + if flags.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) { + props.push("writeWithoutResponse"); + } + if flags.contains(CharPropFlags::NOTIFY) { + props.push("notify"); + } + if flags.contains(CharPropFlags::INDICATE) { + props.push("indicate"); + } + ch_arr.push(json!({ + "uuid": fmt_uuid(&ch.uuid), + "properties": props, + })); + } + svc_arr.push(json!({ + "uuid": fmt_uuid(&svc.uuid), + "characteristics": ch_arr, + })); + } + json!({ + "address": address, + "name": name, + "services": svc_arr, + }) +} + +// ---------------- central-event listener ---------------- + +async fn central_event_loop(state: SharedState, sink: EventSink, adapter: Adapter) { + let mut events = match adapter.events().await { + Ok(e) => e, + Err(err) => { + emit_event( + &sink, + "error", + json!({"command": "adapter", "message": err.to_string()}), + ); + return; + } + }; + while let Some(event) = events.next().await { + match event { + CentralEvent::DeviceDiscovered(id) | CentralEvent::DeviceUpdated(id) => { + let address = id_to_address(&id); + let Ok(p) = adapter.peripheral(&id).await else { continue }; + let props = p.properties().await.ok().flatten(); + let (name, rssi, manuf) = match props { + Some(prop) => ( + prop.local_name.unwrap_or_default(), + prop.rssi.unwrap_or(-127), + prop.manufacturer_data + .values() + .next() + .cloned() + .unwrap_or_default(), + ), + None => (String::new(), -127, Vec::new()), + }; + state + .lock() + .await + .peripherals + .insert(address.clone(), p); + emit_event( + &sink, + "scanResult", + json!({ + "address": address, + "name": name, + "rssi": rssi, + "advertisement": B64.encode(&manuf), + }), + ); + } + CentralEvent::DeviceConnected(id) => { + let address = id_to_address(&id); + let Ok(p) = adapter.peripheral(&id).await else { continue }; + let name = p + .properties() + .await + .ok() + .flatten() + .and_then(|p| p.local_name) + .unwrap_or_default(); + emit_event( + &sink, + "connected", + json!({"address": address, "name": name}), + ); + } + CentralEvent::DeviceDisconnected(id) => { + let address = id_to_address(&id); + // Tear down any notification listeners tied to this address. + let mut s = state.lock().await; + s.notif_listeners + .retain(|key, task| { + if key.starts_with(&format!("{}|", address)) { + task.abort(); + false + } else { + true + } + }); + drop(s); + emit_event( + &sink, + "disconnected", + json!({"address": address, "reason": ""}), + ); + } + _ => {} + } + } +} + +// ---------------- stdin reader ---------------- + +async fn stdin_loop(state: SharedState, sink: EventSink) { + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + loop { + match reader.next_line().await { + Ok(Some(line)) => { + if line.is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(cmd) => { + let state = state.clone(); + let sink = sink.clone(); + tokio::spawn(async move { + handle_command(state, sink, cmd).await; + }); + } + Err(e) => eprintln!("cn1-ble-helper: malformed JSON: {} ({})", line, e), + } + } + Ok(None) => { + // stdin EOF — parent process gone; mirror the Swift helper's + // clean exit so the JVM-side reader thread sees the pipe close. + std::process::exit(0); + } + Err(e) => { + eprintln!("cn1-ble-helper: stdin read error: {}", e); + std::process::exit(0); + } + } + } +} + +// ---------------- stdout writer ---------------- + +async fn writer_loop(mut rx: mpsc::UnboundedReceiver) { + let stdout = tokio::io::stdout(); + let mut stdout = stdout; + while let Some(value) = rx.recv().await { + let mut line = match serde_json::to_string(&value) { + Ok(s) => s, + Err(e) => { + eprintln!("cn1-ble-helper: failed to serialize event: {}", e); + continue; + } + }; + line.push('\n'); + if let Err(e) = stdout.write_all(line.as_bytes()).await { + eprintln!("cn1-ble-helper: stdout write failed: {}", e); + return; + } + if let Err(e) = stdout.flush().await { + eprintln!("cn1-ble-helper: stdout flush failed: {}", e); + return; + } + } +} + +// ---------------- entry point ---------------- + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // Every "we can't get a working adapter" branch — no BlueZ on the host, + // permission denied, zero adapters — produces a stateChanged=unsupported + // event and idles instead of crashing. CI runners hit this all the time + // (no BT hardware); the smoke test treats receipt of *any* stateChanged + // as a success signal, so this keeps the helper's contract intact. + let adapter_result: Result> = async { + let manager = Manager::new().await?; + let adapters = manager.adapters().await?; + adapters.into_iter().next() + .ok_or_else(|| "no adapters returned by btleplug".into()) + }.await; + + let adapter = match adapter_result { + Ok(a) => a, + Err(e) => { + let (tx, rx) = mpsc::unbounded_channel::(); + tokio::spawn(writer_loop(rx)); + // Surface the underlying reason on stderr for diagnostics, but + // the wire-side stateChanged is the only thing the Java side reads. + eprintln!("cn1-ble-helper: no usable adapter ({}); reporting unsupported", e); + emit_event(&tx, "stateChanged", json!({"state": "unsupported"})); + // Drain stdin so a shutdown command or EOF still terminates + // us cleanly. Tokio gives stdout a moment to flush via the + // sleep before we start reading. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + loop { + match reader.next_line().await { + Ok(Some(_)) => continue, + _ => return Ok(()), + } + } + } + }; + + let (tx, rx) = mpsc::unbounded_channel::(); + let state = Arc::new(Mutex::new(State { + adapter: adapter.clone(), + peripherals: HashMap::new(), + notif_listeners: HashMap::new(), + scanning: false, + })); + + tokio::spawn(writer_loop(rx)); + tokio::spawn(central_event_loop(state.clone(), tx.clone(), adapter.clone())); + + // Emit poweredOn now — btleplug doesn't expose a true state-change stream; + // having an adapter at all is our "ready" signal, and a missing adapter + // is the `unsupported` branch above. + emit_event(&tx, "stateChanged", json!({"state": "poweredOn"})); + + stdin_loop(state, tx).await; + Ok(()) +} + +// Some platforms (Linux/macOS) need this import to compile; silence the unused +// warning when only some targets need it. +#[allow(dead_code)] +fn _force_bdaddr_link(_b: BDAddr) {} diff --git a/pom.xml b/pom.xml index 0ee1125..24d2fd7 100644 --- a/pom.xml +++ b/pom.xml @@ -37,8 +37,8 @@ - 7.0.26 - 7.0.71 + 7.0.243 + 7.0.243 UTF-8 1.8 diff --git a/scripts/native-tests/run-android-native-tests.sh b/scripts/native-tests/run-android-native-tests.sh index f652601..accb084 100755 --- a/scripts/native-tests/run-android-native-tests.sh +++ b/scripts/native-tests/run-android-native-tests.sh @@ -30,9 +30,18 @@ mkdir -p BTDemo/target find BTDemo/target -maxdepth 1 -type d -name '*-android-source' -exec rm -rf {} + # Ensure all platform-specific reactor artifacts are installed locally before CN1 native-source generation. -mvn -DskipTests -Dcodename1.platform=android install - -mvn -pl BTDemo -am cn1:build -DskipTests -Dcodename1.platform=android -Dcodename1.buildTarget=android-source -Dopen=false +# -DskipNativeBleHelper=true: Android native tests don't exercise the +# JavaSE Rust helper, and the Linux CI runner doesn't have libdbus-1-dev +# installed (only the dedicated native-ble-helper job does). +mvn -DskipTests -DskipNativeBleHelper=true -Dcodename1.platform=android install + +# Use the fully-qualified plugin coordinate instead of the cn1: prefix. +# After the install above writes to the local m2 group metadata for +# com.codenameone, Maven can resolve the cn1 prefix from the local +# cache without round-tripping to Central, but that local metadata +# doesn't always list codenameone-maven-plugin's prefix mapping, +# causing intermittent "No plugin found for prefix 'cn1'" failures. +mvn -pl BTDemo -am com.codenameone:codenameone-maven-plugin:7.0.243:build -DskipTests -DskipNativeBleHelper=true -Dcodename1.platform=android -Dcodename1.buildTarget=android-source -Dopen=false ANDROID_SRC="$(find BTDemo/target -maxdepth 1 -type d -name '*-android-source' | sort | tail -n 1)" if [[ -z "$ANDROID_SRC" ]]; then @@ -82,8 +91,29 @@ ensure_gradle_property() { GRADLE_PROPERTIES="$ANDROID_SRC/gradle.properties" APP_GRADLE_PROPERTIES="$ANDROID_SRC/app/gradle.properties" +# Enable AndroidX + Jetifier in the generated project. The script +# injects androidx.test deps for the instrumentation test, and the +# generated project still pulls in old com.android.support:* libs; +# Jetifier transparently rewrites the legacy ones at build time so +# they coexist with the AndroidX test runner. +if ! grep -q "^android.useAndroidX=true" "$GRADLE_PROPERTIES" 2>/dev/null; then + echo "android.useAndroidX=true" >> "$GRADLE_PROPERTIES" +fi +if ! grep -q "^android.enableJetifier=true" "$GRADLE_PROPERTIES" 2>/dev/null; then + echo "android.enableJetifier=true" >> "$GRADLE_PROPERTIES" +fi + perl -0pi -e "s/compileSdkVersion\\s+0/compileSdkVersion 30/g; s/targetSdkVersion\\s+0/targetSdkVersion 30/g; s/buildToolsVersion\\s+'0'/buildToolsVersion '30.0.3'/g" "$APP_BUILD_GRADLE" perl -0pi -e "s/com\\.android\\.support:support-v4:0\\.\\+/com.android.support:support-v4:28.0.0/g; s/com\\.android\\.support:appcompat-v7:0\\.\\+/com.android.support:appcompat-v7:28.0.0/g" "$APP_BUILD_GRADLE" +# CN1 master's Android codegen still emits Gradle 5-removed +# androidTestCompile / testCompile / compile dependency configurations. +# Gradle 8 (which the emulator-runner step uses) refuses them and the +# build dies on `app/build.gradle` line 97 with "Could not find method +# androidTestCompile() ...". Rename to the modern equivalents on the +# generated file before running the emulator. Pin to a leading +# whitespace + identifier match so we don't touch coincidental +# substrings elsewhere in the file. +perl -0pi -e "s/^(\\s+)androidTestCompile(\\s|\\()/\$1androidTestImplementation\$2/gm; s/^(\\s+)testCompile(\\s|\\()/\$1testImplementation\$2/gm; s/^(\\s+)compile(\\s|\\()/\$1implementation\$2/gm" "$APP_BUILD_GRADLE" TEST_DIR="$ANDROID_SRC/app/src/androidTest/java/com/codename1/btle" TEST_FILE="$TEST_DIR/BluetoothNativeInstrumentationTest.java" @@ -205,8 +235,8 @@ import com.codename1.bluetoothle.BluetoothCallback; import com.codename1.bluetoothle.BluetoothCallbackRegistry; import com.codename1.bluetoothle.BluetoothNativeBridgeImpl; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -218,7 +248,7 @@ public class BluetoothNativeInstrumentationTest { @Test public void bluetoothStackIsAvailable() { - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); assertNotNull("BluetoothManager should be available", manager); @@ -279,13 +309,13 @@ if [[ -f "$EXAMPLE_TEST" ]]; then rm -f "$EXAMPLE_TEST" fi -if ! rg -q 'testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"' "$APP_BUILD_GRADLE"; then +if ! rg -q 'testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"' "$APP_BUILD_GRADLE"; then TMP_GRADLE="$(mktemp)" awk ' { print if ($0 ~ /^[[:space:]]*defaultConfig[[:space:]]*\{[[:space:]]*$/ && !runnerInserted) { - print " testInstrumentationRunner \"android.support.test.runner.AndroidJUnitRunner\"" + print " testInstrumentationRunner \"androidx.test.runner.AndroidJUnitRunner\"" runnerInserted = 1 } } @@ -294,18 +324,23 @@ if ! rg -q 'testInstrumentationRunner "android.support.test.runner.AndroidJUnitR fi TEST_DEP_CONF="androidTestImplementation" -if ! rg -q "^[[:space:]]*implementation[[:space:]]" "$APP_BUILD_GRADLE"; then - TEST_DEP_CONF="androidTestCompile" -fi +# The previous behavior fell back to the Gradle 5-removed +# "androidTestCompile" when the file didn't have any +# top-level "implementation" line (which happens after the codegen pass +# above rewrites every legacy "compile" / there were none to begin +# with). Gradle 8 doesn't recognize that name and dies on the appended +# block. The modern configuration always works on AGP 3.x+ where the +# test runner library lives, so use it unconditionally. # Remove stale injected test dependency lines from previous runs. perl -ni -e 'print unless /(androidx\.test:(runner|ext:junit|espresso-core)|com\.android\.support\.test:(runner|rules|espresso-core))/' "$APP_BUILD_GRADLE" -if ! rg -q "com\.android\.support\.test:runner" "$APP_BUILD_GRADLE"; then +if ! rg -q "androidx\.test:runner" "$APP_BUILD_GRADLE"; then cat >> "$APP_BUILD_GRADLE" <&2 + exit 2 +fi + +OUT=$(mktemp) +ERR=$(mktemp) +trap 'rm -f "$OUT" "$ERR"' EXIT + +# Drive the helper: ask for state, wait for the first event, then shut down. +( + echo '{"cmd":"initialize","id":1}' + sleep 3 + echo '{"cmd":"shutdown"}' +) | "$HELPER" > "$OUT" 2> "$ERR" & +HELPER_PID=$! + +# Allow up to 10s for the helper to spin up and emit the first event. The +# btleplug + tokio startup on a cold-cache runner can take a few seconds. +for _ in $(seq 10); do + if grep -q '"event":"stateChanged"' "$OUT"; then + break + fi + sleep 1 +done + +if kill -0 "$HELPER_PID" 2>/dev/null; then + kill "$HELPER_PID" 2>/dev/null || true + wait "$HELPER_PID" 2>/dev/null || true +fi + +echo "--- helper stdout ---" +cat "$OUT" +echo "--- helper stderr ---" +cat "$ERR" 2>/dev/null || true +echo "---" + +if ! grep -q '"event":"stateChanged"' "$OUT"; then + echo "FAIL: helper never emitted a stateChanged event." >&2 + exit 1 +fi + +echo "OK: cn1-ble-helper produced a stateChanged event."