diff --git a/CodenameOne/src/com/codename1/bluetooth/AdapterState.java b/CodenameOne/src/com/codename1/bluetooth/AdapterState.java new file mode 100644 index 00000000000..b68a1f2347b --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/AdapterState.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// Lifecycle states of the local Bluetooth adapter, a union of the Android +/// `BluetoothAdapter` states and the iOS `CBManagerState` values. Query via +/// [Bluetooth#getAdapterState()] and observe changes with +/// [Bluetooth#addAdapterStateListener(AdapterStateListener)]. +public enum AdapterState { + /// The state has not been determined yet -- e.g. iOS before the first + /// CoreBluetooth manager callback fired. + UNKNOWN, + + /// The device has no Bluetooth hardware, or the port does not implement + /// Bluetooth at all. The fallback base [Bluetooth] class always reports + /// this state. + UNSUPPORTED, + + /// The app is not authorized to use Bluetooth (iOS privacy authorization + /// denied / restricted, or missing runtime permissions on Android). + UNAUTHORIZED, + + /// The adapter is off. Ask the user to enable it, optionally via + /// [Bluetooth#requestEnable()]. + POWERED_OFF, + + /// The adapter is transitioning to [#POWERED_ON] (Android only; iOS + /// reports the transition as a direct state change). + TURNING_ON, + + /// The adapter is on and ready for scanning, connections and + /// advertising. + POWERED_ON, + + /// The adapter is transitioning to [#POWERED_OFF] (Android only). + TURNING_OFF +} diff --git a/CodenameOne/src/com/codename1/bluetooth/AdapterStateListener.java b/CodenameOne/src/com/codename1/bluetooth/AdapterStateListener.java new file mode 100644 index 00000000000..158d879238a --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/AdapterStateListener.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// Observes local adapter state changes registered via +/// [Bluetooth#addAdapterStateListener(AdapterStateListener)]. Single +/// abstract method so application code can pass a lambda. Always invoked on +/// the EDT. +public interface AdapterStateListener { + /// Fired on the EDT whenever the adapter transitions to a new + /// [AdapterState]. + void adapterStateChanged(AdapterState newState); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/Bluetooth.java b/CodenameOne/src/com/codename1/bluetooth/Bluetooth.java new file mode 100644 index 00000000000..0acf534efdc --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/Bluetooth.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; + +/// Entry point for the Codename One Bluetooth API -- adapter state, runtime +/// permissions and the capability queries that let cross-platform code +/// branch cleanly. Obtain the platform implementation via [#getInstance()]; +/// the returned subclass is owned by the active port and never `null`. +/// +/// The API is split by role: +/// - `com.codename1.bluetooth.le` -- BLE central: scanning, connections and +/// the GATT client, plus L2CAP channels. +/// - `com.codename1.bluetooth.le.server` -- BLE peripheral: advertising and +/// the local GATT server. +/// - `com.codename1.bluetooth.classic` -- classic Bluetooth: discovery and +/// RFCOMM stream connections (Android and the simulator only). +/// +/// #### Quick start: check support and permissions +/// +/// ```java +/// Bluetooth bt = Bluetooth.getInstance(); +/// if (!bt.isLeSupported()) { +/// // hide the feature +/// return; +/// } +/// bt.requestPermissions(BluetoothPermission.SCAN, BluetoothPermission.CONNECT) +/// .onResult((granted, err) -> { +/// if (err == null && granted) { +/// // start scanning... +/// } +/// }); +/// ``` +/// +/// #### Threading +/// +/// Every callback of the Bluetooth API -- `AsyncResource` results, scan +/// results, connection events, notifications, adapter state changes -- is +/// delivered on the EDT. The only exceptions are the blocking +/// `InputStream`/`OutputStream` pairs of RFCOMM and L2CAP connections, +/// which must be consumed off the EDT. +/// +/// #### Platform support +/// +/// - **Android** -- full stack: BLE central + peripheral, L2CAP (API 29+), +/// classic RFCOMM. Permissions and manifest entries are auto-injected at +/// build time when these packages are referenced. +/// - **iOS / Mac Catalyst** -- CoreBluetooth: BLE central + peripheral and +/// L2CAP. Classic RFCOMM is not exposed by iOS. The `NSBluetooth*` plist +/// entries and CoreBluetooth framework are auto-injected at build time. +/// - **JavaSE simulator** -- a scriptable virtual Bluetooth stack with a +/// full feature matrix (Simulate -> Bluetooth Simulation), plus an +/// optional native backend that talks to the host machine's real +/// Bluetooth hardware (BLE central only). +/// - **JavaScript** -- Web Bluetooth where the browser supports it: central +/// role via the browser's device chooser, GATT client and notifications. +/// - **All other ports** -- this base class is returned as-is and reports +/// Bluetooth as unsupported; every operation fails fast with +/// [BluetoothError#NOT_SUPPORTED]. +public class Bluetooth { + + private ArrayList adapterListeners; + + /// Ports construct subclasses. Application code obtains the active + /// instance via [#getInstance()]. + protected Bluetooth() { + } + + /// Returns the platform-specific singleton owned by the current port. + /// On ports that do not implement Bluetooth this returns a base + /// [Bluetooth] instance that reports the feature as unsupported; + /// calling code never needs a `null` check or a platform-specific + /// `if`. + public static Bluetooth getInstance() { + Bluetooth b = Display.getInstance().getBluetooth(); + return b != null ? b : DEFAULT; + } + + private static final Bluetooth DEFAULT = new Bluetooth(); + + /// `true` when this port/device exposes any Bluetooth functionality at + /// all. Returns `false` on the fallback base class. + public boolean isSupported() { + return false; + } + + /// `true` when the BLE central role (scanning, connections, GATT + /// client) is available. Defaults to `false`. + public boolean isLeSupported() { + return false; + } + + /// `true` when classic Bluetooth (discovery, RFCOMM) is available. + /// Always `false` on iOS, which does not expose classic Bluetooth to + /// apps. Defaults to `false`. + public boolean isClassicSupported() { + return false; + } + + /// `true` when the BLE peripheral role (advertising and a local GATT + /// server) is available. `false` on tvOS/watchOS and on the JavaScript + /// port. Defaults to `false`. + public boolean isPeripheralModeSupported() { + return false; + } + + /// `true` when L2CAP connection-oriented channels are available + /// (Android 10+, iOS 11+). Defaults to `false`. + public boolean isL2capSupported() { + return false; + } + + /// The current state of the local adapter. The fallback base class + /// reports [AdapterState#UNSUPPORTED]. + public AdapterState getAdapterState() { + return AdapterState.UNSUPPORTED; + } + + /// Convenience for `getAdapterState() == AdapterState.POWERED_ON`. + public boolean isEnabled() { + return getAdapterState() == AdapterState.POWERED_ON; + } + + /// Registers a listener notified on the EDT whenever the adapter state + /// changes. Listeners stay registered until removed via + /// [#removeAdapterStateListener(AdapterStateListener)]. + public void addAdapterStateListener(AdapterStateListener l) { + if (l == null) { + return; + } + synchronized (this) { + if (adapterListeners == null) { + adapterListeners = new ArrayList(); + } + if (!adapterListeners.contains(l)) { + adapterListeners.add(l); + } + } + } + + /// Removes a listener previously added via + /// [#addAdapterStateListener(AdapterStateListener)]. + public void removeAdapterStateListener(AdapterStateListener l) { + synchronized (this) { + if (adapterListeners != null) { + adapterListeners.remove(l); + } + } + } + + /// Called by ports (from any thread) when the adapter transitions to a + /// new state; dispatches to the registered listeners on the EDT. + protected final void fireAdapterStateChanged(AdapterState newState) { + Object[] snapshot; + synchronized (this) { + if (adapterListeners == null || adapterListeners.isEmpty()) { + return; + } + snapshot = adapterListeners.toArray(); + } + dispatchAdapterState(snapshot, newState); + } + + // The dispatch lives in a static method so the Runnable doesn't carry a + // synthetic outer-Bluetooth reference (SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON). + private static void dispatchAdapterState(final Object[] snapshot, + final AdapterState newState) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + for (Object listener : snapshot) { + ((AdapterStateListener) listener) + .adapterStateChanged(newState); + } + } + }); + } + + /// Asks the user to enable the Bluetooth adapter where the platform + /// supports it (the Android system dialog). Resolves `true` when the + /// adapter was enabled, `false` when the user declined or the platform + /// offers no programmatic enable flow (iOS, and this fallback base + /// class). Never fails just because the feature is unsupported. + public AsyncResource requestEnable() { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.FALSE); + return r; + } + + /// The BLE central role entry point -- scanning, connections, GATT + /// client. Never `null`: on ports without BLE a no-op instance is + /// returned whose operations fail fast with + /// [BluetoothError#NOT_SUPPORTED]; branch via [#isLeSupported()]. + public com.codename1.bluetooth.le.BluetoothLE getLE() { + return DefaultLE.INSTANCE; + } + + /// Lazy holder so the fallback LE instance is only created when a + /// port without BLE support is actually asked for it. + private static final class DefaultLE { + static final com.codename1.bluetooth.le.BluetoothLE INSTANCE = + new com.codename1.bluetooth.le.BluetoothLE() { + }; + } + + /// The classic Bluetooth role entry point -- discovery, bonding and + /// RFCOMM streams. Never `null`: on ports without classic Bluetooth + /// (iOS among them) a no-op instance is returned whose operations fail + /// fast with [BluetoothError#NOT_SUPPORTED]; branch via + /// [#isClassicSupported()]. + public com.codename1.bluetooth.classic.BluetoothClassic getClassic() { + return DefaultClassic.INSTANCE; + } + + /// Lazy holder so the fallback classic instance is only created when + /// a port without classic support is actually asked for it. + private static final class DefaultClassic { + static final com.codename1.bluetooth.classic.BluetoothClassic + INSTANCE = new com.codename1.bluetooth.classic.BluetoothClassic() { + }; + } + + /// `true` when the given runtime permission is currently granted. See + /// [BluetoothPermission] for the per-platform mapping. Defaults to + /// `false`. + public boolean hasPermission(BluetoothPermission permission) { + return false; + } + + /// Requests the given runtime permissions, prompting the user where + /// needed. Resolves `true` when every requested permission is granted. + /// Resolves `false` (rather than failing) when denied or when the + /// platform has no Bluetooth support at all. + public AsyncResource requestPermissions( + BluetoothPermission... permissions) { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.FALSE); + return r; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/BluetoothDevice.java b/CodenameOne/src/com/codename1/bluetooth/BluetoothDevice.java new file mode 100644 index 00000000000..3db503c8f2b --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/BluetoothDevice.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// Identity of a remote Bluetooth device, shared by the LE and classic +/// stacks. Ports construct concrete subclasses; application code receives +/// them from scans, discovery, and bonded-device listings. +/// +/// Two devices are equal when their [#getAddress()] values are equal, so +/// instances can be used directly as map keys. +public abstract class BluetoothDevice { + + /// Ports construct subclasses; application code receives instances from + /// the scanning and discovery APIs. + protected BluetoothDevice() { + } + + /// A stable identifier for this device: the MAC address on Android and + /// the desktop ports (`AA:BB:CC:DD:EE:FF`), but the per-app CoreBluetooth + /// identifier UUID string on iOS. The value is stable for this app on + /// this device and safe to persist for reconnection **on the same + /// install**, but it is NOT portable across phones or platforms -- never + /// treat it as the peripheral's real hardware address. + public abstract String getAddress(); + + /// The advertised or cached device name; may be `null` when the device + /// never advertised one. + public abstract String getName(); + + /// The transport family of this device; defaults to + /// [DeviceType#UNKNOWN]. + public DeviceType getType() { + return DeviceType.UNKNOWN; + } + + /// The current pairing state of this device; defaults to + /// [BondState#NONE]. iOS manages bonds internally and usually reports + /// [BondState#NONE] -- see [BondState]. + public BondState getBondState() { + return BondState.NONE; + } + + /// Devices are equal when their [#getAddress()] values are equal. + @Override + public final boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BluetoothDevice)) { + return false; + } + String a = getAddress(); + return a != null && a.equals(((BluetoothDevice) o).getAddress()); + } + + @Override + public final int hashCode() { + String a = getAddress(); + return a == null ? System.identityHashCode(this) : a.hashCode(); + } + + @Override + public String toString() { + return "BluetoothDevice(" + getAddress() + ", " + getName() + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/BluetoothError.java b/CodenameOne/src/com/codename1/bluetooth/BluetoothError.java new file mode 100644 index 00000000000..0ed03ed76f9 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/BluetoothError.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// Typed error codes carried by every [BluetoothException] fired through the +/// failure path of the asynchronous Bluetooth APIs. Callers branch on these +/// via [BluetoothException#getError()] instead of string-matching messages. +public enum BluetoothError { + /// The requested feature is not available on this port or device. + /// Capability queries such as [Bluetooth#isLeSupported()] let + /// cross-platform code branch before ever seeing this code; the no-op + /// fallback base classes fail every operation with it. + NOT_SUPPORTED, + + /// The adapter is powered off or otherwise unavailable. Ask the user to + /// enable Bluetooth, optionally via [Bluetooth#requestEnable()]. + POWERED_OFF, + + /// A required runtime permission or OS authorization is missing -- + /// Android 12+ `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT`/`BLUETOOTH_ADVERTISE` + /// runtime grants, location on older Android, or the iOS Bluetooth + /// privacy authorization. See [Bluetooth#requestPermissions]. + UNAUTHORIZED, + + /// The OS refused to start a scan or aborted a running one (throttling, + /// too many concurrent scans, hardware error). + SCAN_FAILED, + + /// Advertising could not be started -- payload too large, too many + /// concurrent advertisers, or hardware limitation. + ADVERTISE_FAILED, + + /// A connection attempt failed or timed out before it was established. + CONNECTION_FAILED, + + /// An established link dropped unexpectedly. Delivered as the reason of + /// the disconnection event and used to fail operations that were + /// in-flight when the link died. + CONNECTION_LOST, + + /// A GATT operation was attempted while the peripheral is not connected. + NOT_CONNECTED, + + /// ATT-level failure reported by the remote GATT server. + /// [BluetoothException#getGattStatus()] carries the raw status code. + GATT_ERROR, + + /// Bonding/pairing failed or was rejected by the remote device. + BOND_FAILED, + + /// The platform never delivered a completion callback for an operation + /// within the safety timeout. The internal per-peripheral operation + /// queue fails the operation with this code and moves on rather than + /// wedging. + TIMEOUT, + + /// Resource conflict -- e.g. an RFCOMM channel is already in use or the + /// adapter is busy with a conflicting operation. + BUSY, + + /// Transport I/O failure on a non-stream operation. Stream reads/writes + /// on RFCOMM/L2CAP throw plain `java.io.IOException` instead. + IO_ERROR, + + /// The user dismissed a system dialog (enable request, pairing prompt, + /// device chooser) or the operation was cancelled via + /// `AsyncResource.cancel()`. + USER_CANCELED, + + /// Unclassified failure; the exception message carries the details. + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/bluetooth/BluetoothException.java b/CodenameOne/src/com/codename1/bluetooth/BluetoothException.java new file mode 100644 index 00000000000..50b2947cbef --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/BluetoothException.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// Thrown via the failure path of every `AsyncResource` returned by the +/// Bluetooth APIs. [#getError()] returns a typed [BluetoothError] so callers +/// can react without string-matching the message; for +/// [BluetoothError#GATT_ERROR] failures [#getGattStatus()] additionally +/// exposes the raw ATT status code reported by the remote device. +public class BluetoothException extends Exception { + + private final BluetoothError error; + private final int gattStatus; + + public BluetoothException(BluetoothError error) { + super(error == null ? "UNKNOWN" : error.name()); + this.error = error == null ? BluetoothError.UNKNOWN : error; + this.gattStatus = -1; + } + + public BluetoothException(BluetoothError error, String message) { + super(message); + this.error = error == null ? BluetoothError.UNKNOWN : error; + this.gattStatus = -1; + } + + public BluetoothException(BluetoothError error, String message, + Throwable cause) { + super(message, cause); + this.error = error == null ? BluetoothError.UNKNOWN : error; + this.gattStatus = -1; + } + + public BluetoothException(BluetoothError error, String message, + int gattStatus) { + super(message); + this.error = error == null ? BluetoothError.UNKNOWN : error; + this.gattStatus = gattStatus; + } + + /// Typed error code describing the failure. Never `null`. + public BluetoothError getError() { + return error; + } + + /// The raw ATT status code reported by the remote GATT server when + /// [#getError()] is [BluetoothError#GATT_ERROR]; `-1` otherwise. + public int getGattStatus() { + return gattStatus; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/BluetoothPermission.java b/CodenameOne/src/com/codename1/bluetooth/BluetoothPermission.java new file mode 100644 index 00000000000..c40e33337f9 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/BluetoothPermission.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// The runtime permissions the Bluetooth API may need, abstracted across +/// platforms. Query with [Bluetooth#hasPermission(BluetoothPermission)] and +/// request with [Bluetooth#requestPermissions(BluetoothPermission...)]. +/// +/// Mapping per platform: +/// - **Android 12+** -- `BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT` / +/// `BLUETOOTH_ADVERTISE` runtime permissions respectively. +/// - **Android 6-11** -- [#SCAN] maps to `ACCESS_FINE_LOCATION` (required +/// for BLE scan results); [#CONNECT] and [#ADVERTISE] need no runtime +/// grant. +/// - **iOS** -- a single Bluetooth privacy authorization covers all three. +public enum BluetoothPermission { + /// Discovering nearby devices (BLE scanning, classic discovery). + SCAN, + + /// Connecting to devices and using GATT / RFCOMM / L2CAP. + CONNECT, + + /// Advertising and operating a local GATT server. + ADVERTISE +} diff --git a/CodenameOne/src/com/codename1/bluetooth/BluetoothUuid.java b/CodenameOne/src/com/codename1/bluetooth/BluetoothUuid.java new file mode 100644 index 00000000000..70ebe495422 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/BluetoothUuid.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// Immutable 128-bit Bluetooth UUID value type used throughout the +/// `com.codename1.bluetooth` API for services, characteristics and +/// descriptors. The Codename One core has no `java.util.UUID`, so this class +/// fills that role and adds Bluetooth-specific conveniences: 16/32-bit SIG +/// assigned numbers are expanded over the Bluetooth Base UUID via +/// [#fromShort(int)] and recognized by [#isShortUuid()]. +/// +/// ```java +/// BluetoothUuid heartRate = BluetoothUuid.fromShort(0x180D); +/// BluetoothUuid custom = BluetoothUuid.fromString( +/// "5f47a3c0-1234-4e6b-9d00-000000000001"); +/// ``` +public final class BluetoothUuid { + + private static final long BASE_MSB_SUFFIX = 0x1000L; + private static final long BASE_LSB = 0x800000805F9B34FBL; + + private final long msb; + private final long lsb; + + /// Creates a UUID from its raw 64-bit halves, mirroring + /// `java.util.UUID(long, long)`. + public BluetoothUuid(long mostSigBits, long leastSigBits) { + this.msb = mostSigBits; + this.lsb = leastSigBits; + } + + /// Parses a UUID string. Accepts the full canonical 36 character form + /// (`0000180d-0000-1000-8000-00805f9b34fb`), an 8 hex digit 32-bit SIG + /// assigned number (`0000180D`) or a 4 hex digit 16-bit one (`180D`); + /// the short forms are expanded over the Bluetooth Base UUID. Case + /// insensitive. Throws `IllegalArgumentException` on malformed input. + public static BluetoothUuid fromString(String uuid) { + if (uuid == null) { + throw new IllegalArgumentException("uuid is null"); + } + String s = uuid.trim(); + int len = s.length(); + if (len == 4 || len == 8) { + return fromShort((int) parseHex(s, 0, len)); + } + if (len == 36) { + if (s.charAt(8) != '-' || s.charAt(13) != '-' + || s.charAt(18) != '-' || s.charAt(23) != '-') { + throw new IllegalArgumentException("Malformed UUID: " + uuid); + } + long high = (parseHex(s, 0, 8) << 32) + | (parseHex(s, 9, 13) << 16) + | parseHex(s, 14, 18); + long low = (parseHex(s, 19, 23) << 48) + | parseHex(s, 24, 36); + return new BluetoothUuid(high, low); + } + throw new IllegalArgumentException("Malformed UUID: " + uuid); + } + + /// Expands a 16 or 32-bit Bluetooth SIG assigned number over the + /// Bluetooth Base UUID, e.g. `fromShort(0x180D)` yields + /// `0000180d-0000-1000-8000-00805f9b34fb` (the Heart Rate service). + public static BluetoothUuid fromShort(int assignedNumber) { + return new BluetoothUuid( + ((assignedNumber & 0xFFFFFFFFL) << 32) | BASE_MSB_SUFFIX, + BASE_LSB); + } + + /// The most significant 64 bits, mirroring + /// `java.util.UUID.getMostSignificantBits()`. + public long getMostSignificantBits() { + return msb; + } + + /// The least significant 64 bits, mirroring + /// `java.util.UUID.getLeastSignificantBits()`. + public long getLeastSignificantBits() { + return lsb; + } + + /// `true` when this UUID is a Bluetooth Base UUID derivation, i.e. a + /// 16/32-bit SIG assigned number expanded via [#fromShort(int)] or an + /// equivalent string form. When `true`, [#getShortValue()] returns the + /// assigned number. + public boolean isShortUuid() { + return (msb & 0xFFFFFFFFL) == BASE_MSB_SUFFIX && lsb == BASE_LSB; + } + + /// The 16/32-bit SIG assigned number of a Base-UUID derivation. Throws + /// `IllegalStateException` when [#isShortUuid()] is `false`. + public int getShortValue() { + if (!isShortUuid()) { + throw new IllegalStateException( + "Not a Bluetooth Base UUID derivation: " + toString()); + } + return (int) (msb >>> 32); + } + + /// Canonical lowercase 36 character form, e.g. + /// `0000180d-0000-1000-8000-00805f9b34fb`. + @Override + public String toString() { + StringBuilder sb = new StringBuilder(36); + appendHex(sb, msb >>> 32, 8); + sb.append('-'); + appendHex(sb, msb >>> 16, 4); + sb.append('-'); + appendHex(sb, msb, 4); + sb.append('-'); + appendHex(sb, lsb >>> 48, 4); + sb.append('-'); + appendHex(sb, lsb, 12); + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BluetoothUuid)) { + return false; + } + BluetoothUuid u = (BluetoothUuid) o; + return msb == u.msb && lsb == u.lsb; + } + + @Override + public int hashCode() { + long hilo = msb ^ lsb; + return (int) (hilo >> 32) ^ (int) hilo; + } + + private static long parseHex(String s, int from, int to) { + long v = 0; + for (int i = from; i < to; i++) { + int d = Character.digit(s.charAt(i), 16); + if (d < 0) { + throw new IllegalArgumentException("Malformed UUID: " + s); + } + v = (v << 4) | d; + } + return v; + } + + /// Lowercase hex digits: Character.forDigit is not part of the device + /// API surface (CLDC11), so the mapping is spelled out here. + private static final char[] HEX = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + private static void appendHex(StringBuilder sb, long value, int digits) { + for (int i = digits - 1; i >= 0; i--) { + sb.append(HEX[(int) ((value >> (i * 4)) & 0xf)]); + } + } + + /// The Bluetooth Base UUID, `00000000-0000-1000-8000-00805f9b34fb`. All + /// 16/32-bit SIG assigned numbers are derivations of it. + public static final BluetoothUuid BASE = + new BluetoothUuid(BASE_MSB_SUFFIX, BASE_LSB); + + /// The Client Characteristic Configuration descriptor (`0x2902`) written + /// by the stack when enabling notifications/indications. + public static final BluetoothUuid CCCD = fromShort(0x2902); + + /// The classic Serial Port Profile service (`0x1101`) used as the default + /// UUID for RFCOMM connections. + public static final BluetoothUuid SPP = fromShort(0x1101); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/BondState.java b/CodenameOne/src/com/codename1/bluetooth/BondState.java new file mode 100644 index 00000000000..476d26edc7b --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/BondState.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// Pairing state of a remote [BluetoothDevice]. On iOS bonding is fully +/// OS-managed (triggered on first access to an encrypted characteristic), so +/// devices there usually report [#NONE] even when the OS holds a bond. +public enum BondState { + /// No bond exists with this device. + NONE, + + /// Pairing is in progress. + BONDING, + + /// The device is bonded/paired with this adapter. + BONDED +} diff --git a/CodenameOne/src/com/codename1/bluetooth/DeviceType.java b/CodenameOne/src/com/codename1/bluetooth/DeviceType.java new file mode 100644 index 00000000000..831d67f6d3e --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/DeviceType.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, 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.bluetooth; + +/// The transport family a remote [BluetoothDevice] supports, mirroring +/// Android's device-type classification. iOS only ever reports [#LE] or +/// [#UNKNOWN] since CoreBluetooth does not expose classic devices. +public enum DeviceType { + /// Classic (BR/EDR) only -- reachable via RFCOMM but not BLE. + CLASSIC, + + /// Bluetooth Low Energy only. + LE, + + /// Dual-mode device supporting both classic and LE transports. + DUAL, + + /// The transport family has not been determined. + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/bluetooth/classic/BluetoothClassic.java b/CodenameOne/src/com/codename1/bluetooth/classic/BluetoothClassic.java new file mode 100644 index 00000000000..77fa92fa577 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/classic/BluetoothClassic.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2012, 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.bluetooth.classic; + +import com.codename1.bluetooth.BluetoothDevice; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/// The classic Bluetooth (BR/EDR) role: device discovery, bonding and +/// RFCOMM stream connections. Obtain via +/// `Bluetooth.getInstance().getClassic()`; the instance is owned by the +/// active port and never `null`. +/// +/// Classic Bluetooth is available on Android, the JavaSE simulator and +/// desktop targets. iOS does not expose it to applications -- branch via +/// `Bluetooth.getInstance().isClassicSupported()`; on unsupported ports +/// every operation fails fast with [BluetoothError#NOT_SUPPORTED]. +public class BluetoothClassic { + + /// Ports construct subclasses. Application code obtains the active + /// instance via `Bluetooth.getInstance().getClassic()`. + protected BluetoothClassic() { + } + + /// Starts an inquiry scan (~12 seconds); the listener fires on the + /// EDT per sighting. Returns a live [ClassicDiscovery] handle that + /// resolves when the inquiry ends. On ports without classic Bluetooth + /// the handle is already failed with + /// [BluetoothError#NOT_SUPPORTED]. + public ClassicDiscovery startDiscovery(ClassicDiscoveryListener listener) { + ClassicDiscovery d = new ClassicDiscovery(); + d.error(notSupported()); + return d; + } + + /// The classic devices bonded with this adapter. Empty on ports + /// without classic Bluetooth. + public List getBondedDevices() { + return new ArrayList(); + } + + /// Initiates bonding with the given device, prompting the user where + /// the platform requires it. Resolves `true` once bonded. + public AsyncResource createBond(BluetoothDevice device) { + AsyncResource r = new AsyncResource(); + r.error(notSupported()); + return r; + } + + /// Asks the user to make this device discoverable for the given + /// duration (the Android system dialog). Resolves `true` when + /// granted; `false` when declined or unsupported. + public AsyncResource requestDiscoverable(int durationSeconds) { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.FALSE); + return r; + } + + /// Opens an RFCOMM connection to the given device's service -- + /// [BluetoothUuid#SPP] for classic serial-port devices. `secure` + /// requests an authenticated, encrypted link (pairing if needed). + public AsyncResource connect(BluetoothDevice device, + BluetoothUuid serviceUuid, boolean secure) { + AsyncResource r = + new AsyncResource(); + r.error(notSupported()); + return r; + } + + /// Opens an RFCOMM connection to the device with the given address + /// (persisted earlier) without a prior discovery. + public AsyncResource connect(String address, + BluetoothUuid serviceUuid, boolean secure) { + AsyncResource r = + new AsyncResource(); + r.error(notSupported()); + return r; + } + + /// Registers a listening RFCOMM endpoint (SPP server) under the given + /// name and service UUID with the local SDP database. Resolves with + /// the [RfcommServer]; call [RfcommServer#accept()] to take clients. + public AsyncResource listen(String serviceName, + BluetoothUuid serviceUuid, boolean secure) { + AsyncResource r = new AsyncResource(); + r.error(notSupported()); + return r; + } + + private static BluetoothException notSupported() { + return new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Classic Bluetooth is not supported on this platform"); + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/classic/ClassicDiscovery.java b/CodenameOne/src/com/codename1/bluetooth/classic/ClassicDiscovery.java new file mode 100644 index 00000000000..434ac250399 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/classic/ClassicDiscovery.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, 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.bluetooth.classic; + +import com.codename1.util.AsyncResource; + +/// Live handle of a running classic discovery (inquiry scan, ~12 seconds) +/// returned by +/// [BluetoothClassic#startDiscovery(ClassicDiscoveryListener)]. +/// +/// As an `AsyncResource` the handle resolves when discovery +/// *ends*: with `true` when the inquiry finished (naturally or via +/// [#stop()]), or with a +/// [com.codename1.bluetooth.BluetoothException] when the OS aborted it. +/// `cancel()` is equivalent to [#stop()]. +public class ClassicDiscovery extends AsyncResource { + + /// Created by [BluetoothClassic]; application code receives instances + /// from `startDiscovery`. + protected ClassicDiscovery() { + } + + /// Aborts the inquiry; the handle resolves with `true`. A no-op when + /// discovery already ended. + public void stop() { + if (isDone()) { + return; + } + onStop(); + if (!isDone()) { + complete(Boolean.TRUE); + } + } + + /// `true` while the inquiry is running. + public boolean isActive() { + return !isDone(); + } + + /// Equivalent to [#stop()]. + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + if (isDone()) { + return false; + } + stop(); + return true; + } + + /// Hook for the port to abort the platform inquiry; invoked exactly + /// once from [#stop()]. + protected void onStop() { + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/classic/ClassicDiscoveryListener.java b/CodenameOne/src/com/codename1/bluetooth/classic/ClassicDiscoveryListener.java new file mode 100644 index 00000000000..0f46e2e12a1 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/classic/ClassicDiscoveryListener.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2012, 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.bluetooth.classic; + +/// Receives device sightings during a classic discovery started via +/// [BluetoothClassic#startDiscovery(ClassicDiscoveryListener)]. Single +/// abstract method so application code can pass a lambda. Always invoked +/// on the EDT. +public interface ClassicDiscoveryListener { + /// Fired on the EDT for every device found by the inquiry scan. + void deviceDiscovered(ClassicScanResult result); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/classic/ClassicScanResult.java b/CodenameOne/src/com/codename1/bluetooth/classic/ClassicScanResult.java new file mode 100644 index 00000000000..57763dc17a9 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/classic/ClassicScanResult.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2012, 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.bluetooth.classic; + +import com.codename1.bluetooth.BluetoothDevice; + +/// One device sighting during a classic discovery, delivered to a +/// [ClassicDiscoveryListener]. +/// +/// Instances are constructed by ports; application code never creates +/// them. +public class ClassicScanResult { + + /// [#getRssi()] value when the platform did not report a signal + /// strength. + public static final int RSSI_UNKNOWN = Short.MIN_VALUE; + + private final BluetoothDevice device; + private final int rssi; + private final int majorDeviceClass; + private final int deviceClass; + + /// Constructed by ports when a discovery sighting arrives; not + /// application API. + public ClassicScanResult(BluetoothDevice device, int rssi, + int majorDeviceClass, int deviceClass) { + this.device = device; + this.rssi = rssi; + this.majorDeviceClass = majorDeviceClass; + this.deviceClass = deviceClass; + } + + /// The discovered device; connect via + /// [BluetoothClassic#connect(BluetoothDevice, + /// com.codename1.bluetooth.BluetoothUuid, boolean)]. + public BluetoothDevice getDevice() { + return device; + } + + /// The signal strength in dBm, or [#RSSI_UNKNOWN]. + public int getRssi() { + return rssi; + } + + /// The major device class from the Bluetooth Class-of-Device field + /// (computer, phone, audio, ...). + public int getMajorDeviceClass() { + return majorDeviceClass; + } + + /// The full device class from the Class-of-Device field. + public int getDeviceClass() { + return deviceClass; + } + + @Override + public String toString() { + return "ClassicScanResult(" + device.getAddress() + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/classic/RfcommConnection.java b/CodenameOne/src/com/codename1/bluetooth/classic/RfcommConnection.java new file mode 100644 index 00000000000..30183781946 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/classic/RfcommConnection.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2012, 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.bluetooth.classic; + +import com.codename1.bluetooth.BluetoothDevice; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// An open RFCOMM (Serial Port Profile style) stream connection to a +/// classic Bluetooth device, obtained via [BluetoothClassic#connect] or +/// accepted from an [RfcommServer]. +/// +/// The streams **block** and must be consumed off the EDT; reads/writes +/// throw plain `java.io.IOException` on transport failure. Always +/// [#close()] the connection when done. +public abstract class RfcommConnection { + + private final BluetoothDevice device; + + /// Constructed by ports; not application API. + protected RfcommConnection(BluetoothDevice device) { + this.device = device; + } + + /// The blocking input stream; consume off the EDT. + public abstract InputStream getInputStream() throws IOException; + + /// The blocking output stream; use off the EDT. + public abstract OutputStream getOutputStream() throws IOException; + + /// Closes the connection and both streams. + public abstract void close() throws IOException; + + /// `true` while the connection is open. + public abstract boolean isOpen(); + + /// The remote device of this connection. + public BluetoothDevice getDevice() { + return device; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/classic/RfcommServer.java b/CodenameOne/src/com/codename1/bluetooth/classic/RfcommServer.java new file mode 100644 index 00000000000..97c46c43b30 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/classic/RfcommServer.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2012, 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.bluetooth.classic; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.util.AsyncResource; + +/// A listening RFCOMM endpoint (SPP server) registered with the local SDP +/// database via [BluetoothClassic#listen(String, BluetoothUuid, boolean)]. +public abstract class RfcommServer { + + private final BluetoothUuid serviceUuid; + + /// Constructed by ports; not application API. + protected RfcommServer(BluetoothUuid serviceUuid) { + this.serviceUuid = serviceUuid; + } + + /// Resolves with the next incoming [RfcommConnection]. Call again + /// after each resolution to accept further clients. + public abstract AsyncResource accept(); + + /// Stops listening; pending [#accept()] calls fail with + /// [com.codename1.bluetooth.BluetoothError#IO_ERROR]. + public abstract void close(); + + /// The service UUID this server was registered under. + public BluetoothUuid getServiceUuid() { + return serviceUuid; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/classic/package-info.java b/CodenameOne/src/com/codename1/bluetooth/classic/package-info.java new file mode 100644 index 00000000000..cb8b2f8bc17 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/classic/package-info.java @@ -0,0 +1,31 @@ +/* + * 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. + */ +/// Classic Bluetooth (BR/EDR): inquiry discovery (`ClassicDiscovery`), +/// bonded-device listing and RFCOMM stream connections +/// (`RfcommConnection`, `RfcommServer`). +/// +/// Obtain via `Bluetooth.getInstance().getClassic()` and branch on +/// `Bluetooth.getInstance().isClassicSupported()` -- iOS does not expose +/// classic Bluetooth to applications. The RFCOMM streams block and must +/// be consumed off the EDT. +package com.codename1.bluetooth.classic; diff --git a/CodenameOne/src/com/codename1/bluetooth/gatt/GattCharacteristic.java b/CodenameOne/src/com/codename1/bluetooth/gatt/GattCharacteristic.java new file mode 100644 index 00000000000..4d4941bafc4 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/gatt/GattCharacteristic.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2012, 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.bluetooth.gatt; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/// A characteristic of a remote [GattService] -- the unit of data exchange +/// with a BLE peripheral. Read, write and subscribe operations are routed +/// through the owning peripheral's internal operation queue, so any number +/// of calls may be issued concurrently; they execute in order. +/// +/// Instances are constructed by the port during service discovery; +/// application code never creates them. +public class GattCharacteristic { + + /// The characteristic supports broadcast (bit `0x01`). + public static final int PROPERTY_BROADCAST = 0x01; + /// The characteristic can be read (bit `0x02`). + public static final int PROPERTY_READ = 0x02; + /// The characteristic can be written without response (bit `0x04`). + public static final int PROPERTY_WRITE_WITHOUT_RESPONSE = 0x04; + /// The characteristic can be written with response (bit `0x08`). + public static final int PROPERTY_WRITE = 0x08; + /// The characteristic supports notifications (bit `0x10`). + public static final int PROPERTY_NOTIFY = 0x10; + /// The characteristic supports indications (bit `0x20`). + public static final int PROPERTY_INDICATE = 0x20; + /// The characteristic supports signed writes (bit `0x40`). + public static final int PROPERTY_SIGNED_WRITE = 0x40; + /// The characteristic has extended properties (bit `0x80`). + public static final int PROPERTY_EXTENDED_PROPS = 0x80; + + private final GattService service; + private final BluetoothUuid uuid; + private final int properties; + private final int instanceId; + private final ArrayList descriptors = + new ArrayList(); + + /// Constructed by ports during service discovery; not application API. + public GattCharacteristic(GattService service, BluetoothUuid uuid, + int properties, int instanceId) { + this.service = service; + this.uuid = uuid; + this.properties = properties; + this.instanceId = instanceId; + } + + /// The UUID identifying this characteristic. + public BluetoothUuid getUuid() { + return uuid; + } + + /// The service this characteristic belongs to. + public GattService getService() { + return service; + } + + /// The raw `PROPERTY_*` bitmask as advertised by the peripheral. + public int getProperties() { + return properties; + } + + /// Disambiguates multiple instances of the same characteristic UUID + /// within one service. + public int getInstanceId() { + return instanceId; + } + + /// `true` when [#PROPERTY_READ] is set. + public boolean canRead() { + return (properties & PROPERTY_READ) != 0; + } + + /// `true` when [#PROPERTY_WRITE] is set. + public boolean canWrite() { + return (properties & PROPERTY_WRITE) != 0; + } + + /// `true` when [#PROPERTY_WRITE_WITHOUT_RESPONSE] is set. + public boolean canWriteWithoutResponse() { + return (properties & PROPERTY_WRITE_WITHOUT_RESPONSE) != 0; + } + + /// `true` when [#PROPERTY_NOTIFY] is set. + public boolean canNotify() { + return (properties & PROPERTY_NOTIFY) != 0; + } + + /// `true` when [#PROPERTY_INDICATE] is set. + public boolean canIndicate() { + return (properties & PROPERTY_INDICATE) != 0; + } + + /// The descriptors of this characteristic, in discovery order. + public List getDescriptors() { + return new ArrayList(descriptors); + } + + /// The first descriptor with the given UUID, or `null`. + public GattDescriptor getDescriptor(BluetoothUuid uuid) { + int size = descriptors.size(); + for (int i = 0; i < size; i++) { + GattDescriptor d = descriptors.get(i); + if (d.getUuid().equals(uuid)) { + return d; + } + } + return null; + } + + /// Called by ports while building the discovered GATT database; not + /// application API. + public void addDescriptor(GattDescriptor descriptor) { + descriptors.add(descriptor); + } + + /// Reads the current value. Resolves with the raw bytes on the EDT or + /// fails with a [com.codename1.bluetooth.BluetoothException]. + public AsyncResource read() { + return service.getPeripheral().readCharacteristic(this); + } + + /// Writes the value with response. Resolves `true` on the EDT once the + /// peripheral acknowledged the write. + public AsyncResource write(byte[] value) { + return service.getPeripheral().writeCharacteristic(this, value, true); + } + + /// Writes the value without response. Resolves `true` once the value + /// was queued to the controller -- there is no remote acknowledgement. + public AsyncResource writeWithoutResponse(byte[] value) { + return service.getPeripheral().writeCharacteristic(this, value, false); + } + + /// Subscribes to value changes. The first listener triggers the CCCD + /// write that arms notifications (indications are used when the + /// characteristic only supports [#PROPERTY_INDICATE]); the returned + /// resource resolves `true` once armed. Additional listeners resolve + /// immediately. Values stream to the listener on the EDT until + /// [#unsubscribe(GattNotificationListener)] or disconnection. + public AsyncResource subscribe(GattNotificationListener l) { + return service.getPeripheral().subscribe(this, l); + } + + /// Removes a listener added via + /// [#subscribe(GattNotificationListener)]. When the last listener is + /// removed the CCCD is written to disarm notifications; the returned + /// resource resolves once done. + public AsyncResource unsubscribe(GattNotificationListener l) { + return service.getPeripheral().unsubscribe(this, l); + } + + /// `true` while notifications/indications are armed for this + /// characteristic. + public boolean isSubscribed() { + return service.getPeripheral().isSubscribed(this); + } + + @Override + public String toString() { + return "GattCharacteristic(" + uuid + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/gatt/GattDescriptor.java b/CodenameOne/src/com/codename1/bluetooth/gatt/GattDescriptor.java new file mode 100644 index 00000000000..c3f8931cba2 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/gatt/GattDescriptor.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2012, 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.bluetooth.gatt; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.util.AsyncResource; + +/// A descriptor of a remote [GattCharacteristic]. Note that the Client +/// Characteristic Configuration descriptor ([BluetoothUuid#CCCD]) is +/// managed automatically by +/// [GattCharacteristic#subscribe(GattNotificationListener)] -- there is no +/// need to write it manually. +/// +/// Instances are constructed by the port during service discovery; +/// application code never creates them. +public class GattDescriptor { + + private final GattCharacteristic characteristic; + private final BluetoothUuid uuid; + + /// Constructed by ports during service discovery; not application API. + public GattDescriptor(GattCharacteristic characteristic, + BluetoothUuid uuid) { + this.characteristic = characteristic; + this.uuid = uuid; + } + + /// The UUID identifying this descriptor. + public BluetoothUuid getUuid() { + return uuid; + } + + /// The characteristic this descriptor belongs to. + public GattCharacteristic getCharacteristic() { + return characteristic; + } + + /// Reads the descriptor value. Resolves with the raw bytes on the EDT + /// or fails with a + /// [com.codename1.bluetooth.BluetoothException]. + public AsyncResource read() { + return characteristic.getService().getPeripheral() + .readDescriptor(this); + } + + /// Writes the descriptor value. Resolves `true` on the EDT once the + /// remote acknowledged the write. + public AsyncResource write(byte[] value) { + return characteristic.getService().getPeripheral() + .writeDescriptor(this, value); + } + + @Override + public String toString() { + return "GattDescriptor(" + uuid + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/gatt/GattNotificationListener.java b/CodenameOne/src/com/codename1/bluetooth/gatt/GattNotificationListener.java new file mode 100644 index 00000000000..1986396af15 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/gatt/GattNotificationListener.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2012, 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.bluetooth.gatt; + +/// Receives characteristic value changes (notifications and indications) +/// after subscribing via +/// [GattCharacteristic#subscribe(GattNotificationListener)]. Single +/// abstract method so application code can pass a lambda. Always invoked on +/// the EDT. +public interface GattNotificationListener { + /// Fired on the EDT for every notification/indication received while + /// subscribed. + void valueChanged(GattCharacteristic characteristic, byte[] value); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/gatt/GattService.java b/CodenameOne/src/com/codename1/bluetooth/gatt/GattService.java new file mode 100644 index 00000000000..3472f7a233a --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/gatt/GattService.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2012, 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.bluetooth.gatt; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.BlePeripheral; + +import java.util.ArrayList; +import java.util.List; + +/// A service discovered on a remote GATT server via +/// [BlePeripheral#discoverServices()]. Groups the +/// [GattCharacteristic]s the peripheral exposes. +/// +/// Instances are constructed by the port during service discovery; +/// application code never creates them. +public class GattService { + + private final BlePeripheral peripheral; + private final BluetoothUuid uuid; + private final boolean primary; + private final int instanceId; + private final ArrayList characteristics = + new ArrayList(); + private final ArrayList includedServices = + new ArrayList(); + + /// Constructed by ports during service discovery; not application API. + public GattService(BlePeripheral peripheral, BluetoothUuid uuid, + boolean primary, int instanceId) { + this.peripheral = peripheral; + this.uuid = uuid; + this.primary = primary; + this.instanceId = instanceId; + } + + /// The UUID identifying this service. + public BluetoothUuid getUuid() { + return uuid; + } + + /// `true` for a primary service, `false` for a secondary one. + public boolean isPrimary() { + return primary; + } + + /// Disambiguates multiple instances of the same service UUID on one + /// peripheral. + public int getInstanceId() { + return instanceId; + } + + /// The peripheral this service belongs to. + public BlePeripheral getPeripheral() { + return peripheral; + } + + /// The characteristics of this service, in discovery order. + public List getCharacteristics() { + return new ArrayList(characteristics); + } + + /// The first characteristic with the given UUID, or `null` when the + /// service has none. + public GattCharacteristic getCharacteristic(BluetoothUuid uuid) { + int size = characteristics.size(); + for (int i = 0; i < size; i++) { + GattCharacteristic c = characteristics.get(i); + if (c.getUuid().equals(uuid)) { + return c; + } + } + return null; + } + + /// Secondary services included by this service; usually empty. + public List getIncludedServices() { + return new ArrayList(includedServices); + } + + /// Called by ports while building the discovered GATT database; not + /// application API. + public void addCharacteristic(GattCharacteristic characteristic) { + characteristics.add(characteristic); + } + + /// Called by ports while building the discovered GATT database; not + /// application API. + public void addIncludedService(GattService service) { + includedServices.add(service); + } + + @Override + public String toString() { + return "GattService(" + uuid + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/gatt/GattStatus.java b/CodenameOne/src/com/codename1/bluetooth/gatt/GattStatus.java new file mode 100644 index 00000000000..02395f36035 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/gatt/GattStatus.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2012, 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.bluetooth.gatt; + +/// ATT protocol status codes, used both to interpret remote GATT failures +/// and to respond to requests when acting as a GATT server. [#getAttCode()] +/// returns the raw code from the Bluetooth specification. +public enum GattStatus { + /// The operation completed successfully. + SUCCESS(0x00), + + /// The attribute handle is invalid on this server. + INVALID_HANDLE(0x01), + + /// The attribute cannot be read. + READ_NOT_PERMITTED(0x02), + + /// The attribute cannot be written. + WRITE_NOT_PERMITTED(0x03), + + /// The request requires authentication (bonding/pairing) first. + INSUFFICIENT_AUTHENTICATION(0x05), + + /// The server does not support the request. + REQUEST_NOT_SUPPORTED(0x06), + + /// The offset of a long read/write is past the end of the attribute. + INVALID_OFFSET(0x07), + + /// The request requires an encrypted link. + INSUFFICIENT_ENCRYPTION(0x0F), + + /// The value length is invalid for this attribute. + INVALID_ATTRIBUTE_VALUE_LENGTH(0x0D), + + /// Unlikely, unclassified error. + UNLIKELY_ERROR(0x0E); + + private final int attCode; + + GattStatus(int attCode) { + this.attCode = attCode; + } + + /// The raw ATT status code from the Bluetooth specification. + public int getAttCode() { + return attCode; + } + + /// Maps a raw ATT status code to its enum constant, falling back to + /// [#UNLIKELY_ERROR] for codes without a dedicated constant. + public static GattStatus fromAttCode(int code) { + for (GattStatus s : values()) { + if (s.attCode == code) { + return s; + } + } + return UNLIKELY_ERROR; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/gatt/package-info.java b/CodenameOne/src/com/codename1/bluetooth/gatt/package-info.java new file mode 100644 index 00000000000..b16a6c016e5 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/gatt/package-info.java @@ -0,0 +1,31 @@ +/* + * 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. + */ +/// The client-side GATT model discovered on a remote peripheral: +/// `GattService`, `GattCharacteristic` (read/write/subscribe), +/// `GattDescriptor`, notification listeners and ATT status codes. +/// +/// Instances are constructed by the active port during +/// `BlePeripheral.discoverServices()`; application code navigates the +/// model and issues operations, which route through the owning +/// peripheral's serialized operation queue. +package com.codename1.bluetooth.gatt; diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/BleBackend.java b/CodenameOne/src/com/codename1/bluetooth/helper/BleBackend.java new file mode 100644 index 00000000000..8359df2c817 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/BleBackend.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.util.AsyncResource; + +import java.util.List; + +/// The pluggable BLE engine seam. The simulator ships its own +/// {@code SimulatorBleBackend}; the real-radio {@link HelperBleBackend} +/// drives the host machine's adapter through the bundled +/// {@code cn1-ble-helper} process. Ports swap engines behind this interface. +public interface BleBackend { + + /// Receives the sightings of the single platform scan. + interface ScanSink { + void onResult(ScanResult result); + + void onFailed(BluetoothException reason); + } + + /// Observes adapter state transitions of this backend. + interface AdapterStateSink { + void adapterStateChanged(AdapterState newState); + } + + /// A stable identifier, e.g. {@code "simulator"} or {@code "native"}. + String getName(); + + boolean isLeSupported(); + + boolean isPeripheralModeSupported(); + + boolean isClassicSupported(); + + boolean isL2capSupported(); + + AdapterState getAdapterState(); + + /// Registers the single adapter-state observer (the port owner). + void setAdapterStateSink(AdapterStateSink sink); + + /// Starts the single platform scan; sightings flow into the sink. + void startScan(ScanSink sink); + + /// Stops the platform scan. + void stopScan(); + + /// The canonical peripheral at the address, or {@code null}. + BlePeripheral getPeripheral(String address); + + List getConnectedPeripherals(BluetoothUuid serviceFilter); + + List getBondedPeripherals(); + + AsyncResource openGattServer(GattServerListener listener); + + AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse); + + AsyncResource openL2capServer(boolean secure); + + /// Releases backend resources when it is switched away. + void shutdown(); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/HelperBleBackend.java b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBleBackend.java new file mode 100644 index 00000000000..eeb15252b37 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBleBackend.java @@ -0,0 +1,781 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/// The real-radio {@link BleBackend}: drives the host machine's Bluetooth +/// adapter through the bundled {@code cn1-ble-helper} subprocess (a Rust +/// btleplug bridge -- +/// CoreBluetooth on macOS, BlueZ on Linux, WinRT on Windows). Commands and +/// events travel as line-delimited JSON over the helper's stdin/stdout; see +/// {@code Ports/JavaSE/native/cn1-ble-helper/PROTOCOL.md}. +/// +///

This class is transport-agnostic: the child process's standard I/O is +/// reached only through a {@link HelperTransport} created by the injected +/// {@link HelperTransportFactory}, so a host with an operating-system process +/// API (the JavaSE simulator) and a host without it (the native Windows/Linux +/// ports, reaching the subprocess through a native bridge) share this exact +/// protocol, GATT and lifecycle logic. Nothing here references an OS process +/// API or a shutdown hook -- the owner calls {@link #shutdown()} on app +/// exit.

+/// +///

btleplug is central-only: LE scanning and GATT client operations are +/// supported; peripheral mode (GATT server / advertising), classic +/// Bluetooth, L2CAP channels and bonding are not, and the corresponding +/// capability queries report {@code false}.

+public class HelperBleBackend implements BleBackend { + + /// The backend name reported by {@link #getName()}. + public static final String NAME = "native"; + + /// The name of the fallback simulator backend, for error messages. + private static final String SIMULATOR_NAME = "simulator"; + + /// The wire protocol version this backend speaks. + public static final long PROTOCOL_VERSION = 1; + + /// Completion of one in-flight helper command. Exactly one of the two + /// methods fires, from the helper reader thread. + public interface PendingOp { + /// The command's terminal success event. + void onEvent(String event, Map payload); + + /// The command failed -- helper error event, crash or shutdown. + void onFailure(BluetoothException failure); + } + + /// Shared fire-and-forget completion for commands whose result the caller + /// ignores (e.g. scanStop while already tearing down). Static so it holds + /// no reference to the enclosing backend. + private static final PendingOp NO_OP = new PendingOp() { + @Override + public void onEvent(String event, Map payload) { + } + + @Override + public void onFailure(BluetoothException failure) { + // nothing to report -- the caller does not observe this op + } + }; + + private final HelperTransportFactory transportFactory; + + private final Object processLock = new Object(); + private HelperTransport transport; + private final AtomicBoolean shutdownRequested = new AtomicBoolean(); + /// Set after a crash: the backend stays dead until switched away. + private final AtomicBoolean helperFailed = new AtomicBoolean(); + + private final AtomicLong nextRequestId = new AtomicLong(1); + // ConcurrentHashMap is not on the device API surface (this class is + // translated for the native ports); a synchronized HashMap gives the + // same thread-safety for the caller-thread put / reader-thread + // remove access pattern. + private final Map pending = Collections.synchronizedMap( + new HashMap()); + private final HashMap peripheralCache = + new HashMap(); + private final Set connectedAddresses = Collections.synchronizedSet( + new HashSet()); + + private final AtomicReference adapterState = + new AtomicReference(AdapterState.UNKNOWN); + private final AtomicReference stateSink = + new AtomicReference(); + private final AtomicReference scanSink = + new AtomicReference(); + private final AtomicReference> capabilities = + new AtomicReference>(); + + /// Creates a backend that (re)starts the helper through the given + /// transport factory. The factory is responsible for how the child + /// process is launched -- resolving the binary and, on the JavaSE + /// simulator, building the OS subprocess. + public HelperBleBackend(HelperTransportFactory transportFactory) { + this.transportFactory = transportFactory; + } + + // ------------------------------------------------------------------ + // helper process lifecycle + // ------------------------------------------------------------------ + + private boolean ensureStarted() { + synchronized (processLock) { + if (transport != null && transport.isAlive()) { + return true; + } + if (shutdownRequested.get() || helperFailed.get()) { + return false; + } + try { + HelperTransport t = transportFactory.create(); + t.start(null); + transport = t; + startReader(t); + return true; + } catch (IOException ex) { + System.err.println("HelperBleBackend: failed to start " + + "cn1-ble-helper: " + ex); + transport = null; + return false; + } + } + } + + private void startReader(final HelperTransport t) { + // Not a daemon thread: Thread.setDaemon is not on the device API + // surface (CLDC11) this core class compiles against. The reader + // always terminates when the transport closes -- shutdown() closes + // it, and JavaSE's ProcessTransport additionally closes it from a + // JVM shutdown hook -- so it never blocks process exit. + Thread th = new Thread("cn1ble-helper-stdout") { + @Override + public void run() { + try { + String line; + while ((line = t.readLine()) != null) { + handleLine(line); + } + } catch (IOException ignored) { + // pipe closed -- fall through to the death handler + } + onHelperExited(t); + } + }; + th.start(); + } + + /// Reader thread epilogue: distinguish clean shutdown from a crash. + private void onHelperExited(HelperTransport t) { + synchronized (processLock) { + if (transport != t) { //NOPMD CompareObjectsWithEquals - identity check: is this still the live transport? + return; // superseded by a restart + } + transport = null; + if (!shutdownRequested.get()) { + helperFailed.set(true); + } + } + if (shutdownRequested.get()) { + return; + } + System.err.println("HelperBleBackend: the cn1-ble-helper process " + + "terminated unexpectedly. The native Bluetooth backend " + + "is unavailable; switch back with switchBackend(\"" + + SIMULATOR_NAME + "\")."); + BluetoothException failure = new BluetoothException( + BluetoothError.IO_ERROR, + "The cn1-ble-helper process terminated unexpectedly"); + failAllPending(failure); + ScanSink scan = scanSink.get(); + scanSink.set(null); + if (scan != null) { + scan.onFailed(new BluetoothException(BluetoothError.SCAN_FAILED, + "Scan aborted: the cn1-ble-helper process terminated")); + } + List all; + synchronized (peripheralCache) { + all = new ArrayList( + peripheralCache.values()); + } + connectedAddresses.clear(); + int size = all.size(); + for (int i = 0; i < size; i++) { + all.get(i).handleHelperDied(failure); + } + setAdapterState(AdapterState.UNSUPPORTED); + } + + private void failAllPending(BluetoothException failure) { + // remove per key (never clear()) so an op registered concurrently + // is either failed here or rejected by ensureStarted -- not dropped. + // Snapshot the keys under the map's own lock (synchronizedMap + // requires manual synchronization to iterate its views). + List ids; + synchronized (pending) { + ids = new ArrayList(pending.keySet()); + } + int size = ids.size(); + for (int i = 0; i < size; i++) { + PendingOp op = pending.remove(ids.get(i)); + if (op == null) { + continue; + } + try { + op.onFailure(failure); + } catch (RuntimeException ex) { + System.err.println("HelperBleBackend: pending-op failure " + + "callback threw: " + ex); + } + } + } + + private void setAdapterState(AdapterState newState) { + if (adapterState.get() == newState) { + return; + } + adapterState.set(newState); + AdapterStateSink sink = stateSink.get(); + if (sink != null) { + sink.adapterStateChanged(newState); + } + } + + // ------------------------------------------------------------------ + // incoming events + // ------------------------------------------------------------------ + + private void handleLine(String line) { + if (line.length() == 0) { + return; + } + Map obj; + try { + obj = Wire.parse(line); + } catch (Throwable t) { + System.err.println("HelperBleBackend: malformed helper output: " + + line); + return; + } + String event = Wire.str(obj, "event", ""); + if ("capabilities".equals(event)) { + capabilities.set(obj); + long version = Wire.longVal(obj, "version", -1); + if (version != PROTOCOL_VERSION) { + System.err.println("HelperBleBackend: helper speaks " + + "protocol version " + version + ", expected " + + PROTOCOL_VERSION + " -- continuing best-effort"); + } + return; + } + if ("stateChanged".equals(event)) { + setAdapterState(mapAdapterState(Wire.str(obj, "state", ""))); + return; + } + if ("scanResult".equals(event)) { + handleScanResult(obj); + return; + } + if ("notification".equals(event)) { + HelperBlePeripheral p = cachedPeripheral( + Wire.str(obj, "address", "")); + if (p != null) { + p.handleNotification(Wire.str(obj, "service", ""), + Wire.str(obj, "characteristic", ""), + Wire.str(obj, "value", "")); + } + return; + } + // connection transitions update shared state whether solicited or not + if ("connected".equals(event)) { + String address = Wire.str(obj, "address", ""); + connectedAddresses.add(address); + HelperBlePeripheral p = cachedPeripheral(address); + if (p != null) { + p.handleConnected(Wire.str(obj, "name", null)); + } + } else if ("disconnected".equals(event)) { + String address = Wire.str(obj, "address", ""); + connectedAddresses.remove(address); + HelperBlePeripheral p = cachedPeripheral(address); + if (p != null) { + p.handleDisconnected(Wire.str(obj, "reason", "")); + } + } + long requestId = Wire.longVal(obj, "requestId", -1); + if (requestId >= 0) { + PendingOp op = pending.remove(Long.valueOf(requestId)); + if (op == null) { + return; // late event of an already failed op + } + if ("error".equals(event)) { + op.onFailure(new BluetoothException( + mapErrorCode(Wire.str(obj, "code", "")), + Wire.str(obj, "message", "helper error"))); + } else { + op.onEvent(event, obj); + } + return; + } + if ("error".equals(event)) { + System.err.println("HelperBleBackend: helper error (" + + Wire.str(obj, "command", "?") + "): " + + Wire.str(obj, "message", "")); + return; + } + if (!"connected".equals(event) && !"disconnected".equals(event)) { + System.err.println("HelperBleBackend: unknown event '" + event + + "'"); + } + } + + private void handleScanResult(Map obj) { + String address = Wire.str(obj, "address", ""); + if (address.length() == 0) { + return; + } + String name = Wire.str(obj, "name", ""); + int rssi = Wire.intVal(obj, "rssi", -127); + HelperBlePeripheral p = peripheral(address); + p.updateFromScan(name, rssi); + ScanSink sink = scanSink.get(); + if (sink == null) { + return; + } + AdvertisementData ad = new AdvertisementData(); + if (name.length() > 0) { + ad.setLocalName(name); + } + List uuids = Wire.list(obj, "serviceUuids"); + int size = uuids.size(); + for (int i = 0; i < size; i++) { + BluetoothUuid u = parseUuid(String.valueOf(uuids.get(i))); + if (u != null) { + ad.addServiceUuid(u); + } + } + Map manufacturer = Wire.map(obj.get( + "manufacturerData")); + for (Map.Entry e : manufacturer.entrySet()) { + try { + ad.addManufacturerData(Integer.parseInt(e.getKey()), + Wire.decodeBase64(String.valueOf(e.getValue()))); + } catch (RuntimeException ignored) { + } + } + Map serviceData = Wire.map(obj.get("serviceData")); + for (Map.Entry e : serviceData.entrySet()) { + BluetoothUuid u = parseUuid(e.getKey()); + if (u != null) { + ad.addServiceData(u, + Wire.decodeBase64(String.valueOf(e.getValue()))); + } + } + if (obj.containsKey("txPower")) { + ad.setTxPowerLevel(Integer.valueOf( + Wire.intVal(obj, "txPower", 0))); + } + sink.onResult(new ScanResult(p, rssi, ad, true, + System.currentTimeMillis())); + } + + private static BluetoothUuid parseUuid(String s) { + try { + return BluetoothUuid.fromString(s); + } catch (RuntimeException ex) { + return null; + } + } + + public static AdapterState mapAdapterState(String state) { + if ("poweredOn".equals(state)) { + return AdapterState.POWERED_ON; + } + if ("poweredOff".equals(state)) { + return AdapterState.POWERED_OFF; + } + if ("unsupported".equals(state)) { + return AdapterState.UNSUPPORTED; + } + if ("unauthorized".equals(state)) { + return AdapterState.UNAUTHORIZED; + } + return AdapterState.UNKNOWN; + } + + public static BluetoothError mapErrorCode(String code) { + if ("notSupported".equals(code)) { + return BluetoothError.NOT_SUPPORTED; + } + if ("unauthorized".equals(code)) { + return BluetoothError.UNAUTHORIZED; + } + if ("poweredOff".equals(code)) { + return BluetoothError.POWERED_OFF; + } + if ("scanFailed".equals(code)) { + return BluetoothError.SCAN_FAILED; + } + if ("connectFailed".equals(code) + || "unknownPeripheral".equals(code)) { + return BluetoothError.CONNECTION_FAILED; + } + if ("notConnected".equals(code)) { + return BluetoothError.NOT_CONNECTED; + } + if ("unknownCharacteristic".equals(code) + || "unknownDescriptor".equals(code)) { + return BluetoothError.GATT_ERROR; + } + if ("timeout".equals(code)) { + return BluetoothError.TIMEOUT; + } + if ("ioError".equals(code)) { + return BluetoothError.IO_ERROR; + } + return BluetoothError.UNKNOWN; + } + + // ------------------------------------------------------------------ + // outgoing commands + // ------------------------------------------------------------------ + + /// Registers the pending op and writes the command line; the id inside + /// {@code json} must be the returned request id, so callers build the + /// line via {@link #nextId()} first. + private void send(long id, String json, PendingOp op) { + pending.put(Long.valueOf(id), op); + if (!ensureStarted()) { + pending.remove(Long.valueOf(id)); + op.onFailure(new BluetoothException(BluetoothError.IO_ERROR, + "The cn1-ble-helper process could not be started")); + return; + } + IOException failure = null; + synchronized (processLock) { + try { + if (transport == null) { + throw new IOException("helper stdin closed"); + } + transport.writeLine(json); + } catch (IOException ex) { + failure = ex; + } + } + if (failure != null) { + PendingOp mine = pending.remove(Long.valueOf(id)); + if (mine != null) { + mine.onFailure(new BluetoothException( + BluetoothError.IO_ERROR, + "Failed to send command to cn1-ble-helper: " + + failure)); + } + } + } + + private long nextId() { + return nextRequestId.getAndIncrement(); + } + + void connectPeripheral(String address, PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "connect").put("id", id) + .put("address", address).line(), op); + } + + void disconnectPeripheral(String address, PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "disconnect").put("id", id) + .put("address", address).line(), op); + } + + void discoverServices(String address, PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "discover").put("id", id) + .put("address", address).line(), op); + } + + void readCharacteristic(String address, String service, + String characteristic, PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "read").put("id", id) + .put("address", address).put("service", service) + .put("characteristic", characteristic).line(), op); + } + + void writeCharacteristic(String address, String service, + String characteristic, String valueBase64, boolean noResponse, + PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "write").put("id", id) + .put("address", address).put("service", service) + .put("characteristic", characteristic) + .put("value", valueBase64) + .put("noResponse", noResponse).line(), op); + } + + void setNotifications(String address, String service, + String characteristic, boolean enable, PendingOp op) { + long id = nextId(); + send(id, Wire.obj() + .put("cmd", enable ? "subscribe" : "unsubscribe") + .put("id", id).put("address", address) + .put("service", service) + .put("characteristic", characteristic).line(), op); + } + + void readDescriptor(String address, String service, + String characteristic, String descriptor, PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "readDescriptor").put("id", id) + .put("address", address).put("service", service) + .put("characteristic", characteristic) + .put("descriptor", descriptor).line(), op); + } + + void writeDescriptor(String address, String service, + String characteristic, String descriptor, String valueBase64, + PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "writeDescriptor").put("id", id) + .put("address", address).put("service", service) + .put("characteristic", characteristic) + .put("descriptor", descriptor) + .put("value", valueBase64).line(), op); + } + + void readRssi(String address, PendingOp op) { + long id = nextId(); + send(id, Wire.obj().put("cmd", "readRssi").put("id", id) + .put("address", address).line(), op); + } + + /// True when the helper's capability handshake advertises the key. + public boolean helperSupports(String capability) { + Map caps = capabilities.get(); + return caps != null && Wire.boolVal(caps, capability, false); + } + + // ------------------------------------------------------------------ + // peripheral cache + // ------------------------------------------------------------------ + + /// The canonical peripheral wrapper for the address. + HelperBlePeripheral peripheral(String address) { + synchronized (peripheralCache) { + HelperBlePeripheral p = peripheralCache.get(address); + if (p == null) { + p = new HelperBlePeripheral(this, address); + peripheralCache.put(address, p); + } + return p; + } + } + + private HelperBlePeripheral cachedPeripheral(String address) { + synchronized (peripheralCache) { + return peripheralCache.get(address); + } + } + + // ------------------------------------------------------------------ + // BleBackend + // ------------------------------------------------------------------ + + @Override + public String getName() { + return NAME; + } + + @Override + public boolean isLeSupported() { + return true; + } + + @Override + public boolean isPeripheralModeSupported() { + return false; // btleplug is central-only + } + + @Override + public boolean isClassicSupported() { + return false; + } + + @Override + public boolean isL2capSupported() { + return false; + } + + @Override + public AdapterState getAdapterState() { + return adapterState.get(); + } + + @Override + public void setAdapterStateSink(AdapterStateSink sink) { + stateSink.set(sink); + if (sink != null) { + // installing the sink is the backend's activation point -- + // boot the helper so the adapter state handshake arrives + ensureStarted(); + } + } + + @Override + public void startScan(final ScanSink sink) { + stopScan(); + scanSink.set(sink); + long id = nextId(); + send(id, Wire.obj().put("cmd", "scanStart").put("id", id).line(), + new PendingOp() { + @Override + public void onEvent(String event, + Map payload) { + // scanStarted -- sightings follow as scanResult + } + + @Override + public void onFailure(BluetoothException failure) { + if (scanSink.get() == sink) { //NOPMD CompareObjectsWithEquals - identity check: is this still the registered sink? + scanSink.set(null); + } + sink.onFailed(failure); + } + }); + } + + @Override + public void stopScan() { + scanSink.set(null); + synchronized (processLock) { + if (transport == null || !transport.isAlive()) { + return; // never boot the helper just to stop a scan + } + } + long id = nextId(); + send(id, Wire.obj().put("cmd", "scanStop").put("id", id).line(), NO_OP); + } + + @Override + public BlePeripheral getPeripheral(String address) { + return cachedPeripheral(address); + } + + @Override + public List getConnectedPeripherals( + BluetoothUuid serviceFilter) { + ArrayList out = new ArrayList(); + // snapshot under the set's lock (synchronizedSet needs manual + // synchronization to iterate) + List addresses; + synchronized (connectedAddresses) { + addresses = new ArrayList(connectedAddresses); + } + int n = addresses.size(); + for (int ai = 0; ai < n; ai++) { + String address = addresses.get(ai); + HelperBlePeripheral p = cachedPeripheral(address); + if (p == null) { + continue; + } + if (serviceFilter != null + && p.getService(serviceFilter) == null) { + continue; + } + out.add(p); + } + return out; + } + + @Override + public List getBondedPeripherals() { + // btleplug exposes no bonded-device registry + return new ArrayList(); + } + + @Override + public AsyncResource openGattServer( + GattServerListener listener) { + AsyncResource out = new AsyncResource(); + out.error(notSupported("GATT server")); + return out; + } + + @Override + public AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse) { + AsyncResource out = + new AsyncResource(); + out.error(notSupported("BLE advertising")); + return out; + } + + @Override + public AsyncResource openL2capServer(boolean secure) { + AsyncResource out = new AsyncResource(); + out.error(notSupported("L2CAP")); + return out; + } + + private static BluetoothException notSupported(String feature) { + return new BluetoothException(BluetoothError.NOT_SUPPORTED, feature + + " is not supported by the native JavaSE backend" + + " (btleplug is central-only)"); + } + + @Override + public void shutdown() { + shutdownInternal(); + } + + private void shutdownInternal() { + HelperTransport t; + synchronized (processLock) { + shutdownRequested.set(true); + t = transport; + if (t != null) { + try { + t.writeLine("{\"cmd\":\"shutdown\"}"); + } catch (IOException ignored) { + } + } + transport = null; + } + scanSink.set(null); + failAllPending(new BluetoothException(BluetoothError.IO_ERROR, + "The native Bluetooth backend was shut down")); + if (t != null) { + // the transport honors the shutdown command with a grace period + // before destroying the child process + t.close(); + } + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/HelperBlePeripheral.java b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBlePeripheral.java new file mode 100644 index 00000000000..df895a2822b --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBlePeripheral.java @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.BondState; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionOptions; +import com.codename1.bluetooth.le.ConnectionPriority; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.bluetooth.helper.HelperBleBackend.PendingOp; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/// Real-radio {@link BlePeripheral} bridging the core GATT client contract +/// onto {@code cn1-ble-helper} commands. One canonical instance exists per +/// address (cached by {@link HelperBleBackend}), so discovered +/// {@link GattCharacteristic} objects stay identical across the +/// scan/connect/notify lifecycle. +/// +///

btleplug constraints surface here: no bonding +/// ({@link #doCreateBond}), no L2CAP ({@link #doOpenL2cap}), no MTU +/// negotiation ({@link #doRequestMtu} resolves with the current value), no +/// connection-priority hints, and RSSI reads answer with the last +/// advertisement sighting.

+public class HelperBlePeripheral extends BlePeripheral { + + private final HelperBleBackend backend; + private final String address; + private final AtomicReference name = new AtomicReference(); + private final AtomicInteger lastRssi = new AtomicInteger(-127); + private final AtomicBoolean rssiSeen = new AtomicBoolean(); + + private final Object dbLock = new Object(); + /// service|characteristic uuid to canonical discovered instance + private HashMap characteristicIndex = + new HashMap(); + + HelperBlePeripheral(HelperBleBackend backend, String address) { + this.backend = backend; + this.address = address; + } + + @Override + public String getAddress() { + return address; + } + + @Override + public String getName() { + return name.get(); + } + + @Override + public DeviceType getType() { + return DeviceType.LE; + } + + @Override + public BondState getBondState() { + // btleplug exposes no bond registry + return BondState.NONE; + } + + // ------------------------------------------------------------------ + // backend event entry points + // ------------------------------------------------------------------ + + /// A scan sighting refreshed the advertised name / RSSI. + void updateFromScan(String advertisedName, int rssi) { + if (advertisedName != null && advertisedName.length() > 0) { + name.set(advertisedName); + } + lastRssi.set(rssi); + rssiSeen.set(true); + } + + /// OS-observed link establishment (solicited or not). + void handleConnected(String reportedName) { + if (reportedName != null && reportedName.length() > 0) { + name.set(reportedName); + } + fireConnectionStateChanged(ConnectionState.CONNECTED, null); + } + + /// OS-observed link teardown; {@code reason} is empty when benign. + void handleDisconnected(String reason) { + BluetoothException cause = null; + if (reason != null && reason.length() > 0) { + cause = new BluetoothException(BluetoothError.CONNECTION_LOST, + reason); + } + fireConnectionStateChanged(ConnectionState.DISCONNECTED, cause); + } + + /// The helper process died with this peripheral possibly connected. + void handleHelperDied(BluetoothException failure) { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, failure); + } + + /// Routes a helper notification to the canonical characteristic. + void handleNotification(String serviceUuid, String characteristicUuid, + String valueBase64) { + GattCharacteristic c; + synchronized (dbLock) { + c = characteristicIndex.get(serviceUuid + "|" + + characteristicUuid); + } + if (c != null) { + fireNotification(c, Wire.decodeBase64(valueBase64)); + } + } + + // ------------------------------------------------------------------ + // BlePeripheral SPI + // ------------------------------------------------------------------ + + @Override + protected void doConnect(ConnectionOptions options, + final AsyncResource out) { + backend.connectPeripheral(address, new PendingOp() { + @Override + public void onEvent(String event, Map payload) { + String reportedName = Wire.str(payload, "name", null); + if (reportedName != null && reportedName.length() > 0) { + name.set(reportedName); + } + if (!out.isDone()) { + out.complete(HelperBlePeripheral.this); + } + } + + @Override + public void onFailure(BluetoothException failure) { + if (!out.isDone()) { + out.error(failure); + } + } + }); + } + + @Override + protected void doDisconnect() { + backend.disconnectPeripheral(address, new PendingOp() { + @Override + public void onEvent(String event, Map payload) { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, + null); + } + + @Override + public void onFailure(BluetoothException failure) { + // the link is gone either way + fireConnectionStateChanged(ConnectionState.DISCONNECTED, + null); + } + }); + } + + @Override + protected void doDiscoverServices( + final AsyncResource> out) { + backend.discoverServices(address, new PendingOp() { + @Override + public void onEvent(String event, Map payload) { + List services = buildGattModel(payload); + if (!out.isDone()) { + out.complete(services); + } + } + + @Override + public void onFailure(BluetoothException failure) { + if (!out.isDone()) { + out.error(failure); + } + } + }); + } + + /// Maps the helper's discovered payload to canonical core instances. + private List buildGattModel(Map payload) { + ArrayList services = new ArrayList(); + HashMap index = + new HashMap(); + List svcArr = Wire.list(payload, "services"); + int size = svcArr.size(); + for (int i = 0; i < size; i++) { + Map svc = Wire.map(svcArr.get(i)); + String svcUuid = Wire.str(svc, "uuid", ""); + GattService s = new GattService(this, + parseUuid(svcUuid), Wire.boolVal(svc, "primary", true), + i); + List chArr = Wire.list(svc, "characteristics"); + int cs = chArr.size(); + for (int j = 0; j < cs; j++) { + Map ch = Wire.map(chArr.get(j)); + String chUuid = Wire.str(ch, "uuid", ""); + GattCharacteristic c = new GattCharacteristic(s, + parseUuid(chUuid), + propertiesMask(Wire.list(ch, "properties")), j); + List descArr = Wire.list(ch, "descriptors"); + int ds = descArr.size(); + for (int k = 0; k < ds; k++) { + Map d = Wire.map(descArr.get(k)); + c.addDescriptor(new GattDescriptor(c, + parseUuid(Wire.str(d, "uuid", "")))); + } + s.addCharacteristic(c); + index.put(svcUuid + "|" + chUuid, c); + } + services.add(s); + } + synchronized (dbLock) { + characteristicIndex = index; + } + return services; + } + + private static BluetoothUuid parseUuid(String s) { + return BluetoothUuid.fromString(s); + } + + /// Maps the wire property names onto the GattCharacteristic bits. + public static int propertiesMask(List names) { + int mask = 0; + int size = names.size(); + for (int i = 0; i < size; i++) { + String p = String.valueOf(names.get(i)); + if ("broadcast".equals(p)) { + mask |= GattCharacteristic.PROPERTY_BROADCAST; + } else if ("read".equals(p)) { + mask |= GattCharacteristic.PROPERTY_READ; + } else if ("writeWithoutResponse".equals(p)) { + mask |= GattCharacteristic.PROPERTY_WRITE_WITHOUT_RESPONSE; + } else if ("write".equals(p)) { + mask |= GattCharacteristic.PROPERTY_WRITE; + } else if ("notify".equals(p)) { + mask |= GattCharacteristic.PROPERTY_NOTIFY; + } else if ("indicate".equals(p)) { + mask |= GattCharacteristic.PROPERTY_INDICATE; + } else if ("signedWrite".equals(p)) { + mask |= GattCharacteristic.PROPERTY_SIGNED_WRITE; + } else if ("extendedProps".equals(p)) { + mask |= GattCharacteristic.PROPERTY_EXTENDED_PROPS; + } + } + return mask; + } + + @Override + protected void doReadCharacteristic(GattCharacteristic c, + final AsyncResource out) { + backend.readCharacteristic(address, + c.getService().getUuid().toString(), c.getUuid().toString(), + bytesOp(out)); + } + + @Override + protected void doWriteCharacteristic(GattCharacteristic c, byte[] value, + boolean withResponse, final AsyncResource out) { + backend.writeCharacteristic(address, + c.getService().getUuid().toString(), c.getUuid().toString(), + Wire.encodeBase64(value), !withResponse, + booleanOp(out)); + } + + @Override + protected void doReadDescriptor(GattDescriptor d, + final AsyncResource out) { + GattCharacteristic c = d.getCharacteristic(); + backend.readDescriptor(address, + c.getService().getUuid().toString(), c.getUuid().toString(), + d.getUuid().toString(), bytesOp(out)); + } + + @Override + protected void doWriteDescriptor(GattDescriptor d, byte[] value, + final AsyncResource out) { + GattCharacteristic c = d.getCharacteristic(); + backend.writeDescriptor(address, + c.getService().getUuid().toString(), c.getUuid().toString(), + d.getUuid().toString(), Wire.encodeBase64(value), + booleanOp(out)); + } + + @Override + protected void doSetNotifications(GattCharacteristic c, boolean enable, + boolean indication, final AsyncResource out) { + // btleplug picks notify vs indicate from the characteristic's own + // properties; the indication flag needs no separate wire field + backend.setNotifications(address, + c.getService().getUuid().toString(), c.getUuid().toString(), + enable, booleanOp(out)); + } + + @Override + protected void doReadRssi(final AsyncResource out) { + backend.readRssi(address, new PendingOp() { + @Override + public void onEvent(String event, Map payload) { + int rssi = Wire.intVal(payload, "rssi", -127); + lastRssi.set(rssi); + rssiSeen.set(true); + if (!out.isDone()) { + out.complete(Integer.valueOf(rssi)); + } + } + + @Override + public void onFailure(BluetoothException failure) { + if (out.isDone()) { + return; + } + // the platform can't read live RSSI -- fall back to the + // last scan sighting when one exists + if (failure.getError() == BluetoothError.NOT_SUPPORTED + && rssiSeen.get()) { + out.complete(Integer.valueOf(lastRssi.get())); + } else { + out.error(failure); + } + } + }); + } + + @Override + protected void doRequestMtu(int mtu, AsyncResource out) { + // btleplug negotiates/does not expose MTU control -- resolve with + // the current value, mirroring the iOS behavior of this API + if (!out.isDone()) { + out.complete(Integer.valueOf(getMtu())); + } + } + + @Override + protected void doRequestConnectionPriority(ConnectionPriority priority, + AsyncResource out) { + // interval preferences are platform-managed -- a successful no-op + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + } + + @Override + protected void doCreateBond(AsyncResource out) { + if (backend.helperSupports("bonding")) { + // reserved: no current helper build advertises bonding + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + return; + } + if (!out.isDone()) { + out.error(new BluetoothException(BluetoothError.BOND_FAILED, + "Bonding is not supported by the native JavaSE backend" + + " (btleplug has no bonding API)")); + } + } + + @Override + protected void doOpenL2cap(int psm, boolean secure, + AsyncResource out) { + if (!out.isDone()) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "L2CAP channels are not supported by the native JavaSE" + + " backend (btleplug is central-only GATT)")); + } + } + + private static PendingOp booleanOp(final AsyncResource out) { + return new PendingOp() { + @Override + public void onEvent(String event, Map payload) { + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + } + + @Override + public void onFailure(BluetoothException failure) { + if (!out.isDone()) { + out.error(failure); + } + } + }; + } + + /// Completion that decodes a base64 "value" payload into the raw bytes of + /// a characteristic/descriptor read. Static so it holds no reference to the + /// enclosing peripheral. + private static PendingOp bytesOp(final AsyncResource out) { + return new PendingOp() { + @Override + public void onEvent(String event, Map payload) { + if (!out.isDone()) { + out.complete(Wire.decodeBase64( + Wire.str(payload, "value", ""))); + } + } + + @Override + public void onFailure(BluetoothException failure) { + if (!out.isDone()) { + out.error(failure); + } + } + }; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/HelperBluetooth.java b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBluetooth.java new file mode 100644 index 00000000000..3d751d31296 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBluetooth.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothPermission; +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.util.AsyncResource; + +/// A [Bluetooth] entry point backed by the `cn1-ble-helper` subprocess -- +/// the shared implementation for the native Win32 and Linux desktop ports. +/// Those ports have no `java.lang.ProcessBuilder`, so they pass a +/// [HelperTransportFactory] built on their native process bridge (see +/// [NativeSubprocessTransport]); this class wires it into a +/// [HelperBleBackend] and exposes real BLE central (scan / connect / GATT +/// client / notifications). btleplug -- the helper's backend -- is +/// central-only, so peripheral mode, L2CAP and classic Bluetooth report +/// unsupported. +/// +/// A port creates one instance in its `getBluetooth()` and calls +/// [#shutdown()] on app exit. +public class HelperBluetooth extends Bluetooth { + + private final HelperBleBackend backend; + private final BluetoothLE le; + + public HelperBluetooth(HelperTransportFactory transportFactory) { + this.backend = new HelperBleBackend(transportFactory); + this.le = new HelperBluetoothLE(backend); + this.backend.setAdapterStateSink(new BleBackend.AdapterStateSink() { + @Override + public void adapterStateChanged(AdapterState newState) { + fireAdapterStateChanged(newState); + } + }); + } + + @Override + public boolean isSupported() { + return backend.isLeSupported(); + } + + @Override + public boolean isLeSupported() { + return backend.isLeSupported(); + } + + @Override + public boolean isClassicSupported() { + return backend.isClassicSupported(); + } + + @Override + public boolean isPeripheralModeSupported() { + return backend.isPeripheralModeSupported(); + } + + @Override + public boolean isL2capSupported() { + return backend.isL2capSupported(); + } + + @Override + public AdapterState getAdapterState() { + return backend.getAdapterState(); + } + + @Override + public BluetoothLE getLE() { + return le; + } + + /// Desktop Linux/Windows have no app-level runtime Bluetooth permission + /// (access is governed by the OS/user session), so permissions are + /// reported granted and requests resolve `true`. + @Override + public boolean hasPermission(BluetoothPermission permission) { + return true; + } + + @Override + public AsyncResource requestPermissions( + BluetoothPermission... permissions) { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.TRUE); + return r; + } + + /// There is no programmatic adapter-enable on the desktop helper path; + /// resolves `true` when the adapter is already powered on, else + /// `false`. + @Override + public AsyncResource requestEnable() { + AsyncResource r = new AsyncResource(); + r.complete(getAdapterState() == AdapterState.POWERED_ON + ? Boolean.TRUE : Boolean.FALSE); + return r; + } + + /// Tears down the helper subprocess. Call from the port on app exit. + public void shutdown() { + backend.shutdown(); + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/HelperBluetoothLE.java b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBluetoothLE.java new file mode 100644 index 00000000000..a9926de294f --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/HelperBluetoothLE.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.util.AsyncResource; + +import java.util.List; + +/// The BLE central role for ports backed by the `cn1-ble-helper` +/// subprocess. Delegates every [BluetoothLE] hook to a fixed +/// [BleBackend]; used by [HelperBluetooth] and shared across the native +/// Win32/Linux desktop ports. +class HelperBluetoothLE extends BluetoothLE { + + private final BleBackend backend; + + HelperBluetoothLE(BleBackend backend) { + this.backend = backend; + } + + @Override + protected boolean isScanSupported() { + return backend.isLeSupported(); + } + + @Override + protected void startPlatformScan() { + backend.startScan(new BleBackend.ScanSink() { + @Override + public void onResult(ScanResult result) { + fireScanResult(result); + } + + @Override + public void onFailed(BluetoothException reason) { + fireScanFailed(reason); + } + }); + } + + @Override + protected void stopPlatformScan() { + backend.stopScan(); + } + + @Override + public BlePeripheral getPeripheral(String address) { + return backend.getPeripheral(address); + } + + @Override + public List getConnectedPeripherals( + BluetoothUuid serviceFilter) { + return backend.getConnectedPeripherals(serviceFilter); + } + + @Override + public List getBondedPeripherals() { + return backend.getBondedPeripherals(); + } + + @Override + public AsyncResource openGattServer( + GattServerListener listener) { + return backend.openGattServer(listener); + } + + @Override + public AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse) { + return backend.startAdvertising(settings, data, scanResponse); + } + + @Override + public AsyncResource openL2capServer(boolean secure) { + return backend.openL2capServer(secure); + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/HelperTransport.java b/CodenameOne/src/com/codename1/bluetooth/helper/HelperTransport.java new file mode 100644 index 00000000000..52c42f98711 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/HelperTransport.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import java.io.IOException; +import java.util.List; + +/// The abstraction over the {@code cn1-ble-helper} child process's standard +/// I/O. {@link HelperBleBackend} speaks the line-delimited JSON protocol +/// through this seam only, so a host that has no operating-system process API +/// (the native Windows/Linux ports, which reach the subprocess through a +/// native bridge) can supply its own implementation, while the JavaSE +/// simulator supplies a subprocess-backed one. +/// +///

All methods are called from at most one reader thread and the caller +/// thread; implementations must make {@link #readLine()}/{@link #writeLine} +/// and {@link #close()}/{@link #isAlive()} safe for that pairing.

+public interface HelperTransport { + + /// Launches the helper. {@code command} is the launch command line (the + /// resolved helper binary path, optionally with arguments). A transport + /// that was pre-configured with its own command by its factory may + /// ignore a {@code null} argument and launch that instead. + /// + /// @param command the launch command line, or {@code null} to use the + /// transport's pre-configured command + /// @throws IOException when the child process cannot be started + void start(List command) throws IOException; + + /// Reads one line of the helper's standard output, blocking until a line + /// is available. + /// + /// @return the next line without its terminator, or {@code null} at + /// end of stream (the helper closed stdout / exited) + /// @throws IOException when the pipe fails + String readLine() throws IOException; + + /// Writes one command line to the helper's standard input. The line + /// terminator is appended by the transport and the stream is flushed. + /// + /// @param line the single-line JSON command, without a terminator + /// @throws IOException when the pipe fails + void writeLine(String line) throws IOException; + + /// Releases the transport and terminates the child process. + void close(); + + /// True while the child process is running. + boolean isAlive(); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/HelperTransportFactory.java b/CodenameOne/src/com/codename1/bluetooth/helper/HelperTransportFactory.java new file mode 100644 index 00000000000..a06d423e7cf --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/HelperTransportFactory.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +/// Creates {@link HelperTransport} instances so {@link HelperBleBackend} can +/// (re)start the helper on activation and after a restart. Each call returns +/// a fresh, un-started transport. +public interface HelperTransportFactory { + + /// A new, un-started transport for one helper launch. + HelperTransport create(); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/NativeSubprocessTransport.java b/CodenameOne/src/com/codename1/bluetooth/helper/NativeSubprocessTransport.java new file mode 100644 index 00000000000..41ec61217e4 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/NativeSubprocessTransport.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import java.io.IOException; +import java.util.List; + +/// A [HelperTransport] over a native child process, for the ports that have +/// no `java.lang.ProcessBuilder` (the native Win32 and Linux desktop ports). +/// This class owns the line framing -- assembling `readLine()` from raw byte +/// reads and UTF-8-encoding `writeLine()` with a trailing newline -- over a +/// handful of raw process primitives each port implements with its own +/// `posix_spawn`/`CreateProcess` native bridge. The subclass supplies only +/// those raw calls; all protocol/threading logic lives above this in +/// [HelperBleBackend]. +public abstract class NativeSubprocessTransport implements HelperTransport { + + private final List configuredCommand; + private long handle; + private final byte[] readChunk = new byte[4096]; + private byte[] lineBuffer = new byte[256]; + private int lineLen; + private final byte[] writeNewline = new byte[] {(byte) '\n'}; + + /// @param configuredCommand the helper launch command this transport + /// runs when [#start(List)] is passed `null` + /// (the resolved helper path + args) + protected NativeSubprocessTransport(List configuredCommand) { + this.configuredCommand = configuredCommand; + } + + // ------------------------------------------------------------------ + // raw process primitives -- implemented per port over its Native class + // ------------------------------------------------------------------ + + /// Spawns `argv[0]` with `argv` as arguments and its stdin/stdout wired + /// to pipes; returns an opaque handle, or `0` on failure. + protected abstract long rawSpawn(String[] argv); + + /// Blocking read of up to `len` bytes from the child's stdout into `buf` + /// at `off`; returns bytes read, `0` on EOF, `-1` on error. + protected abstract int rawRead(long handle, byte[] buf, int off, int len); + + /// Writes `len` bytes from `buf` at `off` to the child's stdin; returns + /// bytes written or `-1`. + protected abstract int rawWrite(long handle, byte[] buf, int off, int len); + + /// Closes the child's stdin (EOF) without terminating it. + protected abstract void rawCloseStdin(long handle); + + /// Terminates the child, closes pipes and frees the handle. + protected abstract void rawClose(long handle); + + /// `1` when the child is still running, `0` when it has exited. + protected abstract int rawIsAlive(long handle); + + // ------------------------------------------------------------------ + // HelperTransport + // ------------------------------------------------------------------ + + @Override + public void start(List command) throws IOException { + if (handle != 0) { + throw new IOException("transport already started"); + } + List launch = command != null ? command : configuredCommand; + if (launch == null || launch.isEmpty()) { + throw new IOException("no cn1-ble-helper launch command"); + } + String[] argv = new String[launch.size()]; + for (int i = 0; i < argv.length; i++) { + argv[i] = launch.get(i); + } + long h = rawSpawn(argv); + if (h == 0) { + throw new IOException("failed to spawn helper: " + argv[0]); + } + handle = h; + } + + @Override + public String readLine() throws IOException { + long h = handle; + if (h == 0) { + return null; + } + // drain any complete line already buffered, otherwise read more + while (true) { + int nl = indexOfNewline(); + if (nl >= 0) { + String line = utf8(lineBuffer, 0, nl); + int rest = lineLen - (nl + 1); + if (rest > 0) { + System.arraycopy(lineBuffer, nl + 1, lineBuffer, 0, rest); + } + lineLen = rest; + return line; + } + int n = rawRead(h, readChunk, 0, readChunk.length); + if (n < 0) { + throw new IOException("helper read error"); + } + if (n == 0) { + // EOF: emit any trailing partial line, then signal end + if (lineLen > 0) { + String line = utf8(lineBuffer, 0, lineLen); + lineLen = 0; + return line; + } + return null; + } + appendToLine(readChunk, n); + } + } + + @Override + public void writeLine(String line) throws IOException { + long h = handle; + if (h == 0) { + throw new IOException("transport not started"); + } + byte[] data = utf8Bytes(line); + writeFully(h, data, data.length); + writeFully(h, writeNewline, 1); + } + + @Override + public void close() { + long h = handle; + handle = 0; + if (h != 0) { + rawClose(h); + } + } + + @Override + public boolean isAlive() { + long h = handle; + return h != 0 && rawIsAlive(h) != 0; + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + private void writeFully(long h, byte[] data, int len) throws IOException { + int off = 0; + while (off < len) { + int w = rawWrite(h, data, off, len - off); + if (w < 0) { + throw new IOException("helper write error"); + } + if (w == 0) { + throw new IOException("helper stdin closed"); + } + off += w; + } + } + + private int indexOfNewline() { + for (int i = 0; i < lineLen; i++) { + if (lineBuffer[i] == (byte) '\n') { + return i; + } + } + return -1; + } + + private void appendToLine(byte[] src, int n) { + if (lineLen + n > lineBuffer.length) { + int cap = lineBuffer.length * 2; + while (cap < lineLen + n) { + cap *= 2; + } + byte[] grown = new byte[cap]; + System.arraycopy(lineBuffer, 0, grown, 0, lineLen); + lineBuffer = grown; + } + System.arraycopy(src, 0, lineBuffer, lineLen, n); + lineLen += n; + } + + private static String utf8(byte[] b, int off, int len) { + // trim a trailing CR so CRLF frames decode cleanly + int end = len; + if (end > 0 && b[off + end - 1] == (byte) '\r') { + end--; + } + try { + return new String(b, off, end, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 is unavailable", e); + } + } + + private static byte[] utf8Bytes(String s) { + try { + return s.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 is unavailable", e); + } + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/NativeSubprocessTransportFactory.java b/CodenameOne/src/com/codename1/bluetooth/helper/NativeSubprocessTransportFactory.java new file mode 100644 index 00000000000..7d0c326b0d3 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/NativeSubprocessTransportFactory.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import java.util.ArrayList; +import java.util.List; + +/// Builds [NativeSubprocessTransport]s for a native port. Resolving the +/// helper binary on the native ports is deliberately simpler than the +/// JavaSE classpath-extraction path: there is no runtime jar to extract +/// from, so the helper is located by +/// +/// 1. the `cn1.bluetooth.helperPath` system property (an explicit path), or +/// 2. the bare executable name, spawned through the OS `PATH` search +/// (`posix_spawnp` on Linux, `CreateProcess` `PATH` lookup on Windows), +/// so a helper bundled next to the app or installed on the `PATH` is +/// found without extra plumbing. +/// +/// The subclass supplies the actual transport (which carries the port's +/// native `proc*` bridge) via [#createTransport(List)]. +public abstract class NativeSubprocessTransportFactory + implements HelperTransportFactory { + + /// The system property that overrides the helper location. Kept in sync + /// with the JavaSE resolver's property name. + public static final String HELPER_PATH_PROPERTY = "cn1.bluetooth.helperPath"; + + private static final String HELPER_BASENAME = "cn1-ble-helper"; + + @Override + public HelperTransport create() { + return createTransport(resolveCommand()); + } + + /// Creates the port-specific transport bound to the given launch + /// command (helper path + args). Implemented per port over its native + /// `proc*` bridge. + protected abstract NativeSubprocessTransport createTransport( + List command); + + /// The executable name on this OS -- Windows appends `.exe`. + protected abstract String executableName(String basename); + + private List resolveCommand() { + List command = new ArrayList(); + String override = getProperty(HELPER_PATH_PROPERTY); + if (override != null && override.length() > 0) { + command.add(override); + } else { + command.add(executableName(HELPER_BASENAME)); + } + return command; + } + + private static String getProperty(String key) { + try { + return System.getProperty(key); + } catch (RuntimeException ex) { + return null; + } + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/Wire.java b/CodenameOne/src/com/codename1/bluetooth/helper/Wire.java new file mode 100644 index 00000000000..4511b3c48c2 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/Wire.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.io.JSONParser; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The line-delimited JSON codec of the {@code cn1-ble-helper} protocol: +/// command serialization (with escaping) and event-line parsing via +/// {@link JSONParser}, with Base64-encoded characteristic/descriptor +/// payloads. +public final class Wire { + + private Wire() { + } + + /// Builder for one command line. + public static final class Obj { + private final StringBuilder sb = new StringBuilder("{"); //NOPMD AvoidStringBufferField + private boolean first = true; + + private void key(String k) { + if (!first) { + sb.append(','); + } + first = false; + sb.append('"').append(escape(k)).append("\":"); + } + + public Obj put(String k, String v) { + key(k); + sb.append('"').append(escape(v)).append('"'); + return this; + } + + public Obj put(String k, long v) { + key(k); + sb.append(v); + return this; + } + + public Obj put(String k, boolean v) { + key(k); + sb.append(v); + return this; + } + + /// The finished single-line JSON object. + public String line() { + return sb.toString() + "}"; + } + } + + public static Obj obj() { + return new Obj(); + } + + public static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder sb = new StringBuilder(s.length()); + int len = s.length(); + for (int i = 0; i < len; 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(); + } + + /// Parses one event line into a map. + public static Map parse(String line) throws IOException { + JSONParser parser = new JSONParser(); + parser.setUseLongsInstance(true); + parser.setUseBooleanInstance(true); + return parser.parseJSON(new StringReader(line)); + } + + public static String str(Map m, String key, String def) { + Object v = m.get(key); + return v == null ? def : v.toString(); + } + + public static long longVal(Map m, String key, long def) { + Object v = m.get(key); + if (v instanceof Number) { + return ((Number) v).longValue(); + } + if (v instanceof String) { + try { + return Long.parseLong((String) v); + } catch (NumberFormatException ignored) { + } + } + return def; + } + + public static int intVal(Map m, String key, int def) { + return (int) longVal(m, key, def); + } + + public static boolean boolVal(Map m, String key, + boolean def) { + Object v = m.get(key); + if (v instanceof Boolean) { + return ((Boolean) v).booleanValue(); + } + if (v instanceof String) { + return "true".equals(v); + } + return def; + } + + @SuppressWarnings("unchecked") + public static List list(Map m, String key) { + Object v = m.get(key); + return v instanceof List ? (List) v + : new ArrayList(); + } + + @SuppressWarnings("unchecked") + public static Map map(Object v) { + return v instanceof Map ? (Map) v + : new HashMap(); + } + + // Standard Base64 (RFC 4648) is hand-rolled here rather than using + // java.util.Base64: this class is translated by ParparVM for the native + // ports and java.util.Base64 is not part of the device API surface. The + // implementation is pure array arithmetic, so it runs identically on the + // JVM (simulator/tests) and on-device. + private static final char[] B64 = + ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") + .toCharArray(); + + public static String encodeBase64(byte[] data) { + if (data == null || data.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder((data.length + 2) / 3 * 4); + int i = 0; + while (i + 3 <= data.length) { + int n = ((data[i] & 0xFF) << 16) | ((data[i + 1] & 0xFF) << 8) + | (data[i + 2] & 0xFF); + sb.append(B64[(n >> 18) & 0x3F]).append(B64[(n >> 12) & 0x3F]) + .append(B64[(n >> 6) & 0x3F]).append(B64[n & 0x3F]); + i += 3; + } + int rem = data.length - i; + if (rem == 1) { + int n = (data[i] & 0xFF) << 16; + sb.append(B64[(n >> 18) & 0x3F]).append(B64[(n >> 12) & 0x3F]) + .append("=="); + } else if (rem == 2) { + int n = ((data[i] & 0xFF) << 16) | ((data[i + 1] & 0xFF) << 8); + sb.append(B64[(n >> 18) & 0x3F]).append(B64[(n >> 12) & 0x3F]) + .append(B64[(n >> 6) & 0x3F]).append('='); + } + return sb.toString(); + } + + public static byte[] decodeBase64(String s) { + if (s == null || s.length() == 0) { + return new byte[0]; + } + // count real (non-padding) symbols + int symbols = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '=') { + break; + } + if (b64Value(c) >= 0) { + symbols++; + } + } + byte[] out = new byte[symbols * 6 / 8]; + int acc = 0; + int bits = 0; + int o = 0; + for (int i = 0; i < s.length(); i++) { + int v = b64Value(s.charAt(i)); + if (v < 0) { + continue; + } + acc = (acc << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out[o++] = (byte) ((acc >> bits) & 0xFF); + if (o >= out.length) { + break; + } + } + } + return out; + } + + private static int b64Value(char c) { + if (c >= 'A' && c <= 'Z') { + return c - 'A'; + } + if (c >= 'a' && c <= 'z') { + return c - 'a' + 26; + } + if (c >= '0' && c <= '9') { + return c - '0' + 52; + } + if (c == '+') { + return 62; + } + if (c == '/') { + return 63; + } + return -1; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/helper/package-info.java b/CodenameOne/src/com/codename1/bluetooth/helper/package-info.java new file mode 100644 index 00000000000..8ad20f70afd --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/helper/package-info.java @@ -0,0 +1,34 @@ +/* + * 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. + */ +/// Shared implementation support for the ports that talk to the native +/// `cn1-ble-helper` subprocess (JavaSE real-hardware, Win32 and Linux). +/// Not part of the public Bluetooth API -- application code uses +/// `com.codename1.bluetooth` and its sub packages instead. +/// +/// `HelperBleBackend` implements the BLE central protocol over a +/// line-delimited JSON transport, `HelperBluetooth`/`HelperBluetoothLE` +/// bridge it onto the core facades, and `Wire` is the codec. `HelperTransport` +/// abstracts the subprocess pipe so each port supplies its own launcher -- +/// `ProcessBuilder` on JavaSE, a native `posix_spawn`/`CreateProcess` bridge +/// on the C-translated Win32/Linux ports (`NativeSubprocessTransport`). +package com.codename1.bluetooth.helper; diff --git a/CodenameOne/src/com/codename1/bluetooth/le/AdvertisementData.java b/CodenameOne/src/com/codename1/bluetooth/le/AdvertisementData.java new file mode 100644 index 00000000000..0390f020356 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/AdvertisementData.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.bluetooth.BluetoothUuid; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/// The parsed payload of a BLE advertisement: local name, advertised +/// service UUIDs, manufacturer data, service data and TX power. Obtained +/// from [ScanResult#getAdvertisementData()]. +/// +/// Ports that receive raw advertisement bytes (Android, the simulator) +/// build instances via [#parse(byte[])]; ports that only get pre-parsed +/// dictionaries (iOS) populate the fields directly through the `set*` / +/// `add*` methods, which are not application API. +public class AdvertisementData { + + private static final int TYPE_FLAGS = 0x01; + private static final int TYPE_UUID16_INCOMPLETE = 0x02; + private static final int TYPE_UUID16_COMPLETE = 0x03; + private static final int TYPE_UUID32_INCOMPLETE = 0x04; + private static final int TYPE_UUID32_COMPLETE = 0x05; + private static final int TYPE_UUID128_INCOMPLETE = 0x06; + private static final int TYPE_UUID128_COMPLETE = 0x07; + private static final int TYPE_NAME_SHORT = 0x08; + private static final int TYPE_NAME_COMPLETE = 0x09; + private static final int TYPE_TX_POWER = 0x0A; + private static final int TYPE_SERVICE_DATA_16 = 0x16; + private static final int TYPE_SERVICE_DATA_32 = 0x20; + private static final int TYPE_SERVICE_DATA_128 = 0x21; + private static final int TYPE_MANUFACTURER = 0xFF; + + private String localName; + private final ArrayList serviceUuids = + new ArrayList(); + private final HashMap manufacturerData = + new HashMap(); + private final HashMap serviceData = + new HashMap(); + private Integer txPowerLevel; + private byte[] rawBytes; + + /// Parses raw advertisement bytes -- a sequence of + /// `length, type, payload` AD structures -- into a populated instance. + /// Unknown AD types are skipped; a malformed trailing structure ends + /// parsing without failing. + public static AdvertisementData parse(byte[] raw) { + AdvertisementData ad = new AdvertisementData(); + ad.rawBytes = raw; + if (raw == null) { + return ad; + } + int i = 0; + while (i < raw.length) { + int len = raw[i] & 0xFF; + // a structure spans len + 1 bytes: the length byte itself, + // the type byte and len - 1 payload bytes + if (len == 0 || i + 1 + len > raw.length) { + break; + } + int type = raw[i + 1] & 0xFF; + ad.parseStructure(type, raw, i + 2, len - 1); + i += len + 1; + } + return ad; + } + + private void parseStructure(int type, byte[] raw, int off, int len) { + switch (type) { + case TYPE_NAME_SHORT: + case TYPE_NAME_COMPLETE: + localName = utf8(raw, off, len); + break; + case TYPE_UUID16_INCOMPLETE: + case TYPE_UUID16_COMPLETE: + for (int i = 0; i + 1 < len; i += 2) { + addServiceUuid(BluetoothUuid.fromShort( + readLeInt(raw, off + i, 2))); + } + break; + case TYPE_UUID32_INCOMPLETE: + case TYPE_UUID32_COMPLETE: + for (int i = 0; i + 3 < len; i += 4) { + addServiceUuid(BluetoothUuid.fromShort( + readLeInt(raw, off + i, 4))); + } + break; + case TYPE_UUID128_INCOMPLETE: + case TYPE_UUID128_COMPLETE: + for (int i = 0; i + 15 < len; i += 16) { + addServiceUuid(readLeUuid(raw, off + i)); + } + break; + case TYPE_TX_POWER: + if (len >= 1) { + txPowerLevel = Integer.valueOf(raw[off]); + } + break; + case TYPE_MANUFACTURER: + if (len >= 2) { + int companyId = readLeInt(raw, off, 2); + byte[] payload = new byte[len - 2]; + System.arraycopy(raw, off + 2, payload, 0, len - 2); + addManufacturerData(companyId, payload); + } + break; + case TYPE_SERVICE_DATA_16: + if (len >= 2) { + addServiceData( + BluetoothUuid.fromShort(readLeInt(raw, off, 2)), + copy(raw, off + 2, len - 2)); + } + break; + case TYPE_SERVICE_DATA_32: + if (len >= 4) { + addServiceData( + BluetoothUuid.fromShort(readLeInt(raw, off, 4)), + copy(raw, off + 4, len - 4)); + } + break; + case TYPE_SERVICE_DATA_128: + if (len >= 16) { + addServiceData(readLeUuid(raw, off), + copy(raw, off + 16, len - 16)); + } + break; + case TYPE_FLAGS: + default: + break; + } + } + + private static String utf8(byte[] raw, int off, int len) { + try { + return new String(raw, off, len, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + // every platform we run on ships UTF-8; decoding the name with + // whatever the default encoding happens to be would silently + // corrupt it, so treat this as impossible rather than guess + throw new IllegalStateException("UTF-8 is unavailable", e); + } + } + + private static byte[] copy(byte[] raw, int off, int len) { + byte[] out = new byte[len]; + System.arraycopy(raw, off, out, 0, len); + return out; + } + + private static int readLeInt(byte[] raw, int off, int bytes) { + int v = 0; + for (int i = bytes - 1; i >= 0; i--) { + v = (v << 8) | (raw[off + i] & 0xFF); + } + return v; + } + + /// 128-bit UUIDs travel little-endian in advertisement structures. + private static BluetoothUuid readLeUuid(byte[] raw, int off) { + long lsb = 0; + for (int i = 7; i >= 0; i--) { + lsb = (lsb << 8) | (raw[off + i] & 0xFF); + } + long msb = 0; + for (int i = 15; i >= 8; i--) { + msb = (msb << 8) | (raw[off + i] & 0xFF); + } + return new BluetoothUuid(msb, lsb); + } + + /// The advertised local name, or `null` when the advertisement carries + /// none. + public String getLocalName() { + return localName; + } + + /// The advertised service UUIDs; empty when none were advertised. + public List getServiceUuids() { + return new ArrayList(serviceUuids); + } + + /// The manufacturer-specific payload advertised for the given company + /// identifier, or `null`. + public byte[] getManufacturerData(int companyId) { + return manufacturerData.get(Integer.valueOf(companyId)); + } + + /// The company identifiers for which manufacturer data is present. + public int[] getManufacturerIds() { + int[] out = new int[manufacturerData.size()]; + int i = 0; + for (Integer id : manufacturerData.keySet()) { + out[i++] = id.intValue(); + } + return out; + } + + /// The service-data payload advertised for the given service UUID, or + /// `null`. + public byte[] getServiceData(BluetoothUuid serviceUuid) { + return serviceData.get(serviceUuid); + } + + /// The service UUIDs for which service data is present. + public List getServiceDataUuids() { + return new ArrayList(serviceData.keySet()); + } + + /// The advertised TX power level in dBm, or `null` when absent. + public Integer getTxPowerLevel() { + return txPowerLevel; + } + + /// The raw advertisement bytes when the platform exposes them + /// (Android, simulator); `null` on iOS, which only provides parsed + /// fields. + public byte[] getRawBytes() { + return rawBytes; + } + + /// Populated by ports; not application API. + public void setLocalName(String localName) { + this.localName = localName; + } + + /// Populated by ports; not application API. + public void addServiceUuid(BluetoothUuid uuid) { + if (!serviceUuids.contains(uuid)) { + serviceUuids.add(uuid); + } + } + + /// Populated by ports; not application API. + public void addManufacturerData(int companyId, byte[] data) { + manufacturerData.put(Integer.valueOf(companyId), data); + } + + /// Populated by ports; not application API. + public void addServiceData(BluetoothUuid uuid, byte[] data) { + serviceData.put(uuid, data); + } + + /// Populated by ports; not application API. + public void setTxPowerLevel(Integer txPowerLevel) { + this.txPowerLevel = txPowerLevel; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/BlePeripheral.java b/CodenameOne/src/com/codename1/bluetooth/le/BlePeripheral.java new file mode 100644 index 00000000000..6cff0fa6af8 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/BlePeripheral.java @@ -0,0 +1,784 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.bluetooth.BluetoothDevice; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattNotificationListener; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import com.codename1.util.AsyncResult; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.TimerTask; + +/// A remote BLE peripheral: connection lifecycle, GATT client operations +/// and L2CAP channels. Obtained from scans +/// ([ScanResult#getPeripheral()]) or from +/// [BluetoothLE#getPeripheral(String)]. +/// +/// All operations return independent `AsyncResource` handles and may be +/// issued concurrently -- an internal per-peripheral queue serializes them +/// toward the platform stack (which only allows one in-flight GATT request +/// per connection) and applies a safety timeout so a lost platform +/// callback can never wedge the queue. Operations on *different* +/// peripherals run fully concurrently. +/// +/// Ports subclass this and implement the protected `do*` methods; they +/// report events through the `fire*` methods, which are safe to call from +/// any thread. All application-facing callbacks are delivered on the EDT. +public abstract class BlePeripheral extends BluetoothDevice { + + private final GattOperationQueue queue = new GattOperationQueue(); + private final Object stateLock = new Object(); + private ConnectionState state = ConnectionState.DISCONNECTED; + private AsyncResource pendingConnect; + private TimerTask connectTimeout; + private java.util.Timer connectTimeoutTimer; + private ArrayList connectionListeners; + private ArrayList services; + private final HashMap> + subscriptions = + new HashMap>(); + private final HashSet armed = + new HashSet(); + private final HashMap> armOps = + new HashMap>(); + private int mtu = 23; + + /// Ports construct subclasses; application code receives instances + /// from scans and [BluetoothLE]. + protected BlePeripheral() { + } + + // ------------------------------------------------------------------ + // connection lifecycle + // ------------------------------------------------------------------ + + /// Connects with default [ConnectionOptions]. + public final AsyncResource connect() { + return connect(new ConnectionOptions()); + } + + /// Establishes a connection. Resolves with this peripheral once + /// connected or fails with a [BluetoothException]. Calling `connect` + /// while already [ConnectionState#CONNECTING] returns the existing + /// attempt's handle; while [ConnectionState#CONNECTED] it resolves + /// immediately. + public final AsyncResource connect(ConnectionOptions options) { + final ConnectionOptions opts = + options == null ? new ConnectionOptions() : options; + final AsyncResource out; + synchronized (stateLock) { + if (state == ConnectionState.CONNECTED) { + AsyncResource done = + new AsyncResource(); + done.complete(this); + return done; + } + if (state == ConnectionState.CONNECTING && pendingConnect != null) { + return pendingConnect; + } + out = new AsyncResource(); + pendingConnect = out; + } + out.onResult(new AsyncResult() { + @Override + public void onReady(BlePeripheral value, Throwable err) { + connectFinished(out, err); + } + }); + setState(ConnectionState.CONNECTING, null); + if (opts.getTimeout() > 0) { + TimerTask t = connectTimeoutTask(out, opts.getTimeout()); + synchronized (stateLock) { + connectTimeout = t; + connectTimeoutTimer = + GattOperationQueue.schedule(t, opts.getTimeout()); + } + } + try { + doConnect(opts, out); + } catch (RuntimeException ex) { + if (!out.isDone()) { + out.error(new BluetoothException(BluetoothError.CONNECTION_FAILED, + "Connect failed to start: " + ex, ex)); + } + } + return out; + } + + // Static so the TimerTask doesn't carry a synthetic outer-BlePeripheral + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static TimerTask connectTimeoutTask( + final AsyncResource out, final int timeout) { + return new TimerTask() { + @Override + public void run() { + if (!out.isDone()) { + out.error(new BluetoothException(BluetoothError.TIMEOUT, + "Connect attempt timed out after " + timeout + + "ms")); + } + } + }; + } + + private void connectFinished(AsyncResource out, + Throwable err) { + synchronized (stateLock) { + if (pendingConnect == out) { //NOPMD CompareObjectsWithEquals + pendingConnect = null; + } + if (connectTimeout != null) { + connectTimeout.cancel(); + connectTimeout = null; + } + if (connectTimeoutTimer != null) { + connectTimeoutTimer.cancel(); + connectTimeoutTimer = null; + } + } + if (err == null) { + setState(ConnectionState.CONNECTED, null); + } else { + // abort a possibly still ongoing platform attempt (timeout or + // cancellation path) + try { + doDisconnect(); + } catch (RuntimeException ignored) { + } + setState(ConnectionState.DISCONNECTED, asBluetoothException(err, + BluetoothError.CONNECTION_FAILED)); + } + } + + /// Disconnects. A no-op while already disconnected; an in-flight + /// connect attempt is aborted with + /// [BluetoothError#USER_CANCELED]. + public final void disconnect() { + AsyncResource pending; + synchronized (stateLock) { + if (state == ConnectionState.DISCONNECTED + || state == ConnectionState.DISCONNECTING) { + return; + } + pending = pendingConnect; + } + if (pending != null && !pending.isDone()) { + pending.error(new BluetoothException(BluetoothError.USER_CANCELED, + "disconnect() called during connect")); + return; + } + setState(ConnectionState.DISCONNECTING, null); + try { + doDisconnect(); + } catch (RuntimeException ignored) { + } + } + + /// The current connection state. + public final ConnectionState getConnectionState() { + synchronized (stateLock) { + return state; + } + } + + /// Registers a listener notified on the EDT of every connection state + /// transition. + public final void addConnectionListener(ConnectionListener l) { + if (l == null) { + return; + } + synchronized (stateLock) { + if (connectionListeners == null) { + connectionListeners = new ArrayList(); + } + if (!connectionListeners.contains(l)) { + connectionListeners.add(l); + } + } + } + + /// Removes a listener added via + /// [#addConnectionListener(ConnectionListener)]. + public final void removeConnectionListener(ConnectionListener l) { + synchronized (stateLock) { + if (connectionListeners != null) { + connectionListeners.remove(l); + } + } + } + + // ------------------------------------------------------------------ + // GATT client + // ------------------------------------------------------------------ + + /// Discovers the peripheral's services. Resolves with the service list + /// (also cached -- see [#getServices()]) or fails with a + /// [BluetoothException]. + public final AsyncResource> discoverServices() { + final AsyncResource> out = + new AsyncResource>(); + if (failIfNotConnected(out)) { + return out; + } + out.onResult(new AsyncResult>() { + @Override + public void onReady(List value, Throwable err) { + if (err == null && value != null) { + synchronized (stateLock) { + services = new ArrayList(value); + } + } + } + }); + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doDiscoverServices(out); + } + }); + return out; + } + + /// The cached service list from the last [#discoverServices()] call; + /// empty before discovery. + public final List getServices() { + synchronized (stateLock) { + return services == null + ? new ArrayList() + : new ArrayList(services); + } + } + + /// The first cached service with the given UUID, or `null`. + public final GattService getService(BluetoothUuid uuid) { + List list = getServices(); + int size = list.size(); + for (int i = 0; i < size; i++) { + GattService s = list.get(i); + if (s.getUuid().equals(uuid)) { + return s; + } + } + return null; + } + + /// Convenience for `getService(service).getCharacteristic(characteristic)` + /// that returns `null` instead of throwing when either is absent. + public final GattCharacteristic getCharacteristic(BluetoothUuid service, + BluetoothUuid characteristic) { + GattService s = getService(service); + return s == null ? null : s.getCharacteristic(characteristic); + } + + /// Reads a characteristic value; prefer the convenience + /// [GattCharacteristic#read()]. + public final AsyncResource readCharacteristic( + final GattCharacteristic c) { + final AsyncResource out = new AsyncResource(); + if (failIfNotConnected(out)) { + return out; + } + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doReadCharacteristic(c, out); + } + }); + return out; + } + + /// Writes a characteristic value; prefer the convenience + /// [GattCharacteristic#write(byte[])] / + /// [GattCharacteristic#writeWithoutResponse(byte[])]. + public final AsyncResource writeCharacteristic( + final GattCharacteristic c, final byte[] value, + final boolean withResponse) { + final AsyncResource out = new AsyncResource(); + if (failIfNotConnected(out)) { + return out; + } + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doWriteCharacteristic(c, value, withResponse, out); + } + }); + return out; + } + + /// Reads a descriptor value; prefer the convenience + /// [GattDescriptor#read()]. + public final AsyncResource readDescriptor(final GattDescriptor d) { + final AsyncResource out = new AsyncResource(); + if (failIfNotConnected(out)) { + return out; + } + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doReadDescriptor(d, out); + } + }); + return out; + } + + /// Writes a descriptor value; prefer the convenience + /// [GattDescriptor#write(byte[])]. + public final AsyncResource writeDescriptor(final GattDescriptor d, + final byte[] value) { + final AsyncResource out = new AsyncResource(); + if (failIfNotConnected(out)) { + return out; + } + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doWriteDescriptor(d, value, out); + } + }); + return out; + } + + /// Subscribes a listener to a characteristic's notifications; prefer + /// the convenience + /// [GattCharacteristic#subscribe(GattNotificationListener)]. The CCCD + /// is written only on the transition from zero to one listener. + public final AsyncResource subscribe(final GattCharacteristic c, + GattNotificationListener l) { + final AsyncResource out = new AsyncResource(); + if (l == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "subscribe requires a listener")); + return out; + } + if (failIfNotConnected(out)) { + return out; + } + AsyncResource arm = null; + boolean startArm = false; + synchronized (subscriptions) { + ArrayList list = subscriptions.get(c); + if (list == null) { + list = new ArrayList(); + subscriptions.put(c, list); + } + if (!list.contains(l)) { + list.add(l); + } + if (armed.contains(c)) { + out.complete(Boolean.TRUE); + return out; + } + arm = armOps.get(c); + if (arm == null) { + arm = new AsyncResource(); + armOps.put(c, arm); + startArm = true; + } + } + arm.addListener(out); + if (startArm) { + final AsyncResource armRes = arm; + arm.onResult(new AsyncResult() { + @Override + public void onReady(Boolean value, Throwable err) { + synchronized (subscriptions) { + armOps.remove(c); + if (err == null) { + armed.add(c); + } + } + checkDisarm(c); + } + }); + final boolean indication = !c.canNotify() && c.canIndicate(); + queue.enqueue(new GattOperationQueue.Op(armRes) { + @Override + void start() { + doSetNotifications(c, true, indication, armRes); + } + }); + } + return out; + } + + /// Removes a notification listener; prefer the convenience + /// [GattCharacteristic#unsubscribe(GattNotificationListener)]. The + /// CCCD is disarmed when the last listener is removed. + public final AsyncResource unsubscribe(GattCharacteristic c, + GattNotificationListener l) { + synchronized (subscriptions) { + ArrayList list = subscriptions.get(c); + if (list != null) { + list.remove(l); + if (list.isEmpty()) { + subscriptions.remove(c); + } + } + } + return checkDisarm(c); + } + + /// `true` while notifications are armed for the characteristic. + public final boolean isSubscribed(GattCharacteristic c) { + synchronized (subscriptions) { + return armed.contains(c); + } + } + + private AsyncResource checkDisarm(final GattCharacteristic c) { + final AsyncResource out = new AsyncResource(); + boolean needDisarm; + synchronized (subscriptions) { + boolean hasListeners = subscriptions.containsKey(c); + boolean armInFlight = armOps.containsKey(c); + needDisarm = !hasListeners && !armInFlight && armed.contains(c); + if (needDisarm) { + armed.remove(c); + } + } + if (!needDisarm) { + out.complete(Boolean.TRUE); + return out; + } + if (getConnectionState() != ConnectionState.CONNECTED) { + out.complete(Boolean.TRUE); + return out; + } + final boolean indication = !c.canNotify() && c.canIndicate(); + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doSetNotifications(c, false, indication, out); + } + }); + return out; + } + + /// Reads the current RSSI of the connection. + public final AsyncResource readRssi() { + final AsyncResource out = new AsyncResource(); + if (failIfNotConnected(out)) { + return out; + } + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doReadRssi(out); + } + }); + return out; + } + + /// Requests an MTU; resolves with the granted value (which may be + /// smaller). iOS negotiates the MTU itself -- there the request + /// resolves immediately with the current value. + public final AsyncResource requestMtu(final int mtu) { + final AsyncResource out = new AsyncResource(); + if (failIfNotConnected(out)) { + return out; + } + out.onResult(new AsyncResult() { + @Override + public void onReady(Integer value, Throwable err) { + if (err == null && value != null) { + BlePeripheral.this.mtu = value.intValue(); + } + } + }); + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doRequestMtu(mtu, out); + } + }); + return out; + } + + /// The current MTU of the connection; `23` (the BLE default) until a + /// larger value was negotiated. + public final int getMtu() { + return mtu; + } + + /// Requests a connection-interval preference; a successful no-op on + /// platforms that manage intervals themselves (iOS). + public final AsyncResource requestConnectionPriority( + final ConnectionPriority priority) { + final AsyncResource out = new AsyncResource(); + if (failIfNotConnected(out)) { + return out; + } + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doRequestConnectionPriority(priority, out); + } + }); + return out; + } + + /// Initiates bonding/pairing. On iOS bonding is OS-managed (triggered + /// by encrypted characteristics), so the request resolves `true` + /// without user interaction. + public final AsyncResource createBond() { + final AsyncResource out = new AsyncResource(); + queue.enqueue(new GattOperationQueue.Op(out) { + @Override + void start() { + doCreateBond(out); + } + }); + return out; + } + + /// Opens an L2CAP connection-oriented channel to the given PSM. Not + /// serialized with GATT operations. On Android this establishes its + /// own link and does not require [#connect()] first; on iOS the + /// peripheral must be connected. + public final AsyncResource openL2capChannel(int psm, + boolean secure) { + final AsyncResource out = + new AsyncResource(); + try { + doOpenL2cap(psm, secure, out); + } catch (RuntimeException ex) { + if (!out.isDone()) { + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "L2CAP open failed: " + ex, ex)); + } + } + return out; + } + + // ------------------------------------------------------------------ + // port SPI -- one queued operation is in flight at a time + // ------------------------------------------------------------------ + + /// Establishes the platform connection and completes/fails `out`. + protected abstract void doConnect(ConnectionOptions options, + AsyncResource out); + + /// Tears down the platform connection; + /// [#fireConnectionStateChanged(ConnectionState, BluetoothException)] + /// reports the resulting `DISCONNECTED` transition. + protected abstract void doDisconnect(); + + /// Performs service discovery and completes `out` with the discovered + /// [GattService] list (constructed via the public gatt-package + /// constructors). + protected abstract void doDiscoverServices( + AsyncResource> out); + + /// Reads a characteristic and completes `out` with its value. + protected abstract void doReadCharacteristic(GattCharacteristic c, + AsyncResource out); + + /// Writes a characteristic and completes `out` once acknowledged + /// (`withResponse`) or queued to the controller. + protected abstract void doWriteCharacteristic(GattCharacteristic c, + byte[] value, boolean withResponse, AsyncResource out); + + /// Reads a descriptor and completes `out` with its value. + protected abstract void doReadDescriptor(GattDescriptor d, + AsyncResource out); + + /// Writes a descriptor and completes `out` once acknowledged. + protected abstract void doWriteDescriptor(GattDescriptor d, byte[] value, + AsyncResource out); + + /// Arms or disarms notifications/indications (including the CCCD + /// write) and completes `out` once done. + protected abstract void doSetNotifications(GattCharacteristic c, + boolean enable, boolean indication, AsyncResource out); + + /// Reads the connection RSSI and completes `out`. + protected abstract void doReadRssi(AsyncResource out); + + /// Requests an MTU and completes `out` with the granted value. + protected abstract void doRequestMtu(int mtu, AsyncResource out); + + /// Requests a connection-interval preference and completes `out`. + protected abstract void doRequestConnectionPriority( + ConnectionPriority priority, AsyncResource out); + + /// Initiates bonding and completes `out` when the bond state settles. + protected abstract void doCreateBond(AsyncResource out); + + /// Opens an L2CAP channel and completes `out` with it. Called + /// directly (not through the operation queue). + protected abstract void doOpenL2cap(int psm, boolean secure, + AsyncResource out); + + // ------------------------------------------------------------------ + // port event entry points -- safe to call from any thread + // ------------------------------------------------------------------ + + /// Reports an (unsolicited) connection state transition -- link loss, + /// remote disconnect, or connection established outside a pending + /// connect call. `reason` is `null` for app-requested transitions. + protected final void fireConnectionStateChanged(ConnectionState newState, + BluetoothException reason) { + setState(newState, reason); + } + + /// Delivers a notification/indication value to the subscribed + /// listeners on the EDT. Ports must pass the canonical + /// [GattCharacteristic] instance from the discovered database. + protected final void fireNotification(final GattCharacteristic c, + final byte[] value) { + final Object[] snapshot; + synchronized (subscriptions) { + ArrayList list = subscriptions.get(c); + if (list == null || list.isEmpty()) { + return; + } + snapshot = list.toArray(); + } + dispatchNotification(snapshot, c, value); + } + + // The dispatch helpers are static so their Runnables don't carry a + // synthetic outer-BlePeripheral reference (SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON). + private static void dispatchNotification(final Object[] snapshot, + final GattCharacteristic c, final byte[] value) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + for (Object listener : snapshot) { + ((GattNotificationListener) listener) + .valueChanged(c, value); + } + } + }); + } + + /// Invalidates the cached service database (iOS + /// `didModifyServices`); the app should re-run + /// [#discoverServices()]. + protected final void fireServicesInvalidated() { + synchronized (stateLock) { + services = null; + } + } + + /// Adjusts the safety timeout applied to each queued GATT operation + /// (default 30000ms; `0` disables). + protected final void setOperationTimeout(int millis) { + queue.setTimeoutMillis(millis); + } + + /// Records the negotiated MTU -- for ports whose platform reports MTU + /// changes outside a [#requestMtu(int)] call. + protected final void setMtu(int mtu) { + this.mtu = mtu; + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + private void setState(ConnectionState newState, BluetoothException reason) { + AsyncResource pending = null; + final Object[] snapshot; + synchronized (stateLock) { + if (state == newState) { + return; + } + state = newState; + if (newState == ConnectionState.CONNECTED + || newState == ConnectionState.DISCONNECTED) { + pending = pendingConnect; + } + snapshot = connectionListeners == null + || connectionListeners.isEmpty() + ? null : connectionListeners.toArray(); + } + if (newState == ConnectionState.DISCONNECTED) { + BluetoothException failReason = reason != null ? reason + : new BluetoothException(BluetoothError.NOT_CONNECTED, + "Peripheral disconnected"); + queue.failAll(failReason); + synchronized (subscriptions) { + armed.clear(); + } + if (pending != null && !pending.isDone()) { + pending.error(reason != null ? reason + : new BluetoothException( + BluetoothError.CONNECTION_FAILED, + "Disconnected while connecting")); + } + } else if (newState == ConnectionState.CONNECTED) { + if (pending != null && !pending.isDone()) { + pending.complete(this); + } + } + if (snapshot != null) { + dispatchConnectionEvent(snapshot, + new ConnectionEvent(this, newState, reason)); + } + } + + private static void dispatchConnectionEvent(final Object[] snapshot, + final ConnectionEvent ev) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + for (Object listener : snapshot) { + ((ConnectionListener) listener) + .connectionStateChanged(ev); + } + } + }); + } + + private boolean failIfNotConnected(AsyncResource out) { + if (getConnectionState() != ConnectionState.CONNECTED) { + out.error(new BluetoothException(BluetoothError.NOT_CONNECTED, + "Peripheral is not connected")); + return true; + } + return false; + } + + private static BluetoothException asBluetoothException(Throwable t, + BluetoothError fallback) { + if (t instanceof BluetoothException) { + return (BluetoothException) t; + } + return new BluetoothException(fallback, + t == null ? null : t.toString(), t); + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/BleScan.java b/CodenameOne/src/com/codename1/bluetooth/le/BleScan.java new file mode 100644 index 00000000000..01a718035ea --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/BleScan.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.util.AsyncResource; + +/// Live handle of a running BLE scan returned by +/// [BluetoothLE#startScan(ScanSettings, ScanListener)]. Any number of +/// scans may run concurrently -- each handle only sees advertisements +/// passing its own filters. +/// +/// As an `AsyncResource` the handle resolves when the scan +/// *ends*: with `true` after a normal [#stop()], or with a +/// [com.codename1.bluetooth.BluetoothException] when the OS aborted the +/// scan. `cancel()` is equivalent to [#stop()]. +public class BleScan extends AsyncResource { + + /// Created by [BluetoothLE]; application code receives instances from + /// `startScan`. + protected BleScan() { + } + + /// Stops this scan. The handle resolves with `true`; the underlying + /// platform scan keeps running while other handles remain active. + /// Calling `stop()` on an already ended scan is a no-op. + public void stop() { + if (isDone()) { + return; + } + onStop(); + if (!isDone()) { + complete(Boolean.TRUE); + } + } + + /// `true` while the scan is running. + public boolean isActive() { + return !isDone(); + } + + /// Equivalent to [#stop()]. + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + if (isDone()) { + return false; + } + stop(); + return true; + } + + /// Hook for [BluetoothLE] to unregister the handle and stop the + /// platform scan when it was the last one; invoked exactly once from + /// [#stop()]. + protected void onStop() { + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/BluetoothLE.java b/CodenameOne/src/com/codename1/bluetooth/le/BluetoothLE.java new file mode 100644 index 00000000000..e9da7177236 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/BluetoothLE.java @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/// The BLE central role: scanning for peripherals and re-obtaining known +/// ones. Obtain via `Bluetooth.getInstance().getLE()`; the instance is +/// owned by the active port and never `null` -- on ports without BLE it +/// reports every operation as unsupported. +/// +/// Any number of scans may run concurrently. This base class multiplexes +/// them over a single platform scan: each [BleScan] handle only receives +/// advertisements matching its own [ScanSettings] filters, and the +/// platform scan stops when the last handle stops. +public class BluetoothLE { + + private final Object scanLock = new Object(); + private final ArrayList activeScans = + new ArrayList(); + + private static final class ScanRegistration { + BleScan handle; + ScanSettings settings; + ScanListener listener; + HashSet seen; + } + + /// Ports construct subclasses. Application code obtains the active + /// instance via `Bluetooth.getInstance().getLE()`. + protected BluetoothLE() { + } + + /// Starts scanning; the listener fires on the EDT for every + /// advertisement passing the settings' filters. Returns a live + /// [BleScan] handle -- keep a reference and call [BleScan#stop()] + /// when done. On ports without BLE the returned handle is already + /// failed with [BluetoothError#NOT_SUPPORTED]. + public final BleScan startScan(ScanSettings settings, ScanListener listener) { + final ScanRegistration reg = new ScanRegistration(); + reg.settings = settings == null ? new ScanSettings() : settings; + reg.listener = listener; + reg.handle = new BleScan() { + @Override + protected void onStop() { + unregisterScan(reg); + } + }; + if (listener == null) { + reg.handle.error(new BluetoothException(BluetoothError.UNKNOWN, + "startScan requires a listener")); + return reg.handle; + } + if (!isScanSupported()) { + reg.handle.error(new BluetoothException( + BluetoothError.NOT_SUPPORTED, + "BLE scanning is not supported on this platform")); + return reg.handle; + } + if (!reg.settings.isAllowDuplicates()) { + reg.seen = new HashSet(); + } + boolean first; + synchronized (scanLock) { + activeScans.add(reg); + first = activeScans.size() == 1; + } + if (first) { + try { + startPlatformScan(); + } catch (RuntimeException ex) { + unregisterScan(reg); + if (!reg.handle.isDone()) { + reg.handle.error(new BluetoothException( + BluetoothError.SCAN_FAILED, + "Failed to start scan: " + ex, ex)); + } + return reg.handle; + } + } + onScanRegistrationsChanged(); + return reg.handle; + } + + private void unregisterScan(ScanRegistration reg) { + boolean last; + synchronized (scanLock) { + boolean removed = activeScans.remove(reg); + last = removed && activeScans.isEmpty(); + } + if (last) { + try { + stopPlatformScan(); + } catch (RuntimeException ignored) { + } + } + onScanRegistrationsChanged(); + } + + /// Re-obtains a peripheral from an address persisted earlier (see + /// [com.codename1.bluetooth.BluetoothDevice#getAddress()] for the + /// address semantics) without scanning. Returns `null` when the + /// platform cannot resolve the address. + public BlePeripheral getPeripheral(String address) { + return null; + } + + /// The peripherals the *system* currently holds connected (e.g. + /// paired wearables), optionally filtered to those offering the given + /// service. Empty on ports without BLE. + /// + /// iOS filters exactly (CoreBluetooth resolves services); Android can + /// only consult its cached SDP/GATT UUIDs, so the filter is + /// best-effort there and devices with an empty cache are retained. + public List getConnectedPeripherals( + BluetoothUuid serviceFilter) { + return new ArrayList(); + } + + /// The LE peripherals bonded with this device. Empty on ports without + /// BLE or without a bonded-device registry (iOS). + public List getBondedPeripherals() { + return new ArrayList(); + } + + // ------------------------------------------------------------------ + // peripheral role + // ------------------------------------------------------------------ + + /// Opens the local GATT server for the peripheral role; the + /// listener's methods fire on the EDT. Fails with + /// [BluetoothError#NOT_SUPPORTED] on ports without peripheral mode -- + /// branch via `Bluetooth.getInstance().isPeripheralModeSupported()`. + public AsyncResource openGattServer( + GattServerListener listener) { + AsyncResource r = new AsyncResource(); + r.error(peripheralNotSupported()); + return r; + } + + /// Starts advertising. Resolves with a live [BleAdvertisement] handle + /// once the platform actually started broadcasting, or fails with + /// [BluetoothError#ADVERTISE_FAILED] / + /// [BluetoothError#NOT_SUPPORTED]. `scanResponse` may be `null`. + public AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse) { + AsyncResource r = + new AsyncResource(); + r.error(peripheralNotSupported()); + return r; + } + + /// Opens a listening L2CAP endpoint for the peripheral role; publish + /// [L2capServer#getPsm()] to centrals (typically via a GATT + /// characteristic). Fails with [BluetoothError#NOT_SUPPORTED] where + /// L2CAP or peripheral mode is unavailable. + public AsyncResource openL2capServer(boolean secure) { + AsyncResource r = new AsyncResource(); + r.error(peripheralNotSupported()); + return r; + } + + private static BluetoothException peripheralNotSupported() { + return new BluetoothException(BluetoothError.NOT_SUPPORTED, + "BLE peripheral mode is not supported on this platform"); + } + + // ------------------------------------------------------------------ + // port SPI + // ------------------------------------------------------------------ + + /// `true` when this port can scan; the base class returns `false`, + /// making [#startScan(ScanSettings, ScanListener)] fail fast. + protected boolean isScanSupported() { + return false; + } + + /// Starts the single underlying platform scan; called when the first + /// [BleScan] handle registers. Sightings are reported via + /// [#fireScanResult(ScanResult)]. May throw a `RuntimeException` to + /// fail the initiating handle. + protected void startPlatformScan() { + } + + /// Stops the underlying platform scan; called when the last handle + /// stops. + protected void stopPlatformScan() { + } + + /// Called after every scan registration change. Ports may retune the + /// platform scan (e.g. push down filters or raise the scan mode) based + /// on [#getActiveScanSettings()]. Default does nothing. + protected void onScanRegistrationsChanged() { + } + + /// The settings of all currently active scan handles -- for ports + /// that optimize the platform scan. + protected final List getActiveScanSettings() { + ArrayList out = new ArrayList(); + synchronized (scanLock) { + int size = activeScans.size(); + for (int i = 0; i < size; i++) { + out.add(activeScans.get(i).settings); + } + } + return out; + } + + /// The most aggressive [ScanMode] among the active handles -- + /// convenience for ports mapping the merged scan onto a platform scan + /// mode. + protected final ScanMode getAggregateScanMode() { + ScanMode mode = ScanMode.OPPORTUNISTIC; + synchronized (scanLock) { + int size = activeScans.size(); + for (int i = 0; i < size; i++) { + ScanMode m = activeScans.get(i).settings.getScanMode(); + if (m.ordinal() > mode.ordinal()) { + mode = m; + } + } + } + return mode; + } + + /// Reports one advertisement sighting from the platform scan; safe to + /// call from any thread. The base class demultiplexes it to every + /// active handle whose filters match, applying per-handle duplicate + /// suppression, and dispatches the listeners on the EDT. + protected final void fireScanResult(final ScanResult result) { + if (result == null) { + return; + } + final ArrayList targets = new ArrayList(); + synchronized (scanLock) { + int size = activeScans.size(); + for (int i = 0; i < size; i++) { + ScanRegistration reg = activeScans.get(i); + if (!reg.settings.matches(result)) { + continue; + } + if (reg.seen != null && !reg.seen.add( + result.getPeripheral().getAddress())) { + continue; + } + targets.add(reg.listener); + } + } + if (targets.isEmpty()) { + return; + } + dispatchScanResult(targets, result); + } + + // Static so the Runnable doesn't carry a synthetic outer-BluetoothLE + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static void dispatchScanResult( + final ArrayList targets, final ScanResult result) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + int size = targets.size(); + for (int i = 0; i < size; i++) { + targets.get(i).peripheralDiscovered(result); + } + } + }); + } + + /// Reports that the OS aborted the platform scan; every active handle + /// fails with the given reason and the registry is cleared. The base + /// class does NOT call [#stopPlatformScan()] on this path -- the + /// platform scan is presumed dead, so ports must clean up their own + /// scanner state before (or when) calling this. + protected final void fireScanFailed(BluetoothException reason) { + final ArrayList failed; + synchronized (scanLock) { + failed = new ArrayList(activeScans); + activeScans.clear(); + } + BluetoothException r = reason != null ? reason + : new BluetoothException(BluetoothError.SCAN_FAILED, + "Scan aborted by the OS"); + int size = failed.size(); + for (int i = 0; i < size; i++) { + BleScan handle = failed.get(i).handle; + if (!handle.isDone()) { + handle.error(r); + } + } + onScanRegistrationsChanged(); + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ConnectionEvent.java b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionEvent.java new file mode 100644 index 00000000000..bb3cfd47108 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionEvent.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.bluetooth.BluetoothException; + +/// Payload of a [ConnectionListener] callback describing a +/// [BlePeripheral] state transition. +public class ConnectionEvent { + + private final BlePeripheral peripheral; + private final ConnectionState state; + private final BluetoothException reason; + + ConnectionEvent(BlePeripheral peripheral, ConnectionState state, + BluetoothException reason) { + this.peripheral = peripheral; + this.state = state; + this.reason = reason; + } + + /// The peripheral whose connection state changed. + public BlePeripheral getPeripheral() { + return peripheral; + } + + /// The new connection state. + public ConnectionState getState() { + return state; + } + + /// For failure-driven transitions (an unexpected link loss, a failed + /// connect) the typed cause; `null` for app-requested transitions + /// such as a plain [BlePeripheral#disconnect()]. + public BluetoothException getReason() { + return reason; + } + + @Override + public String toString() { + return "ConnectionEvent(" + peripheral.getAddress() + " -> " + state + + (reason != null ? ", reason=" + reason.getError() : "") + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ConnectionListener.java b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionListener.java new file mode 100644 index 00000000000..f200f29aa5e --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionListener.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +/// Observes [BlePeripheral] connection state transitions registered via +/// [BlePeripheral#addConnectionListener(ConnectionListener)]. Single +/// abstract method so application code can pass a lambda. Always invoked +/// on the EDT. +public interface ConnectionListener { + /// Fired on the EDT for every connection state transition. + void connectionStateChanged(ConnectionEvent event); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ConnectionOptions.java b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionOptions.java new file mode 100644 index 00000000000..32ec414e75f --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionOptions.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +/// Options for [BlePeripheral#connect(ConnectionOptions)]. Fluent setters +/// return `this` for chaining. +public class ConnectionOptions { + + private boolean autoConnect; + private int timeout; + + /// When `true`, asks the platform to reconnect automatically whenever + /// the peripheral comes back into range (Android `autoConnect`; iOS + /// re-issues the connect request, which never times out there). + /// Defaults to `false`: a single direct connection attempt. + public ConnectionOptions setAutoConnect(boolean auto) { + this.autoConnect = auto; + return this; + } + + /// Fails the connection attempt with + /// [com.codename1.bluetooth.BluetoothError#TIMEOUT] after the given + /// number of milliseconds. `0` (the default) uses the platform's own + /// timeout behavior -- note that iOS connect requests never time out + /// on their own. + public ConnectionOptions setTimeout(int millis) { + this.timeout = millis; + return this; + } + + /// The configured auto-connect flag. + public boolean isAutoConnect() { + return autoConnect; + } + + /// The configured timeout in milliseconds; `0` means platform + /// default. + public int getTimeout() { + return timeout; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ConnectionPriority.java b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionPriority.java new file mode 100644 index 00000000000..881e0d4208a --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionPriority.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +/// Connection-interval preference requested via +/// [BlePeripheral#requestConnectionPriority(ConnectionPriority)]. Android +/// maps these to its connection-priority constants; iOS manages intervals +/// itself and treats the request as a successful no-op. +public enum ConnectionPriority { + /// Longer connection interval -- lower throughput, better battery. + LOW_POWER, + + /// The platform default trade-off. + BALANCED, + + /// Short connection interval for high-throughput transfers. + HIGH +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ConnectionState.java b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionState.java new file mode 100644 index 00000000000..0a84e3fd91f --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ConnectionState.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +/// Lifecycle states of a [BlePeripheral] connection. +public enum ConnectionState { + /// No connection. GATT operations fail with + /// [com.codename1.bluetooth.BluetoothError#NOT_CONNECTED]. + DISCONNECTED, + + /// A [BlePeripheral#connect()] attempt is in progress. + CONNECTING, + + /// The link is established; GATT operations are available. + CONNECTED, + + /// A [BlePeripheral#disconnect()] request is in progress. + DISCONNECTING +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/GattOperationQueue.java b/CodenameOne/src/com/codename1/bluetooth/le/GattOperationQueue.java new file mode 100644 index 00000000000..e616e069098 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/GattOperationQueue.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.util.AsyncResource; +import com.codename1.util.AsyncResult; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.Timer; +import java.util.TimerTask; + +/// Serializes GATT operations against one peripheral: platform stacks +/// (Android in particular) silently drop a second in-flight GATT request, +/// so every operation is started only after the previous one completed, +/// failed, or hit the safety timeout. Each operation owns an independent +/// `AsyncResource`, so callers may issue any number of concurrent requests +/// and correlate results per call. +final class GattOperationQueue { + + /// One queued operation: `start()` invokes the port SPI which must + /// eventually complete or fail [#result]. + abstract static class Op { + final AsyncResource result; + TimerTask timeoutTask; + Timer timeoutTimer; + + Op(AsyncResource result) { + this.result = result; + } + + abstract void start(); + } + + private final Object lock = new Object(); + private final LinkedList pending = new LinkedList(); + private Op current; + private int timeoutMillis = 30000; + + /// Sets the per-operation safety timeout; `0` or negative disables it. + void setTimeoutMillis(int millis) { + timeoutMillis = millis; + } + + int getTimeoutMillis() { + return timeoutMillis; + } + + /// Enqueues the operation; it starts immediately when the queue is + /// idle. Safe to call from any thread. + void enqueue(final Op op) { + op.result.onResult(new AsyncResult() { + @Override + public void onReady(Object value, Throwable err) { + advance(op); + } + }); + boolean startNow; + synchronized (lock) { + if (current == null) { + current = op; + startNow = true; + } else { + pending.add(op); + startNow = false; + } + } + if (startNow) { + startOp(op); + } + } + + /// Fails the in-flight and all queued operations -- called when the + /// connection drops. + void failAll(BluetoothException reason) { + ArrayList toFail = new ArrayList(); + synchronized (lock) { + if (current != null) { + toFail.add(current); + current = null; + } + toFail.addAll(pending); + pending.clear(); + } + int size = toFail.size(); + for (int i = 0; i < size; i++) { + Op op = toFail.get(i); + cancelTimeout(op); + if (!op.result.isDone()) { + op.result.error(reason); + } + } + } + + private void startOp(final Op op) { + // an operation cancelled while it waited in the queue never fires + // its callbacks, so it must be skipped explicitly + if (op.result.isDone()) { + advance(op); + return; + } + armTimeout(op); + try { + op.start(); + } catch (RuntimeException ex) { + if (!op.result.isDone()) { + op.result.error(new BluetoothException(BluetoothError.UNKNOWN, + "GATT operation failed to start: " + ex, ex)); + } + } + } + + private void advance(Op op) { + Op next; + synchronized (lock) { + if (current != op) { //NOPMD CompareObjectsWithEquals + return; + } + cancelTimeout(op); + current = pending.poll(); + next = current; + } + if (next != null) { + startOp(next); + } + } + + private void armTimeout(final Op op) { + final int t = timeoutMillis; + if (t <= 0) { + return; + } + TimerTask task = new TimerTask() { + @Override + public void run() { + boolean isCurrent; + synchronized (lock) { + isCurrent = current == op; //NOPMD CompareObjectsWithEquals + } + if (isCurrent && !op.result.isDone()) { + op.result.error(new BluetoothException( + BluetoothError.TIMEOUT, + "GATT operation timed out after " + t + "ms")); + } + } + }; + op.timeoutTask = task; + op.timeoutTimer = schedule(task, t); + } + + private void cancelTimeout(Op op) { + TimerTask task = op.timeoutTask; + if (task != null) { + op.timeoutTask = null; + task.cancel(); + } + Timer timer = op.timeoutTimer; + if (timer != null) { + op.timeoutTimer = null; + // ends the timer thread rather than leaving it parked for the + // life of the process + timer.cancel(); + } + } + + /// Schedules a one-shot task on its own timer and returns it so the + /// caller can cancel both task and timer. A timer per pending timeout + /// mirrors `Display.setTimeout` and keeps the device API surface + /// (CLDC11) satisfied -- it declares no daemon-thread constructor, and + /// a shared non-daemon timer would keep a desktop JVM alive after the + /// app closes. Also used by [BlePeripheral] for connect timeouts. + static Timer schedule(TimerTask task, int delayMillis) { + Timer timer = new Timer(); + timer.schedule(task, delayMillis); + return timer; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/L2capChannel.java b/CodenameOne/src/com/codename1/bluetooth/le/L2capChannel.java new file mode 100644 index 00000000000..4233989996f --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/L2capChannel.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// An open L2CAP connection-oriented channel -- a raw bidirectional byte +/// stream to a peripheral, obtained via +/// [BlePeripheral#openL2capChannel(int, boolean)] on the central side or +/// accepted from an [L2capServer] on the peripheral side. +/// +/// The streams **block** and must be consumed off the EDT; reads/writes +/// throw plain `java.io.IOException` on transport failure. Always +/// [#close()] the channel when done. +public abstract class L2capChannel { + + private final int psm; + + /// Constructed by ports; not application API. + protected L2capChannel(int psm) { + this.psm = psm; + } + + /// The blocking input stream of the channel; consume off the EDT. + public abstract InputStream getInputStream() throws IOException; + + /// The blocking output stream of the channel; use off the EDT. + public abstract OutputStream getOutputStream() throws IOException; + + /// Closes the channel and both streams. + public abstract void close() throws IOException; + + /// `true` while the channel is open. + public abstract boolean isOpen(); + + /// The Protocol/Service Multiplexer this channel is bound to. + public int getPsm() { + return psm; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/L2capServer.java b/CodenameOne/src/com/codename1/bluetooth/le/L2capServer.java new file mode 100644 index 00000000000..c6a4e947e8d --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/L2capServer.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.util.AsyncResource; + +/// A listening L2CAP endpoint on the local device, opened via +/// `BluetoothLE.openL2capServer(boolean)` when acting as a peripheral. +/// Publish [#getPsm()] to centrals (typically through a GATT +/// characteristic) so they can connect. +public abstract class L2capServer { + + /// Constructed by ports; not application API. + protected L2capServer() { + } + + /// The dynamic Protocol/Service Multiplexer the stack assigned to this + /// listener; advertise it to centrals. + public abstract int getPsm(); + + /// Resolves with the next incoming [L2capChannel]. Call again after + /// each resolution to accept further channels. + public abstract AsyncResource accept(); + + /// Stops listening; pending [#accept()] calls fail with + /// [com.codename1.bluetooth.BluetoothError#IO_ERROR]. + public abstract void close(); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ScanFilter.java b/CodenameOne/src/com/codename1/bluetooth/le/ScanFilter.java new file mode 100644 index 00000000000..9e75ea38b6d --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ScanFilter.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import com.codename1.bluetooth.BluetoothUuid; + +/// A single scan filter -- all criteria set on one filter are AND-combined; +/// multiple filters added to a [ScanSettings] are OR-combined. Fluent +/// setters return `this` for chaining: +/// +/// ```java +/// new ScanFilter() +/// .setServiceUuid(BluetoothUuid.fromShort(0x180D)) +/// .setNamePrefix("Polar"); +/// ``` +public class ScanFilter { + + private BluetoothUuid serviceUuid; + private String name; + private String namePrefix; + private String address; + private int manufacturerId = -1; + private byte[] manufacturerData; + private byte[] manufacturerDataMask; + + /// Matches devices advertising the given service UUID. + public ScanFilter setServiceUuid(BluetoothUuid uuid) { + this.serviceUuid = uuid; + return this; + } + + /// Matches devices advertising exactly this local name. + public ScanFilter setName(String exactName) { + this.name = exactName; + return this; + } + + /// Matches devices whose advertised local name starts with the given + /// prefix. + public ScanFilter setNamePrefix(String prefix) { + this.namePrefix = prefix; + return this; + } + + /// Matches the device with the given address (see + /// [com.codename1.bluetooth.BluetoothDevice#getAddress()] for the + /// per-platform address semantics). + public ScanFilter setAddress(String address) { + this.address = address; + return this; + } + + /// Matches devices whose advertisement carries manufacturer data for + /// `companyId` whose leading bytes equal `data` under `mask` (a `null` + /// mask compares all of `data` exactly). Pass `null` data to match any + /// payload for the company. + public ScanFilter setManufacturerData(int companyId, byte[] data, + byte[] mask) { + this.manufacturerId = companyId; + this.manufacturerData = data; + this.manufacturerDataMask = mask; + return this; + } + + /// The service UUID criterion, or `null` when unset. Ports use the + /// getters to push filters down to the platform scanner -- required + /// for background scanning on iOS. + public BluetoothUuid getServiceUuid() { + return serviceUuid; + } + + /// The exact-name criterion, or `null` when unset. + public String getName() { + return name; + } + + /// The name-prefix criterion, or `null` when unset. + public String getNamePrefix() { + return namePrefix; + } + + /// The address criterion, or `null` when unset. + public String getAddress() { + return address; + } + + /// The manufacturer-data company identifier criterion, or `-1` when + /// unset. + public int getManufacturerId() { + return manufacturerId; + } + + /// The manufacturer-data payload criterion, or `null`. + public byte[] getManufacturerData() { + return manufacturerData; + } + + /// The manufacturer-data mask, or `null` (exact comparison). + public byte[] getManufacturerDataMask() { + return manufacturerDataMask; + } + + /// `true` when the given scan result satisfies every criterion of this + /// filter. Used by the core scan demultiplexer; ports may also use it + /// to pre-filter. + public boolean matches(ScanResult result) { + AdvertisementData ad = result.getAdvertisementData(); + if (address != null) { + if (!address.equals(result.getPeripheral().getAddress())) { + return false; + } + } + if (name != null) { + String n = ad == null ? null : ad.getLocalName(); + if (n == null) { + n = result.getPeripheral().getName(); + } + if (!name.equals(n)) { + return false; + } + } + if (namePrefix != null) { + String n = ad == null ? null : ad.getLocalName(); + if (n == null) { + n = result.getPeripheral().getName(); + } + if (n == null || !n.startsWith(namePrefix)) { + return false; + } + } + if (serviceUuid != null) { + if (ad == null || !ad.getServiceUuids().contains(serviceUuid)) { + return false; + } + } + if (manufacturerId >= 0) { + byte[] payload = ad == null + ? null : ad.getManufacturerData(manufacturerId); + if (payload == null) { + return false; + } + if (manufacturerData != null) { + if (payload.length < manufacturerData.length) { + return false; + } + for (int i = 0; i < manufacturerData.length; i++) { + byte m = manufacturerDataMask == null + || i >= manufacturerDataMask.length + ? (byte) 0xFF : manufacturerDataMask[i]; + if ((payload[i] & m) != (manufacturerData[i] & m)) { + return false; + } + } + } + } + return true; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ScanListener.java b/CodenameOne/src/com/codename1/bluetooth/le/ScanListener.java new file mode 100644 index 00000000000..8e3f1d14b12 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ScanListener.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +/// Receives advertisement sightings during a scan started via +/// [BluetoothLE#startScan(ScanSettings, ScanListener)]. Single abstract +/// method so application code can pass a lambda. Always invoked on the EDT. +public interface ScanListener { + /// Fired on the EDT for every advertisement passing the scan's + /// filters. + void peripheralDiscovered(ScanResult result); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ScanMode.java b/CodenameOne/src/com/codename1/bluetooth/le/ScanMode.java new file mode 100644 index 00000000000..cfe53a5ae9c --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ScanMode.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +/// Power/latency trade-off of a BLE scan, mirroring Android's scan modes. +/// Platforms without an equivalent control (iOS) treat this as a hint. +public enum ScanMode { + /// Passive: only report advertisements other scans triggered. Lowest + /// power; Android only. + OPPORTUNISTIC, + + /// Low duty cycle scanning; large battery savings, slow discovery. + LOW_POWER, + + /// The default balance between latency and power. + BALANCED, + + /// Continuous scanning for the fastest discovery; highest power draw. + LOW_LATENCY +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ScanResult.java b/CodenameOne/src/com/codename1/bluetooth/le/ScanResult.java new file mode 100644 index 00000000000..032a9e1d340 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ScanResult.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +/// One advertisement sighting delivered to a +/// [ScanListener] during a scan. Carries the [BlePeripheral] handle used +/// to connect, the signal strength and the parsed advertisement payload. +/// +/// Instances are constructed by ports; application code never creates +/// them. +public class ScanResult { + + private final BlePeripheral peripheral; + private final int rssi; + private final AdvertisementData advertisementData; + private final boolean connectable; + private final long timestamp; + + /// Constructed by ports when a scan sighting arrives; not application + /// API. + public ScanResult(BlePeripheral peripheral, int rssi, + AdvertisementData advertisementData, boolean connectable, + long timestamp) { + this.peripheral = peripheral; + this.rssi = rssi; + this.advertisementData = advertisementData == null + ? new AdvertisementData() : advertisementData; + this.connectable = connectable; + this.timestamp = timestamp; + } + + /// The discovered peripheral; call [BlePeripheral#connect()] on it to + /// establish a connection. + public BlePeripheral getPeripheral() { + return peripheral; + } + + /// The received signal strength of this sighting in dBm. + public int getRssi() { + return rssi; + } + + /// The parsed advertisement payload; never `null`. + public AdvertisementData getAdvertisementData() { + return advertisementData; + } + + /// `true` when the advertisement indicates the peripheral accepts + /// connections. + public boolean isConnectable() { + return connectable; + } + + /// The sighting time -- `System.currentTimeMillis()` terms on device + /// ports; the simulator's virtual stack uses a monotonic counter, so + /// treat the value as ordered rather than as a wall-clock time. + public long getTimestamp() { + return timestamp; + } + + @Override + public String toString() { + return "ScanResult(" + peripheral.getAddress() + ", rssi=" + rssi + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/ScanSettings.java b/CodenameOne/src/com/codename1/bluetooth/le/ScanSettings.java new file mode 100644 index 00000000000..40a79611d4a --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/ScanSettings.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le; + +import java.util.ArrayList; +import java.util.List; + +/// Options for a BLE scan started via +/// [BluetoothLE#startScan(ScanSettings, ScanListener)]. Fluent setters +/// return `this` for chaining. +public class ScanSettings { + + private ScanMode scanMode = ScanMode.BALANCED; + private boolean allowDuplicates; + private ArrayList filters; + + /// The power/latency trade-off; defaults to [ScanMode#BALANCED]. + public ScanSettings setScanMode(ScanMode mode) { + this.scanMode = mode == null ? ScanMode.BALANCED : mode; + return this; + } + + /// When `false` (the default) each device is reported once per scan; + /// when `true` every advertisement sighting is reported -- required + /// for RSSI tracking and beacon monitoring. + public ScanSettings setAllowDuplicates(boolean allow) { + this.allowDuplicates = allow; + return this; + } + + /// Adds a filter; multiple filters are OR-combined. A scan without + /// filters reports every advertising device. + public ScanSettings addFilter(ScanFilter filter) { + if (filter != null) { + if (filters == null) { + filters = new ArrayList(); + } + filters.add(filter); + } + return this; + } + + /// The configured scan mode. + public ScanMode getScanMode() { + return scanMode; + } + + /// Whether duplicate sightings are reported. + public boolean isAllowDuplicates() { + return allowDuplicates; + } + + /// The filters added via [#addFilter(ScanFilter)]; empty means + /// match-all. + public List getFilters() { + return filters == null + ? new ArrayList() : new ArrayList(filters); + } + + /// `true` when the given result passes this settings object's filter + /// set (no filters == match all). Used by the core scan + /// demultiplexer. + public boolean matches(ScanResult result) { + if (filters == null || filters.isEmpty()) { + return true; + } + int size = filters.size(); + for (int i = 0; i < size; i++) { + if (filters.get(i).matches(result)) { + return true; + } + } + return false; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/package-info.java b/CodenameOne/src/com/codename1/bluetooth/le/package-info.java new file mode 100644 index 00000000000..2effb69ecf6 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/package-info.java @@ -0,0 +1,32 @@ +/* + * 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. + */ +/// BLE central role: scanning (`BluetoothLE`, `ScanSettings`, +/// `ScanFilter`, `ScanResult`, `BleScan`), connections and the GATT +/// client (`BlePeripheral`, connection events) and L2CAP +/// connection-oriented channels (`L2capChannel`, `L2capServer`). +/// +/// Obtain the entry point via `Bluetooth.getInstance().getLE()`. Any +/// number of scans and per-peripheral GATT operations may run +/// concurrently -- an internal queue serializes operations toward the +/// platform stack and every call returns its own `AsyncResource`. +package com.codename1.bluetooth.le; diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseData.java b/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseData.java new file mode 100644 index 00000000000..143d5f306a6 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseData.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.BluetoothUuid; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The payload to advertise via `BluetoothLE.startAdvertising`. Keep it +/// small -- a legacy advertisement carries at most 31 bytes; oversized +/// payloads fail with +/// [com.codename1.bluetooth.BluetoothError#ADVERTISE_FAILED]. Fluent +/// setters return `this` for chaining. +/// +/// Platform note: iOS only broadcasts the local name and service UUIDs -- +/// CoreBluetooth silently ignores manufacturer data, service data and TX +/// power inclusion when advertising (Android broadcasts everything). +public class AdvertiseData { + + private final ArrayList serviceUuids = + new ArrayList(); + private final HashMap manufacturerData = + new HashMap(); + private final HashMap serviceData = + new HashMap(); + private boolean includeDeviceName; + private boolean includeTxPower; + + /// Advertises the given service UUID so filtered scans can find this + /// peripheral. + public AdvertiseData addServiceUuid(BluetoothUuid uuid) { + if (uuid != null && !serviceUuids.contains(uuid)) { + serviceUuids.add(uuid); + } + return this; + } + + /// Includes the device name in the advertisement (off by default -- + /// names use up scarce advertisement bytes). + public AdvertiseData setIncludeDeviceName(boolean include) { + this.includeDeviceName = include; + return this; + } + + /// Includes the TX power level in the advertisement. + public AdvertiseData setIncludeTxPower(boolean include) { + this.includeTxPower = include; + return this; + } + + /// Adds manufacturer-specific data for the given company identifier. + public AdvertiseData addManufacturerData(int companyId, byte[] data) { + manufacturerData.put(Integer.valueOf(companyId), data); + return this; + } + + /// Adds service data for the given service UUID. + public AdvertiseData addServiceData(BluetoothUuid uuid, byte[] data) { + serviceData.put(uuid, data); + return this; + } + + /// The service UUIDs to advertise. + public List getServiceUuids() { + return new ArrayList(serviceUuids); + } + + /// Whether the device name is included. + public boolean isIncludeDeviceName() { + return includeDeviceName; + } + + /// Whether the TX power level is included. + public boolean isIncludeTxPower() { + return includeTxPower; + } + + /// The manufacturer data entries, keyed by company identifier. + public Map getManufacturerData() { + return new HashMap(manufacturerData); + } + + /// The service data entries, keyed by service UUID. + public Map getServiceData() { + return new HashMap(serviceData); + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseMode.java b/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseMode.java new file mode 100644 index 00000000000..acfeb2513d1 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseMode.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +/// Advertising-interval trade-off, mirroring Android's advertise modes. +/// Platforms without the control (iOS) treat it as a hint. +public enum AdvertiseMode { + /// Long advertising interval; lowest power, slowest discovery by + /// centrals. + LOW_POWER, + + /// The default balance. + BALANCED, + + /// Short advertising interval for the fastest discovery; highest + /// power draw. + LOW_LATENCY +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseSettings.java b/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseSettings.java new file mode 100644 index 00000000000..c12c581fa2a --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/AdvertiseSettings.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +/// Options for `BluetoothLE.startAdvertising`. Fluent setters return +/// `this` for chaining. +public class AdvertiseSettings { + + private AdvertiseMode mode = AdvertiseMode.BALANCED; + private TxPowerLevel txPower = TxPowerLevel.MEDIUM; + private boolean connectable = true; + private int timeout; + + /// The advertising-interval trade-off; defaults to + /// [AdvertiseMode#BALANCED]. + public AdvertiseSettings setMode(AdvertiseMode mode) { + this.mode = mode == null ? AdvertiseMode.BALANCED : mode; + return this; + } + + /// The transmit power; defaults to [TxPowerLevel#MEDIUM]. + public AdvertiseSettings setTxPower(TxPowerLevel level) { + this.txPower = level == null ? TxPowerLevel.MEDIUM : level; + return this; + } + + /// Whether centrals may connect (the default) or the advertisement is + /// broadcast-only (beacons). + public AdvertiseSettings setConnectable(boolean connectable) { + this.connectable = connectable; + return this; + } + + /// Stops advertising automatically after the given number of + /// milliseconds; `0` (the default) advertises until + /// [BleAdvertisement#stop()]. + public AdvertiseSettings setTimeout(int millis) { + this.timeout = millis; + return this; + } + + /// The configured advertise mode. + public AdvertiseMode getMode() { + return mode; + } + + /// The configured transmit power. + public TxPowerLevel getTxPower() { + return txPower; + } + + /// Whether the advertisement accepts connections. + public boolean isConnectable() { + return connectable; + } + + /// The configured timeout in milliseconds; `0` means until stopped. + public int getTimeout() { + return timeout; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/BleAdvertisement.java b/CodenameOne/src/com/codename1/bluetooth/le/server/BleAdvertisement.java new file mode 100644 index 00000000000..d5cf979d599 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/BleAdvertisement.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +/// Live handle of a running advertisement returned by +/// `BluetoothLE.startAdvertising`. Keep a reference and [#stop()] when +/// done. +public abstract class BleAdvertisement { + + /// Constructed by ports; not application API. + protected BleAdvertisement() { + } + + /// Stops advertising. A no-op when already stopped. + public abstract void stop(); + + /// `true` while the advertisement is broadcast. + public abstract boolean isActive(); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/BleCentral.java b/CodenameOne/src/com/codename1/bluetooth/le/server/BleCentral.java new file mode 100644 index 00000000000..05b827f2b6e --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/BleCentral.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +/// A remote central connected to the local [GattServer] -- the mirror +/// image of `BlePeripheral` when this device acts as the peripheral. +public abstract class BleCentral { + + private int mtu = 23; + + /// Constructed by ports; not application API. + protected BleCentral() { + } + + /// A stable identifier of the connected central (platform-specific, + /// same semantics as + /// [com.codename1.bluetooth.BluetoothDevice#getAddress()]). + public abstract String getAddress(); + + /// The MTU negotiated with this central; `23` until negotiated + /// higher. + public int getMtu() { + return mtu; + } + + /// Records the negotiated MTU; called by ports. + protected void setMtu(int mtu) { + this.mtu = mtu; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalCharacteristic.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalCharacteristic.java new file mode 100644 index 00000000000..8e96bf581f1 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalCharacteristic.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.BluetoothUuid; + +import java.util.ArrayList; +import java.util.List; + +/// A characteristic definition of a local [GattLocalService] served by +/// this device's [GattServer]. Properties use the +/// [GattCharacteristic]`.PROPERTY_*` bits; access permissions use the +/// `PERMISSION_*` bits defined here. +public class GattLocalCharacteristic { + + /// The characteristic may be read (bit `0x01`). + public static final int PERMISSION_READ = 0x01; + /// The characteristic may be read over an encrypted link only (bit + /// `0x02`). + public static final int PERMISSION_READ_ENCRYPTED = 0x02; + /// The characteristic may be written (bit `0x10`). + public static final int PERMISSION_WRITE = 0x10; + /// The characteristic may be written over an encrypted link only (bit + /// `0x20`). + public static final int PERMISSION_WRITE_ENCRYPTED = 0x20; + + private final BluetoothUuid uuid; + private final int properties; + private final int permissions; + private byte[] value; + private final ArrayList descriptors = + new ArrayList(); + + /// Creates a characteristic definition. `properties` uses + /// [GattCharacteristic]`.PROPERTY_*` bits; `permissions` uses the + /// `PERMISSION_*` bits of this class. + public GattLocalCharacteristic(BluetoothUuid uuid, int properties, + int permissions) { + this.uuid = uuid; + this.properties = properties; + this.permissions = permissions; + } + + /// Serves a static value without routing read requests to the + /// [GattServerListener]. Without a static value, every read arrives + /// as a [GattReadRequest]. + public GattLocalCharacteristic setValue(byte[] staticValue) { + this.value = staticValue; + return this; + } + + /// Adds a descriptor definition. + public GattLocalCharacteristic addDescriptor(GattLocalDescriptor d) { + if (d != null) { + descriptors.add(d); + } + return this; + } + + /// The UUID identifying this characteristic. + public BluetoothUuid getUuid() { + return uuid; + } + + /// The [GattCharacteristic]`.PROPERTY_*` bitmask. + public int getProperties() { + return properties; + } + + /// The `PERMISSION_*` bitmask. + public int getPermissions() { + return permissions; + } + + /// The static value, or `null` when reads are served via the + /// [GattServerListener]. + public byte[] getValue() { + return value; + } + + /// The descriptor definitions added via + /// [#addDescriptor(GattLocalDescriptor)]. + public List getDescriptors() { + return new ArrayList(descriptors); + } + + @Override + public String toString() { + return "GattLocalCharacteristic(" + uuid + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalDescriptor.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalDescriptor.java new file mode 100644 index 00000000000..d881a74d989 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalDescriptor.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.BluetoothUuid; + +/// A descriptor definition of a local [GattLocalCharacteristic] served by +/// this device's [GattServer]. Note that the Client Characteristic +/// Configuration descriptor is managed by the platform stack -- do not add +/// it manually. +public class GattLocalDescriptor { + + private final BluetoothUuid uuid; + private final int permissions; + private byte[] value; + + /// Creates a descriptor definition with + /// [GattLocalCharacteristic]`.PERMISSION_*` bits. + public GattLocalDescriptor(BluetoothUuid uuid, int permissions) { + this.uuid = uuid; + this.permissions = permissions; + } + + /// Serves a static value without routing read requests to the + /// [GattServerListener]. + public GattLocalDescriptor setValue(byte[] staticValue) { + this.value = staticValue; + return this; + } + + /// The UUID identifying this descriptor. + public BluetoothUuid getUuid() { + return uuid; + } + + /// The `PERMISSION_*` bitmask. + public int getPermissions() { + return permissions; + } + + /// The static value, or `null` when reads are served via the + /// [GattServerListener]. + public byte[] getValue() { + return value; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalService.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalService.java new file mode 100644 index 00000000000..ada09f1d024 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattLocalService.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.BluetoothUuid; + +import java.util.ArrayList; +import java.util.List; + +/// A service definition added to this device's [GattServer] via +/// [GattServer#addService(GattLocalService)]. +public class GattLocalService { + + private final BluetoothUuid uuid; + private final boolean primary; + private final ArrayList characteristics = + new ArrayList(); + + /// Creates a primary service definition. + public GattLocalService(BluetoothUuid uuid) { + this(uuid, true); + } + + /// Creates a service definition; `primary` is `false` for secondary + /// services. + public GattLocalService(BluetoothUuid uuid, boolean primary) { + this.uuid = uuid; + this.primary = primary; + } + + /// Adds a characteristic definition; fluent. + public GattLocalService addCharacteristic(GattLocalCharacteristic c) { + if (c != null) { + characteristics.add(c); + } + return this; + } + + /// The UUID identifying this service. + public BluetoothUuid getUuid() { + return uuid; + } + + /// `true` for a primary service. + public boolean isPrimary() { + return primary; + } + + /// The characteristic definitions added via + /// [#addCharacteristic(GattLocalCharacteristic)]. + public List getCharacteristics() { + return new ArrayList(characteristics); + } + + @Override + public String toString() { + return "GattLocalService(" + uuid + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattReadRequest.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattReadRequest.java new file mode 100644 index 00000000000..37bbd491350 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattReadRequest.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.gatt.GattStatus; + +/// A read request from a connected central, delivered to +/// [GattServerListener#characteristicReadRequest(GattReadRequest)] or +/// [GattServerListener#descriptorReadRequest(GattReadRequest)]. Answer +/// promptly with [#respond(byte[])] or [#reject(GattStatus)] -- centrals +/// time out unanswered requests. +public abstract class GattReadRequest { + + private final BleCentral central; + private final GattLocalCharacteristic characteristic; + private final GattLocalDescriptor descriptor; + private final int offset; + + /// Constructed by ports; not application API. + protected GattReadRequest(BleCentral central, + GattLocalCharacteristic characteristic, + GattLocalDescriptor descriptor, int offset) { + this.central = central; + this.characteristic = characteristic; + this.descriptor = descriptor; + this.offset = offset; + } + + /// The central issuing the request. + public BleCentral getCentral() { + return central; + } + + /// The requested characteristic, or `null` for a descriptor read. + public GattLocalCharacteristic getCharacteristic() { + return characteristic; + } + + /// The requested descriptor, or `null` for a characteristic read. + public GattLocalDescriptor getDescriptor() { + return descriptor; + } + + /// The read offset for long reads; `0` for plain reads. + public int getOffset() { + return offset; + } + + /// Sends the value to the central. May be called from any thread; + /// call exactly once per request. + public abstract void respond(byte[] value); + + /// Rejects the request with the given ATT status. + public abstract void reject(GattStatus status); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattServer.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattServer.java new file mode 100644 index 00000000000..f31969cc5f1 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattServer.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.List; + +/// The local GATT server, opened via `BluetoothLE.openGattServer` when +/// this device acts as a BLE peripheral. Add [GattLocalService] +/// definitions, respond to central requests through the +/// [GattServerListener] and push value changes with +/// [#notifyValue(GattLocalCharacteristic, byte[])]. +/// +/// All listener callbacks are delivered on the EDT; the `fire*` methods +/// ports call are safe from any thread. +public abstract class GattServer { + + private final GattServerListener listener; + + /// Ports construct subclasses with the listener the application + /// passed to `openGattServer`. + protected GattServer(GattServerListener listener) { + this.listener = listener; + } + + /// Publishes a service definition. Resolves `true` once the platform + /// registered the service. + public final AsyncResource addService(GattLocalService service) { + final AsyncResource out = new AsyncResource(); + if (service == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "addService requires a service")); + return out; + } + try { + doAddService(service, out); + } catch (RuntimeException ex) { + if (!out.isDone()) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "addService failed: " + ex, ex)); + } + } + return out; + } + + /// Removes a previously added service. + public abstract void removeService(GattLocalService service); + + /// Shuts the server down and disconnects its centrals. + public abstract void close(); + + /// The centrals currently connected to this server. + public abstract List getConnectedCentrals(); + + /// Notifies every subscribed central of a new characteristic value. + /// Resolves once the controller accepted the notification(s). + public final AsyncResource notifyValue( + GattLocalCharacteristic characteristic, byte[] value) { + return notifyCentral(null, characteristic, value, false); + } + + /// Notifies one central -- or all subscribed centrals when `central` + /// is `null`. With `confirm` an *indication* is sent and the resource + /// resolves on the central's acknowledgement. + public final AsyncResource notifyCentral(BleCentral central, + GattLocalCharacteristic characteristic, byte[] value, + boolean confirm) { + final AsyncResource out = new AsyncResource(); + if (characteristic == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "notify requires a characteristic")); + return out; + } + try { + doNotify(central, characteristic, value, confirm, out); + } catch (RuntimeException ex) { + if (!out.isDone()) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "notify failed: " + ex, ex)); + } + } + return out; + } + + // ------------------------------------------------------------------ + // port SPI + // ------------------------------------------------------------------ + + /// Registers the service with the platform stack and completes `out`. + /// Ports must serialize consecutive service additions where the + /// platform requires it (Android). + protected abstract void doAddService(GattLocalService service, + AsyncResource out); + + /// Sends a notification/indication to `central` (or all subscribed + /// centrals when `null`) and completes `out`. Ports must serialize + /// notifications where the platform requires it (Android's + /// `onNotificationSent`). + protected abstract void doNotify(BleCentral central, + GattLocalCharacteristic characteristic, byte[] value, + boolean confirm, AsyncResource out); + + // ------------------------------------------------------------------ + // port event entry points -- safe to call from any thread + // ------------------------------------------------------------------ + + /// Routes a characteristic read request to the listener on the EDT. + protected final void fireCharacteristicReadRequest( + final GattReadRequest request) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.characteristicReadRequest(request); + } + }); + } + + /// Routes a characteristic write request to the listener on the EDT. + protected final void fireCharacteristicWriteRequest( + final GattWriteRequest request) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.characteristicWriteRequest(request); + } + }); + } + + /// Routes a descriptor read request to the listener on the EDT. + protected final void fireDescriptorReadRequest( + final GattReadRequest request) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.descriptorReadRequest(request); + } + }); + } + + /// Routes a descriptor write request to the listener on the EDT. + protected final void fireDescriptorWriteRequest( + final GattWriteRequest request) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.descriptorWriteRequest(request); + } + }); + } + + /// Reports a subscription change to the listener on the EDT. + protected final void fireSubscriptionChanged(final BleCentral central, + final GattLocalCharacteristic characteristic, + final boolean subscribed) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.subscriptionChanged(central, characteristic, + subscribed); + } + }); + } + + /// Reports a central connection to the listener on the EDT. + protected final void fireCentralConnected(final BleCentral central) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.centralConnected(central); + } + }); + } + + /// Reports a central disconnection to the listener on the EDT. + protected final void fireCentralDisconnected(final BleCentral central) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.centralDisconnected(central); + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattServerAdapter.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattServerAdapter.java new file mode 100644 index 00000000000..7b74c179208 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattServerAdapter.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.gatt.GattStatus; + +/// Convenience [GattServerListener] with empty implementations -- extend +/// it and override only the events you handle. Unanswered read/write +/// requests are rejected with [GattStatus#REQUEST_NOT_SUPPORTED] so +/// centrals fail fast instead of timing out. +public class GattServerAdapter implements GattServerListener { + + @Override + public void characteristicReadRequest(GattReadRequest request) { + request.reject(GattStatus.REQUEST_NOT_SUPPORTED); + } + + @Override + public void characteristicWriteRequest(GattWriteRequest request) { + if (request.isResponseRequired()) { + request.reject(GattStatus.REQUEST_NOT_SUPPORTED); + } + } + + @Override + public void descriptorReadRequest(GattReadRequest request) { + request.reject(GattStatus.REQUEST_NOT_SUPPORTED); + } + + @Override + public void descriptorWriteRequest(GattWriteRequest request) { + if (request.isResponseRequired()) { + request.reject(GattStatus.REQUEST_NOT_SUPPORTED); + } + } + + @Override + public void subscriptionChanged(BleCentral central, + GattLocalCharacteristic characteristic, boolean subscribed) { + } + + @Override + public void centralConnected(BleCentral central) { + } + + @Override + public void centralDisconnected(BleCentral central) { + } +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattServerListener.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattServerListener.java new file mode 100644 index 00000000000..ebc6175fa6d --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattServerListener.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +/// Receives the events of a local [GattServer]: requests from centrals, +/// subscription changes and central connections. All methods are invoked +/// on the EDT. Extend [GattServerAdapter] to override only the events you +/// care about. +public interface GattServerListener { + + /// A central reads a characteristic without a static value; answer + /// via [GattReadRequest#respond(byte[])] or + /// [GattReadRequest#reject]. + void characteristicReadRequest(GattReadRequest request); + + /// A central writes a characteristic; acknowledge via + /// [GattWriteRequest#respond()] when required. + void characteristicWriteRequest(GattWriteRequest request); + + /// A central reads a descriptor without a static value. Platform + /// note: iOS serves descriptors from their static values only, so + /// this never fires there -- always give local descriptors a static + /// value for cross-platform behavior. + void descriptorReadRequest(GattReadRequest request); + + /// A central writes a descriptor. Never fires on iOS -- see + /// [#descriptorReadRequest(GattReadRequest)]. + void descriptorWriteRequest(GattWriteRequest request); + + /// A central subscribed to or unsubscribed from a characteristic's + /// notifications. + void subscriptionChanged(BleCentral central, + GattLocalCharacteristic characteristic, boolean subscribed); + + /// A central connected to the local server. + void centralConnected(BleCentral central); + + /// A central disconnected from the local server. + void centralDisconnected(BleCentral central); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/GattWriteRequest.java b/CodenameOne/src/com/codename1/bluetooth/le/server/GattWriteRequest.java new file mode 100644 index 00000000000..ecb5c3bacea --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/GattWriteRequest.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +import com.codename1.bluetooth.gatt.GattStatus; + +/// A write request from a connected central, delivered to +/// [GattServerListener#characteristicWriteRequest(GattWriteRequest)] or +/// [GattServerListener#descriptorWriteRequest(GattWriteRequest)]. When +/// [#isResponseRequired()] answer promptly with [#respond()] or +/// [#reject(GattStatus)]. +public abstract class GattWriteRequest { + + private final BleCentral central; + private final GattLocalCharacteristic characteristic; + private final GattLocalDescriptor descriptor; + private final byte[] value; + private final int offset; + private final boolean responseRequired; + + /// Constructed by ports; not application API. + protected GattWriteRequest(BleCentral central, + GattLocalCharacteristic characteristic, + GattLocalDescriptor descriptor, byte[] value, int offset, + boolean responseRequired) { + this.central = central; + this.characteristic = characteristic; + this.descriptor = descriptor; + this.value = value; + this.offset = offset; + this.responseRequired = responseRequired; + } + + /// The central issuing the request. + public BleCentral getCentral() { + return central; + } + + /// The written characteristic, or `null` for a descriptor write. + public GattLocalCharacteristic getCharacteristic() { + return characteristic; + } + + /// The written descriptor, or `null` for a characteristic write. + public GattLocalDescriptor getDescriptor() { + return descriptor; + } + + /// The written bytes. + public byte[] getValue() { + return value; + } + + /// The write offset for long writes; `0` for plain writes. + public int getOffset() { + return offset; + } + + /// `true` when the central expects an acknowledgement ([#respond()] or + /// [#reject(GattStatus)]); `false` for write-without-response. + public boolean isResponseRequired() { + return responseRequired; + } + + /// Acknowledges the write. May be called from any thread; call + /// exactly once when [#isResponseRequired()]. + public abstract void respond(); + + /// Rejects the write with the given ATT status. + public abstract void reject(GattStatus status); +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/TxPowerLevel.java b/CodenameOne/src/com/codename1/bluetooth/le/server/TxPowerLevel.java new file mode 100644 index 00000000000..8172fd98b0f --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/TxPowerLevel.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2012, 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.bluetooth.le.server; + +/// Transmit power for advertising, trading range against battery. +public enum TxPowerLevel { + /// Minimal power -- shortest range. + ULTRA_LOW, + + /// Low power. + LOW, + + /// The default medium power. + MEDIUM, + + /// Maximum power -- longest range. + HIGH +} diff --git a/CodenameOne/src/com/codename1/bluetooth/le/server/package-info.java b/CodenameOne/src/com/codename1/bluetooth/le/server/package-info.java new file mode 100644 index 00000000000..008e478bd4d --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/le/server/package-info.java @@ -0,0 +1,35 @@ +/* + * 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. + */ +/// BLE peripheral role: the local GATT server (`GattServer`, +/// `GattLocalService`, `GattLocalCharacteristic`, request envelopes) and +/// advertising (`AdvertiseSettings`, `AdvertiseData`, +/// `BleAdvertisement`). +/// +/// Obtain via `Bluetooth.getInstance().getLE().openGattServer(...)` and +/// `startAdvertising(...)`; branch on +/// `Bluetooth.getInstance().isPeripheralModeSupported()` first. +/// +/// Referencing this package is what triggers the automatic injection of +/// advertise permissions (Android `BLUETOOTH_ADVERTISE`) at build time -- +/// central-only apps that never touch it are not burdened with them. +package com.codename1.bluetooth.le.server; diff --git a/CodenameOne/src/com/codename1/bluetooth/package-info.java b/CodenameOne/src/com/codename1/bluetooth/package-info.java new file mode 100644 index 00000000000..29f02813ba0 --- /dev/null +++ b/CodenameOne/src/com/codename1/bluetooth/package-info.java @@ -0,0 +1,37 @@ +/* + * 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. + */ +/// Cross-platform Bluetooth API: adapter state, runtime permissions and +/// capability queries. +/// +/// `Bluetooth.getInstance()` returns the platform implementation and is +/// the single entry point; from it, `getLE()` reaches the BLE central +/// role (`com.codename1.bluetooth.le`), `getLE().openGattServer(...)` / +/// `startAdvertising(...)` the peripheral role +/// (`com.codename1.bluetooth.le.server`) and `getClassic()` classic +/// RFCOMM (`com.codename1.bluetooth.classic`). Identity types +/// (`BluetoothDevice`, `BluetoothUuid`) and the typed error model +/// (`BluetoothError`, `BluetoothException`) live here. +/// +/// Every callback of the API is delivered on the EDT; only the blocking +/// RFCOMM/L2CAP streams are consumed off it. +package com.codename1.bluetooth; diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 57b09139e56..b622d799a3b 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -7233,6 +7233,17 @@ public com.codename1.nfc.Nfc getNfc() { return null; } + /// Returns the port-specific Bluetooth entry point. Default + /// implementation returns {@code null}; ports that implement + /// {@link com.codename1.bluetooth.Bluetooth} override this to return a + /// cached singleton. Application code should use + /// {@link com.codename1.bluetooth.Bluetooth#getInstance()} instead of + /// calling this directly --- it transparently substitutes a no-op + /// fallback when the port returns {@code null}. + public com.codename1.bluetooth.Bluetooth getBluetooth() { + return null; + } + /// Allows buggy implementations (Android) to release image objects /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index ba32c684692..e7aff233b2d 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4796,6 +4796,14 @@ public com.codename1.nfc.Nfc getNfc() { return impl.getNfc(); } + /// Returns the platform Bluetooth entry point. Prefer + /// {@link com.codename1.bluetooth.Bluetooth#getInstance()} in + /// application code --- it handles the fallback to a no-op stub when + /// the current port does not implement Bluetooth. + public com.codename1.bluetooth.Bluetooth getBluetooth() { + return impl.getBluetooth(); + } + /// This method tries to invoke the device native camera to capture images. /// The method returns immediately and the response will be sent asynchronously /// to the given ActionListener Object diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBlePeripheral.java b/Ports/Android/src/com/codename1/impl/android/AndroidBlePeripheral.java new file mode 100644 index 00000000000..e54207d0611 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBlePeripheral.java @@ -0,0 +1,854 @@ +/* + * Copyright (c) 2012, 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.impl.android; + +import android.content.Context; +import android.os.Build; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BondState; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionOptions; +import com.codename1.bluetooth.le.ConnectionPriority; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Android implementation of {@link BlePeripheral} on top of + * {@code BluetoothGatt}. The core class serializes the {@code do*} SPI + * calls one-at-a-time per peripheral, so each operation kind keeps a single + * pending {@code AsyncResource} slot that the matching + * {@code BluetoothGattCallback} method completes. All platform callbacks + * arrive on binder threads; the core fire methods and AsyncResource + * plumbing perform the EDT dispatch. + */ +class AndroidBlePeripheral extends BlePeripheral { + + private final android.bluetooth.BluetoothDevice device; + private volatile android.bluetooth.BluetoothGatt gatt; + + private final Object lock = new Object(); + private AsyncResource pendingConnect; + private AsyncResource> pendingDiscover; + private AsyncResource pendingCharRead; + private AsyncResource pendingCharWrite; + private AsyncResource pendingDescRead; + private AsyncResource pendingDescWrite; + private AsyncResource pendingRssi; + private AsyncResource pendingMtu; + + /** + * Maps between the platform GATT database and the canonical core model + * built during the last service discovery. fireNotification must be + * handed the canonical GattCharacteristic instance, and the do* methods + * must resolve the platform object backing a canonical one. Neither + * class overrides equals, so plain HashMaps give identity semantics. + */ + private final Object mapLock = new Object(); + private final HashMap + charToCore = + new HashMap(); + private final HashMap + charToPlatform = + new HashMap(); + private final HashMap + descToPlatform = + new HashMap(); + + AndroidBlePeripheral(android.bluetooth.BluetoothDevice device) { + this.device = device; + } + + android.bluetooth.BluetoothDevice getPlatformDevice() { + return device; + } + + // ------------------------------------------------------------------ + // identity + // ------------------------------------------------------------------ + + @Override + public String getAddress() { + return device.getAddress(); + } + + @Override + public String getName() { + try { + return device.getName(); + } catch (SecurityException se) { + return null; + } + } + + @Override + public DeviceType getType() { + try { + return AndroidBluetooth.mapDeviceType(device.getType()); + } catch (SecurityException se) { + return DeviceType.UNKNOWN; + } + } + + @Override + public BondState getBondState() { + try { + return AndroidBluetooth.mapBondState(device.getBondState()); + } catch (SecurityException se) { + return BondState.NONE; + } + } + + // ------------------------------------------------------------------ + // connection lifecycle + // ------------------------------------------------------------------ + + @Override + protected void doConnect(ConnectionOptions options, + AsyncResource out) { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) { + throw new RuntimeException("No Android context available"); + } + synchronized (lock) { + pendingConnect = out; + } + android.bluetooth.BluetoothGatt g; + try { + if (Build.VERSION.SDK_INT >= 23) { + g = device.connectGatt(ctx, options.isAutoConnect(), callback, + android.bluetooth.BluetoothDevice.TRANSPORT_LE); + } else { + g = device.connectGatt(ctx, options.isAutoConnect(), callback); + } + } catch (SecurityException se) { + synchronized (lock) { + pendingConnect = null; + } + out.error(new BluetoothException(BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + return; + } + if (g == null) { + synchronized (lock) { + pendingConnect = null; + } + throw new RuntimeException("connectGatt returned null"); + } + gatt = g; + } + + @Override + protected void doDisconnect() { + android.bluetooth.BluetoothGatt g = gatt; + if (g != null) { + try { + g.disconnect(); + } catch (Throwable ignore) { + } + } + } + + /** Always close the BluetoothGatt after the disconnect callback -- a + * leaked client eventually exhausts the per-device GATT interfaces. */ + private void closeGatt() { + android.bluetooth.BluetoothGatt g; + synchronized (lock) { + g = gatt; + gatt = null; + } + if (g != null) { + try { + g.close(); + } catch (Throwable ignore) { + } + } + } + + private android.bluetooth.BluetoothGatt requireGatt(AsyncResource out) { + android.bluetooth.BluetoothGatt g = gatt; + if (g == null && !out.isDone()) { + out.error(new BluetoothException(BluetoothError.NOT_CONNECTED, + "Peripheral is not connected")); + } + return g; + } + + // ------------------------------------------------------------------ + // GATT client SPI + // ------------------------------------------------------------------ + + @Override + protected void doDiscoverServices(AsyncResource> out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + synchronized (lock) { + pendingDiscover = out; + } + boolean started; + try { + started = g.discoverServices(); + } catch (SecurityException se) { + started = false; + } + if (!started) { + synchronized (lock) { + pendingDiscover = null; + } + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Failed to start service discovery")); + } + } + + @Override + protected void doReadCharacteristic(GattCharacteristic c, + AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + android.bluetooth.BluetoothGattCharacteristic pc = platformChar(c); + if (pc == null) { + out.error(staleCharacteristic()); + return; + } + synchronized (lock) { + pendingCharRead = out; + } + boolean started; + try { + started = g.readCharacteristic(pc); + } catch (SecurityException se) { + started = false; + } + if (!started) { + synchronized (lock) { + pendingCharRead = null; + } + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Failed to start characteristic read")); + } + } + + @Override + protected void doWriteCharacteristic(GattCharacteristic c, byte[] value, + boolean withResponse, AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + android.bluetooth.BluetoothGattCharacteristic pc = platformChar(c); + if (pc == null) { + out.error(staleCharacteristic()); + return; + } + pc.setWriteType(withResponse + ? android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + : android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); + pc.setValue(value == null ? new byte[0] : value); + synchronized (lock) { + pendingCharWrite = out; + } + boolean started; + try { + started = g.writeCharacteristic(pc); + } catch (SecurityException se) { + started = false; + } + if (!started) { + synchronized (lock) { + pendingCharWrite = null; + } + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Failed to start characteristic write")); + } + } + + @Override + protected void doReadDescriptor(GattDescriptor d, + AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + android.bluetooth.BluetoothGattDescriptor pd = platformDesc(d); + if (pd == null) { + out.error(staleCharacteristic()); + return; + } + synchronized (lock) { + pendingDescRead = out; + } + boolean started; + try { + started = g.readDescriptor(pd); + } catch (SecurityException se) { + started = false; + } + if (!started) { + synchronized (lock) { + pendingDescRead = null; + } + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Failed to start descriptor read")); + } + } + + @Override + protected void doWriteDescriptor(GattDescriptor d, byte[] value, + AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + android.bluetooth.BluetoothGattDescriptor pd = platformDesc(d); + if (pd == null) { + out.error(staleCharacteristic()); + return; + } + pd.setValue(value == null ? new byte[0] : value); + startDescriptorWrite(g, pd, out); + } + + @Override + protected void doSetNotifications(GattCharacteristic c, boolean enable, + boolean indication, AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + android.bluetooth.BluetoothGattCharacteristic pc = platformChar(c); + if (pc == null) { + out.error(staleCharacteristic()); + return; + } + boolean armed; + try { + armed = g.setCharacteristicNotification(pc, enable); + } catch (SecurityException se) { + armed = false; + } + if (!armed) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "setCharacteristicNotification failed")); + return; + } + android.bluetooth.BluetoothGattDescriptor cccd = + pc.getDescriptor(AndroidBluetooth.CCCD_UUID); + if (cccd == null) { + // no CCCD on this characteristic -- the local arm is all there is + out.complete(Boolean.TRUE); + return; + } + byte[] v; + if (!enable) { + v = android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE; + } else if (indication) { + v = android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + v = android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } + cccd.setValue(v); + startDescriptorWrite(g, cccd, out); + } + + /** Shared by doWriteDescriptor and the CCCD write of + * doSetNotifications -- only one queued operation is in flight, so a + * single pending slot serves both. The AsyncResource completes in + * onDescriptorWrite. */ + private void startDescriptorWrite(android.bluetooth.BluetoothGatt g, + android.bluetooth.BluetoothGattDescriptor pd, + AsyncResource out) { + synchronized (lock) { + pendingDescWrite = out; + } + boolean started; + try { + started = g.writeDescriptor(pd); + } catch (SecurityException se) { + started = false; + } + if (!started) { + synchronized (lock) { + pendingDescWrite = null; + } + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Failed to start descriptor write")); + } + } + + @Override + protected void doReadRssi(AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + synchronized (lock) { + pendingRssi = out; + } + boolean started; + try { + started = g.readRemoteRssi(); + } catch (SecurityException se) { + started = false; + } + if (!started) { + synchronized (lock) { + pendingRssi = null; + } + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Failed to start RSSI read")); + } + } + + @Override + protected void doRequestMtu(int mtu, AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + if (Build.VERSION.SDK_INT < 21) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "MTU negotiation requires Android 5 (API 21)")); + return; + } + synchronized (lock) { + pendingMtu = out; + } + boolean started; + try { + started = g.requestMtu(mtu); + } catch (SecurityException se) { + started = false; + } + if (!started) { + synchronized (lock) { + pendingMtu = null; + } + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Failed to start MTU negotiation")); + } + } + + @Override + protected void doRequestConnectionPriority(ConnectionPriority priority, + AsyncResource out) { + android.bluetooth.BluetoothGatt g = requireGatt(out); + if (g == null) { + return; + } + if (Build.VERSION.SDK_INT < 21) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Connection priority requires Android 5 (API 21)")); + return; + } + int v; + if (priority == ConnectionPriority.HIGH) { + v = android.bluetooth.BluetoothGatt.CONNECTION_PRIORITY_HIGH; + } else if (priority == ConnectionPriority.LOW_POWER) { + v = android.bluetooth.BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER; + } else { + v = android.bluetooth.BluetoothGatt.CONNECTION_PRIORITY_BALANCED; + } + boolean ok; + try { + // Android has no public completion callback for this request; + // resolve with the submission result. + ok = g.requestConnectionPriority(v); + } catch (SecurityException se) { + ok = false; + } + out.complete(ok ? Boolean.TRUE : Boolean.FALSE); + } + + @Override + protected void doCreateBond(AsyncResource out) { + AndroidBluetooth.createBondImpl(device, out); + } + + @Override + protected void doOpenL2cap(final int psm, final boolean secure, + final AsyncResource out) { + if (!AndroidL2capCompat.isSupported()) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "L2CAP channels require Android 10 (API 29) or newer")); + return; + } + Thread t = new Thread(new Runnable() { + public void run() { + android.bluetooth.BluetoothSocket socket = null; + try { + socket = AndroidL2capCompat.openChannel(device, psm, + secure); + socket.connect(); + out.complete(new AndroidL2capChannel(psm, socket)); + } catch (SecurityException se) { + closeQuietly(socket); + out.error(new BluetoothException( + BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + } catch (IOException ioe) { + closeQuietly(socket); + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "L2CAP open failed: " + ioe.getMessage(), ioe)); + } catch (Throwable ex) { + closeQuietly(socket); + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "L2CAP open failed: " + ex, ex)); + } + } + }, "CN1-L2CAP-connect"); + t.setDaemon(true); + t.start(); + } + + private static void closeQuietly(android.bluetooth.BluetoothSocket s) { + if (s != null) { + try { + s.close(); + } catch (Throwable ignore) { + } + } + } + + // ------------------------------------------------------------------ + // model mapping + // ------------------------------------------------------------------ + + private android.bluetooth.BluetoothGattCharacteristic platformChar( + GattCharacteristic c) { + synchronized (mapLock) { + return charToPlatform.get(c); + } + } + + private android.bluetooth.BluetoothGattDescriptor platformDesc( + GattDescriptor d) { + synchronized (mapLock) { + return descToPlatform.get(d); + } + } + + private static BluetoothException staleCharacteristic() { + return new BluetoothException(BluetoothError.GATT_ERROR, + "Unknown attribute -- use instances from the last " + + "discoverServices() result"); + } + + /** Rebuilds the canonical core model from the platform GATT database + * after a successful discovery. */ + private List buildServiceModel( + android.bluetooth.BluetoothGatt g) { + ArrayList result = new ArrayList(); + List platformServices = + g.getServices(); + HashMap svcMap = + new HashMap(); + synchronized (mapLock) { + charToCore.clear(); + charToPlatform.clear(); + descToPlatform.clear(); + for (android.bluetooth.BluetoothGattService ps : platformServices) { + GattService s = new GattService(this, + AndroidBluetooth.toCn1Uuid(ps.getUuid()), + ps.getType() == android.bluetooth.BluetoothGattService.SERVICE_TYPE_PRIMARY, + ps.getInstanceId()); + for (android.bluetooth.BluetoothGattCharacteristic pc + : ps.getCharacteristics()) { + // the core PROPERTY_* bits mirror the Bluetooth spec and + // therefore Android's values -- pass through verbatim + GattCharacteristic c = new GattCharacteristic(s, + AndroidBluetooth.toCn1Uuid(pc.getUuid()), + pc.getProperties(), pc.getInstanceId()); + for (android.bluetooth.BluetoothGattDescriptor pd + : pc.getDescriptors()) { + GattDescriptor d = new GattDescriptor(c, + AndroidBluetooth.toCn1Uuid(pd.getUuid())); + c.addDescriptor(d); + descToPlatform.put(d, pd); + } + s.addCharacteristic(c); + charToCore.put(pc, c); + charToPlatform.put(c, pc); + } + svcMap.put(ps, s); + result.add(s); + } + // second pass: link included services to the canonical instances + for (android.bluetooth.BluetoothGattService ps : platformServices) { + GattService s = svcMap.get(ps); + for (android.bluetooth.BluetoothGattService inc + : ps.getIncludedServices()) { + GattService coreInc = svcMap.get(inc); + if (coreInc != null) { + s.addIncludedService(coreInc); + } + } + } + } + return result; + } + + // ------------------------------------------------------------------ + // platform callback -- binder threads + // ------------------------------------------------------------------ + + private final android.bluetooth.BluetoothGattCallback callback = + new android.bluetooth.BluetoothGattCallback() { + + @Override + public void onConnectionStateChange( + android.bluetooth.BluetoothGatt g, int status, int newState) { + if (newState == android.bluetooth.BluetoothProfile.STATE_CONNECTED) { + AsyncResource pc; + synchronized (lock) { + pc = pendingConnect; + pendingConnect = null; + } + if (pc != null) { + if (pc.isDone()) { + // the core already timed out / cancelled this + // attempt -- tear the late connection down + try { + g.disconnect(); + } catch (Throwable ignore) { + } + return; + } + pc.complete(AndroidBlePeripheral.this); + } else { + // autoConnect reconnection outside a pending connect() + fireConnectionStateChanged(ConnectionState.CONNECTED, + null); + } + } else if (newState + == android.bluetooth.BluetoothProfile.STATE_DISCONNECTED) { + closeGatt(); + AsyncResource pc; + synchronized (lock) { + pc = pendingConnect; + pendingConnect = null; + } + if (pc != null && !pc.isDone()) { + pc.error(new BluetoothException( + BluetoothError.CONNECTION_FAILED, + "Connection attempt failed (status " + status + + ")", status)); + } else { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, + status == android.bluetooth.BluetoothGatt.GATT_SUCCESS + ? null + : new BluetoothException( + BluetoothError.CONNECTION_LOST, + "Connection lost (status " + + status + ")", status)); + } + } + } + + @Override + public void onServicesDiscovered(android.bluetooth.BluetoothGatt g, + int status) { + AsyncResource> out; + synchronized (lock) { + out = pendingDiscover; + pendingDiscover = null; + } + if (out == null || out.isDone()) { + return; + } + if (status != android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Service discovery failed (status " + status + ")", + status)); + return; + } + out.complete(buildServiceModel(g)); + } + + @Override + public void onCharacteristicRead(android.bluetooth.BluetoothGatt g, + android.bluetooth.BluetoothGattCharacteristic pc, + int status) { + // copy before anything else -- Android reuses the buffer + byte[] copied = copyValue(pc.getValue()); + AsyncResource out; + synchronized (lock) { + out = pendingCharRead; + pendingCharRead = null; + } + if (out == null || out.isDone()) { + return; + } + if (status != android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Characteristic read failed (status " + status + ")", + status)); + return; + } + out.complete(copied); + } + + @Override + public void onCharacteristicWrite(android.bluetooth.BluetoothGatt g, + android.bluetooth.BluetoothGattCharacteristic pc, + int status) { + AsyncResource out; + synchronized (lock) { + out = pendingCharWrite; + pendingCharWrite = null; + } + if (out == null || out.isDone()) { + return; + } + if (status != android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Characteristic write failed (status " + status + ")", + status)); + return; + } + out.complete(Boolean.TRUE); + } + + @Override + public void onDescriptorRead(android.bluetooth.BluetoothGatt g, + android.bluetooth.BluetoothGattDescriptor pd, int status) { + byte[] copied = copyValue(pd.getValue()); + AsyncResource out; + synchronized (lock) { + out = pendingDescRead; + pendingDescRead = null; + } + if (out == null || out.isDone()) { + return; + } + if (status != android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Descriptor read failed (status " + status + ")", + status)); + return; + } + out.complete(copied); + } + + @Override + public void onDescriptorWrite(android.bluetooth.BluetoothGatt g, + android.bluetooth.BluetoothGattDescriptor pd, int status) { + AsyncResource out; + synchronized (lock) { + out = pendingDescWrite; + pendingDescWrite = null; + } + if (out == null || out.isDone()) { + return; + } + if (status != android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "Descriptor write failed (status " + status + ")", + status)); + return; + } + out.complete(Boolean.TRUE); + } + + @Override + public void onCharacteristicChanged( + android.bluetooth.BluetoothGatt g, + android.bluetooth.BluetoothGattCharacteristic pc) { + // Android reuses the value buffer -- copy IMMEDIATELY, before + // any dispatch hop + byte[] copied = copyValue(pc.getValue()); + GattCharacteristic core; + synchronized (mapLock) { + core = charToCore.get(pc); + } + if (core != null) { + fireNotification(core, copied); + } + } + + @Override + public void onReadRemoteRssi(android.bluetooth.BluetoothGatt g, + int rssi, int status) { + AsyncResource out; + synchronized (lock) { + out = pendingRssi; + pendingRssi = null; + } + if (out == null || out.isDone()) { + return; + } + if (status != android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "RSSI read failed (status " + status + ")", status)); + return; + } + out.complete(Integer.valueOf(rssi)); + } + + @Override + public void onMtuChanged(android.bluetooth.BluetoothGatt g, int mtu, + int status) { + AsyncResource out; + synchronized (lock) { + out = pendingMtu; + pendingMtu = null; + } + if (status == android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + if (out != null && !out.isDone()) { + // the core records the granted value from the result + out.complete(Integer.valueOf(mtu)); + } else { + // peer-initiated MTU change + setMtu(mtu); + } + } else if (out != null && !out.isDone()) { + out.error(new BluetoothException(BluetoothError.GATT_ERROR, + "MTU negotiation failed (status " + status + ")", + status)); + } + } + }; + + private static byte[] copyValue(byte[] raw) { + if (raw == null) { + return new byte[0]; + } + byte[] copy = new byte[raw.length]; + System.arraycopy(raw, 0, copy, 0, raw.length); + return copy; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBluetooth.java b/Ports/Android/src/com/codename1/impl/android/AndroidBluetooth.java new file mode 100644 index 00000000000..bd38d160336 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBluetooth.java @@ -0,0 +1,1030 @@ +/* + * Copyright (c) 2012, 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.impl.android; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.os.Build; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothPermission; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.BondState; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.bluetooth.le.ScanMode; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseMode; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.bluetooth.le.server.TxPowerLevel; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Android implementation of {@link com.codename1.bluetooth.Bluetooth}: + * capability queries, adapter state (with an ACTION_STATE_CHANGED + * broadcast receiver feeding fireAdapterStateChanged), the runtime + * permission mapping and the enable-request system dialog. The LE and + * classic role objects live in {@link AndroidBluetoothLE} and + * {@link AndroidRfcomm}. + * + * Android Bluetooth classes are referenced fully qualified throughout the + * Bluetooth port files because their simple names collide with the core + * API's (BluetoothDevice, ScanResult, AdvertiseData, ...). + */ +class AndroidBluetooth extends com.codename1.bluetooth.Bluetooth { + + /** Android 12 runtime permissions -- string literals because the port + * compiles against the API 27 android.jar (same precedent as + * POST_NOTIFICATIONS in AndroidImplementation). */ + static final String PERMISSION_SCAN = "android.permission.BLUETOOTH_SCAN"; + static final String PERMISSION_CONNECT = + "android.permission.BLUETOOTH_CONNECT"; + static final String PERMISSION_ADVERTISE = + "android.permission.BLUETOOTH_ADVERTISE"; + + /** The Client Characteristic Configuration descriptor. */ + static final java.util.UUID CCCD_UUID = + toPlatformUuid(BluetoothUuid.CCCD); + + private AndroidBluetoothLE le; + private AndroidRfcomm classic; + + AndroidBluetooth() { + registerAdapterStateReceiver(); + } + + // ------------------------------------------------------------------ + // shared plumbing used by the other Bluetooth port classes + // ------------------------------------------------------------------ + + static android.bluetooth.BluetoothAdapter adapter() { + try { + return android.bluetooth.BluetoothAdapter.getDefaultAdapter(); + } catch (Throwable t) { + return null; + } + } + + static android.bluetooth.BluetoothManager manager() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) { + return null; + } + try { + return (android.bluetooth.BluetoothManager) ctx + .getSystemService(Context.BLUETOOTH_SERVICE); + } catch (Throwable t) { + return null; + } + } + + static BluetoothUuid toCn1Uuid(java.util.UUID uuid) { + return new BluetoothUuid(uuid.getMostSignificantBits(), + uuid.getLeastSignificantBits()); + } + + static java.util.UUID toPlatformUuid(BluetoothUuid uuid) { + return new java.util.UUID(uuid.getMostSignificantBits(), + uuid.getLeastSignificantBits()); + } + + static DeviceType mapDeviceType(int platformType) { + switch (platformType) { + case android.bluetooth.BluetoothDevice.DEVICE_TYPE_CLASSIC: + return DeviceType.CLASSIC; + case android.bluetooth.BluetoothDevice.DEVICE_TYPE_LE: + return DeviceType.LE; + case android.bluetooth.BluetoothDevice.DEVICE_TYPE_DUAL: + return DeviceType.DUAL; + default: + return DeviceType.UNKNOWN; + } + } + + static BondState mapBondState(int platformState) { + switch (platformState) { + case android.bluetooth.BluetoothDevice.BOND_BONDING: + return BondState.BONDING; + case android.bluetooth.BluetoothDevice.BOND_BONDED: + return BondState.BONDED; + default: + return BondState.NONE; + } + } + + /** + * Registers a receiver for a protected system broadcast. On API 33+ + * the 3-argument registerReceiver overload is invoked reflectively + * with RECEIVER_EXPORTED (0x2) because the constant/overload is absent + * from the API 27 android.jar the port compiles against. + */ + static void registerSystemReceiver(Context appCtx, + BroadcastReceiver receiver, IntentFilter filter) { + boolean registered = false; + if (Build.VERSION.SDK_INT >= 33) { + try { + java.lang.reflect.Method m = Context.class.getMethod( + "registerReceiver", BroadcastReceiver.class, + IntentFilter.class, int.class); + m.invoke(appCtx, receiver, filter, Integer.valueOf(0x2)); + registered = true; + } catch (Throwable ignore) { + } + } + if (!registered) { + appCtx.registerReceiver(receiver, filter); + } + } + + /** + * Shared bonding flow: watches ACTION_BOND_STATE_CHANGED for the given + * device and resolves the resource when the bond state settles. Used by + * both the LE peripheral and the classic role. + */ + static void createBondImpl(final android.bluetooth.BluetoothDevice device, + final AsyncResource out) { + if (device == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "createBond requires a device")); + return; + } + try { + if (device.getBondState() + == android.bluetooth.BluetoothDevice.BOND_BONDED) { + out.complete(Boolean.TRUE); + return; + } + } catch (SecurityException se) { + out.error(new BluetoothException(BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + return; + } + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "No Android context available")); + return; + } + final Context appCtx = ctx.getApplicationContext() != null + ? ctx.getApplicationContext() : ctx; + final String address = device.getAddress(); + final BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent intent) { + android.bluetooth.BluetoothDevice d = + (android.bluetooth.BluetoothDevice) intent + .getParcelableExtra( + android.bluetooth.BluetoothDevice.EXTRA_DEVICE); + if (d == null || !address.equals(d.getAddress())) { + return; + } + int state = intent.getIntExtra( + android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE, + -1); + if (state + == android.bluetooth.BluetoothDevice.BOND_BONDED) { + try { + appCtx.unregisterReceiver(this); + } catch (Throwable ignore) { + } + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + } else if (state + == android.bluetooth.BluetoothDevice.BOND_NONE) { + try { + appCtx.unregisterReceiver(this); + } catch (Throwable ignore) { + } + if (!out.isDone()) { + out.error(new BluetoothException( + BluetoothError.BOND_FAILED, + "Bonding failed or was rejected")); + } + } + } + }; + registerSystemReceiver(appCtx, receiver, new IntentFilter( + android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED)); + boolean started; + try { + started = device.createBond(); + } catch (SecurityException se) { + try { + appCtx.unregisterReceiver(receiver); + } catch (Throwable ignore) { + } + out.error(new BluetoothException(BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + return; + } + if (!started) { + try { + appCtx.unregisterReceiver(receiver); + } catch (Throwable ignore) { + } + out.error(new BluetoothException(BluetoothError.BOND_FAILED, + "The platform could not start bonding")); + } + } + + // ------------------------------------------------------------------ + // capabilities + // ------------------------------------------------------------------ + + @Override + public boolean isSupported() { + return adapter() != null; + } + + @Override + public boolean isLeSupported() { + if (adapter() == null || Build.VERSION.SDK_INT < 21) { + return false; + } + Context ctx = AndroidImplementation.getContext(); + return ctx != null && ctx.getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE); + } + + @Override + public boolean isClassicSupported() { + return isSupported(); + } + + @Override + public boolean isPeripheralModeSupported() { + return isLeSupported(); + } + + @Override + public boolean isL2capSupported() { + return isLeSupported() && AndroidL2capCompat.isSupported(); + } + + // ------------------------------------------------------------------ + // adapter state + // ------------------------------------------------------------------ + + @Override + public AdapterState getAdapterState() { + android.bluetooth.BluetoothAdapter a = adapter(); + if (a == null) { + return AdapterState.UNSUPPORTED; + } + try { + return mapAdapterState(a.getState()); + } catch (SecurityException se) { + return AdapterState.UNAUTHORIZED; + } + } + + static AdapterState mapAdapterState(int platformState) { + switch (platformState) { + case android.bluetooth.BluetoothAdapter.STATE_ON: + return AdapterState.POWERED_ON; + case android.bluetooth.BluetoothAdapter.STATE_OFF: + return AdapterState.POWERED_OFF; + case android.bluetooth.BluetoothAdapter.STATE_TURNING_ON: + return AdapterState.TURNING_ON; + case android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF: + return AdapterState.TURNING_OFF; + default: + return AdapterState.UNKNOWN; + } + } + + private void registerAdapterStateReceiver() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null || adapter() == null) { + return; + } + Context appCtx = ctx.getApplicationContext() != null + ? ctx.getApplicationContext() : ctx; + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent intent) { + int state = intent.getIntExtra( + android.bluetooth.BluetoothAdapter.EXTRA_STATE, -1); + fireAdapterStateChanged(mapAdapterState(state)); + } + }; + try { + registerSystemReceiver(appCtx, receiver, new IntentFilter( + android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED)); + } catch (Throwable ignore) { + } + } + + @Override + public AsyncResource requestEnable() { + final AsyncResource out = new AsyncResource(); + android.bluetooth.BluetoothAdapter a = adapter(); + if (a == null) { + out.complete(Boolean.FALSE); + return out; + } + if (a.isEnabled()) { + out.complete(Boolean.TRUE); + return out; + } + if (AndroidImplementation.getActivity() == null) { + // the system dialog needs a foreground activity + out.complete(Boolean.FALSE); + return out; + } + Display.getInstance().callSerially(makeRequestEnableRunnable(out)); + return out; + } + + // Static so the Runnable doesn't carry a synthetic outer-AndroidBluetooth + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static Runnable makeRequestEnableRunnable( + final AsyncResource out) { + return new Runnable() { + public void run() { + if (Build.VERSION.SDK_INT >= 31 + && !AndroidImplementation.checkForPermission( + PERMISSION_CONNECT, + "This is required to turn on Bluetooth")) { + out.complete(Boolean.FALSE); + return; + } + try { + AndroidNativeUtil.startActivityForResult(new Intent( + android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE), + new IntentResultListener() { + public void onActivityResult(int requestCode, + int resultCode, Intent data) { + out.complete(resultCode == Activity.RESULT_OK + ? Boolean.TRUE : Boolean.FALSE); + } + }); + } catch (RuntimeException ex) { + out.complete(Boolean.FALSE); + } + } + }; + } + + // ------------------------------------------------------------------ + // permissions + // ------------------------------------------------------------------ + + /** + * Maps a portable permission to the runtime permissions the current + * Android version actually requires: the BLUETOOTH_* runtime trio on + * 12+ (API 31), fine location for scanning on 6-11 (API 23-30), and + * nothing below that (install-time manifest permissions only). + */ + static String[] runtimePermissions(BluetoothPermission p) { + if (Build.VERSION.SDK_INT >= 31) { + switch (p) { + case SCAN: + return new String[]{PERMISSION_SCAN}; + case CONNECT: + return new String[]{PERMISSION_CONNECT}; + case ADVERTISE: + return new String[]{PERMISSION_ADVERTISE}; + default: + return new String[0]; + } + } + if (Build.VERSION.SDK_INT >= 23 && p == BluetoothPermission.SCAN) { + return new String[]{ + android.Manifest.permission.ACCESS_FINE_LOCATION}; + } + return new String[0]; + } + + @Override + public boolean hasPermission(BluetoothPermission permission) { + if (!isSupported() || permission == null) { + return false; + } + if (Build.VERSION.SDK_INT < 23) { + return true; + } + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) { + return false; + } + String[] perms = runtimePermissions(permission); + for (int i = 0; i < perms.length; i++) { + if (android.support.v4.content.ContextCompat.checkSelfPermission( + ctx, perms[i]) != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + @Override + public AsyncResource requestPermissions( + BluetoothPermission... permissions) { + final AsyncResource out = new AsyncResource(); + if (!isSupported()) { + out.complete(Boolean.FALSE); + return out; + } + final ArrayList perms = new ArrayList(); + if (permissions != null) { + for (int i = 0; i < permissions.length; i++) { + if (permissions[i] == null) { + continue; + } + String[] mapped = runtimePermissions(permissions[i]); + for (int j = 0; j < mapped.length; j++) { + if (!perms.contains(mapped[j])) { + perms.add(mapped[j]); + } + } + } + } + if (perms.isEmpty()) { + out.complete(Boolean.TRUE); + return out; + } + // checkForPermission blocks via invokeAndBlock and must run on the + // EDT + Display.getInstance().callSerially( + makeRequestPermissionsRunnable(perms, out)); + return out; + } + + // Static so the Runnable doesn't carry a synthetic outer-AndroidBluetooth + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static Runnable makeRequestPermissionsRunnable( + final ArrayList perms, final AsyncResource out) { + return new Runnable() { + public void run() { + boolean all = true; + int size = perms.size(); + for (int i = 0; i < size; i++) { + all = AndroidImplementation.checkForPermission( + perms.get(i), + "This is required to use Bluetooth") && all; + } + out.complete(all ? Boolean.TRUE : Boolean.FALSE); + } + }; + } + + // ------------------------------------------------------------------ + // role entry points + // ------------------------------------------------------------------ + + @Override + public synchronized BluetoothLE getLE() { + if (le == null) { + le = new AndroidBluetoothLE(this); + } + return le; + } + + @Override + public synchronized com.codename1.bluetooth.classic.BluetoothClassic + getClassic() { + if (classic == null) { + classic = new AndroidRfcomm(); + } + return classic; + } +} + +/** + * Android implementation of the BLE central + peripheral role entry point + * over BluetoothLeScanner / BluetoothLeAdvertiser / BluetoothGattServer. + * The core class multiplexes any number of app-level scans onto the single + * platform scan managed here. + */ +class AndroidBluetoothLE extends BluetoothLE { + + private final AndroidBluetooth bluetooth; + + /** Peripheral identity cache so repeated sightings of one device share + * a single stateful BlePeripheral instance. */ + private final Object peripheralLock = new Object(); + private final HashMap peripherals = + new HashMap(); + + private final Object scanLock = new Object(); + private android.bluetooth.le.BluetoothLeScanner activeScanner; + private android.bluetooth.le.ScanCallback activeCallback; + + AndroidBluetoothLE(AndroidBluetooth bluetooth) { + this.bluetooth = bluetooth; + } + + AndroidBlePeripheral getOrCreatePeripheral( + android.bluetooth.BluetoothDevice device) { + synchronized (peripheralLock) { + AndroidBlePeripheral p = peripherals.get(device.getAddress()); + if (p == null) { + p = new AndroidBlePeripheral(device); + peripherals.put(device.getAddress(), p); + } + return p; + } + } + + // ------------------------------------------------------------------ + // scanning SPI + // ------------------------------------------------------------------ + + @Override + protected boolean isScanSupported() { + return bluetooth.isLeSupported(); + } + + @Override + protected void startPlatformScan() { + android.bluetooth.BluetoothAdapter a = AndroidBluetooth.adapter(); + if (a == null) { + throw new RuntimeException( + "Bluetooth is not available on this device"); + } + if (!a.isEnabled()) { + throw new RuntimeException("The Bluetooth adapter is powered off"); + } + android.bluetooth.le.BluetoothLeScanner scanner = + a.getBluetoothLeScanner(); + if (scanner == null) { + throw new RuntimeException( + "BLE scanning is unavailable (adapter off?)"); + } + android.bluetooth.le.ScanSettings settings = + new android.bluetooth.le.ScanSettings.Builder() + .setScanMode(mapScanMode(getAggregateScanMode())) + .build(); + android.bluetooth.le.ScanCallback cb = + new android.bluetooth.le.ScanCallback() { + @Override + public void onScanResult(int callbackType, + android.bluetooth.le.ScanResult result) { + deliver(result); + } + + @Override + public void onBatchScanResults( + List results) { + if (results != null) { + int size = results.size(); + for (int i = 0; i < size; i++) { + deliver(results.get(i)); + } + } + } + + @Override + public void onScanFailed(int errorCode) { + clearPlatformScan(); + fireScanFailed(new BluetoothException( + BluetoothError.SCAN_FAILED, + "The OS aborted the scan (code " + errorCode + ")")); + } + }; + // a SecurityException (missing BLUETOOTH_SCAN / location) propagates + // as a RuntimeException and fails the initiating handle + scanner.startScan(null, settings, cb); + synchronized (scanLock) { + activeScanner = scanner; + activeCallback = cb; + } + } + + @Override + protected void stopPlatformScan() { + clearPlatformScan(); + } + + private void clearPlatformScan() { + android.bluetooth.le.BluetoothLeScanner scanner; + android.bluetooth.le.ScanCallback cb; + synchronized (scanLock) { + scanner = activeScanner; + cb = activeCallback; + activeScanner = null; + activeCallback = null; + } + if (scanner != null && cb != null) { + try { + scanner.stopScan(cb); + } catch (Throwable ignore) { + } + } + } + + private void deliver(android.bluetooth.le.ScanResult platformResult) { + if (platformResult == null || platformResult.getDevice() == null) { + return; + } + AndroidBlePeripheral p = getOrCreatePeripheral( + platformResult.getDevice()); + android.bluetooth.le.ScanRecord record = + platformResult.getScanRecord(); + AdvertisementData ad = record != null + ? AdvertisementData.parse(record.getBytes()) + : new AdvertisementData(); + boolean connectable = true; + if (Build.VERSION.SDK_INT >= 26) { + connectable = platformResult.isConnectable(); + } + fireScanResult(new ScanResult(p, platformResult.getRssi(), ad, + connectable, System.currentTimeMillis())); + } + + private static int mapScanMode(ScanMode mode) { + if (mode == ScanMode.OPPORTUNISTIC && Build.VERSION.SDK_INT >= 23) { + return android.bluetooth.le.ScanSettings.SCAN_MODE_OPPORTUNISTIC; + } + if (mode == ScanMode.LOW_POWER || mode == ScanMode.OPPORTUNISTIC) { + return android.bluetooth.le.ScanSettings.SCAN_MODE_LOW_POWER; + } + if (mode == ScanMode.LOW_LATENCY) { + return android.bluetooth.le.ScanSettings.SCAN_MODE_LOW_LATENCY; + } + return android.bluetooth.le.ScanSettings.SCAN_MODE_BALANCED; + } + + // ------------------------------------------------------------------ + // peripheral lookup + // ------------------------------------------------------------------ + + @Override + public BlePeripheral getPeripheral(String address) { + android.bluetooth.BluetoothAdapter a = AndroidBluetooth.adapter(); + if (a == null || address == null + || !android.bluetooth.BluetoothAdapter + .checkBluetoothAddress(address)) { + return null; + } + return getOrCreatePeripheral(a.getRemoteDevice(address)); + } + + @Override + public List getConnectedPeripherals( + BluetoothUuid serviceFilter) { + ArrayList out = new ArrayList(); + android.bluetooth.BluetoothManager mgr = AndroidBluetooth.manager(); + if (mgr == null) { + return out; + } + List devices; + try { + devices = mgr.getConnectedDevices( + android.bluetooth.BluetoothProfile.GATT); + } catch (SecurityException se) { + return out; + } + if (devices == null) { + return out; + } + java.util.UUID want = serviceFilter == null + ? null : AndroidBluetooth.toPlatformUuid(serviceFilter); + int size = devices.size(); + for (int i = 0; i < size; i++) { + android.bluetooth.BluetoothDevice d = devices.get(i); + if (want != null) { + // best effort: the SDP/GATT cache is only populated for + // some devices; when it is unavailable the device is kept + android.os.ParcelUuid[] uuids = null; + try { + uuids = d.getUuids(); + } catch (Throwable ignore) { + } + if (uuids != null && uuids.length > 0) { + boolean match = false; + for (int j = 0; j < uuids.length; j++) { + if (want.equals(uuids[j].getUuid())) { + match = true; + break; + } + } + if (!match) { + continue; + } + } + } + out.add(getOrCreatePeripheral(d)); + } + return out; + } + + @Override + public List getBondedPeripherals() { + ArrayList out = new ArrayList(); + android.bluetooth.BluetoothAdapter a = AndroidBluetooth.adapter(); + if (a == null) { + return out; + } + java.util.Set bonded; + try { + bonded = a.getBondedDevices(); + } catch (SecurityException se) { + return out; + } + if (bonded == null) { + return out; + } + for (android.bluetooth.BluetoothDevice d : bonded) { + int type; + try { + type = d.getType(); + } catch (SecurityException se) { + type = android.bluetooth.BluetoothDevice.DEVICE_TYPE_UNKNOWN; + } + if (type == android.bluetooth.BluetoothDevice.DEVICE_TYPE_LE + || type == android.bluetooth.BluetoothDevice.DEVICE_TYPE_DUAL) { + out.add(getOrCreatePeripheral(d)); + } + } + return out; + } + + // ------------------------------------------------------------------ + // peripheral role + // ------------------------------------------------------------------ + + @Override + public AsyncResource openGattServer( + GattServerListener listener) { + AsyncResource out = new AsyncResource(); + if (!bluetooth.isPeripheralModeSupported()) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "BLE peripheral mode is not supported on this device")); + return out; + } + if (listener == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "openGattServer requires a listener")); + return out; + } + try { + out.complete(new AndroidGattServerImpl(listener)); + } catch (SecurityException se) { + out.error(new BluetoothException(BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + } catch (RuntimeException ex) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "Failed to open the GATT server: " + ex.getMessage(), + ex)); + } + return out; + } + + @Override + public AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse) { + final AsyncResource out = + new AsyncResource(); + android.bluetooth.BluetoothAdapter a = AndroidBluetooth.adapter(); + if (!bluetooth.isPeripheralModeSupported() || a == null) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "BLE peripheral mode is not supported on this device")); + return out; + } + if (!a.isEnabled()) { + out.error(new BluetoothException(BluetoothError.POWERED_OFF, + "The Bluetooth adapter is powered off")); + return out; + } + android.bluetooth.le.BluetoothLeAdvertiser advertiser = + a.getBluetoothLeAdvertiser(); + if (advertiser == null) { + out.error(new BluetoothException(BluetoothError.ADVERTISE_FAILED, + "This device cannot advertise")); + return out; + } + AdvertiseSettings s = settings == null + ? new AdvertiseSettings() : settings; + android.bluetooth.le.AdvertiseSettings platformSettings = + new android.bluetooth.le.AdvertiseSettings.Builder() + .setAdvertiseMode(mapAdvertiseMode(s.getMode())) + .setTxPowerLevel(mapTxPower(s.getTxPower())) + .setConnectable(s.isConnectable()) + .setTimeout(s.getTimeout()) + .build(); + android.bluetooth.le.AdvertiseData platformData = + buildAdvertiseData(data); + android.bluetooth.le.AdvertiseData platformScanResponse = + scanResponse == null ? null : buildAdvertiseData(scanResponse); + final AndroidAdvertisementHandle handle = + new AndroidAdvertisementHandle(advertiser); + android.bluetooth.le.AdvertiseCallback cb = + makeAdvertiseCallback(handle, out); + handle.callback = cb; + try { + advertiser.startAdvertising(platformSettings, platformData, + platformScanResponse, cb); + } catch (SecurityException se) { + out.error(new BluetoothException(BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_ADVERTISE permission", se)); + } catch (RuntimeException ex) { + out.error(new BluetoothException(BluetoothError.ADVERTISE_FAILED, + "Advertising failed to start: " + ex.getMessage(), ex)); + } + return out; + } + + // Static so the AdvertiseCallback doesn't carry a synthetic + // outer-AndroidBluetoothLE reference (SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON). + private static android.bluetooth.le.AdvertiseCallback makeAdvertiseCallback( + final AndroidAdvertisementHandle handle, + final AsyncResource out) { + return new android.bluetooth.le.AdvertiseCallback() { + @Override + public void onStartSuccess( + android.bluetooth.le.AdvertiseSettings settingsInEffect) { + handle.markActive(); + if (!out.isDone()) { + out.complete(handle); + } + } + + @Override + public void onStartFailure(int errorCode) { + if (!out.isDone()) { + out.error(new BluetoothException( + BluetoothError.ADVERTISE_FAILED, + "Advertising failed to start (code " + errorCode + + ")")); + } + } + }; + } + + private static android.bluetooth.le.AdvertiseData buildAdvertiseData( + AdvertiseData data) { + android.bluetooth.le.AdvertiseData.Builder b = + new android.bluetooth.le.AdvertiseData.Builder(); + if (data != null) { + b.setIncludeDeviceName(data.isIncludeDeviceName()); + b.setIncludeTxPowerLevel(data.isIncludeTxPower()); + List uuids = data.getServiceUuids(); + int size = uuids.size(); + for (int i = 0; i < size; i++) { + b.addServiceUuid(new android.os.ParcelUuid( + AndroidBluetooth.toPlatformUuid(uuids.get(i)))); + } + for (Map.Entry e + : data.getManufacturerData().entrySet()) { + b.addManufacturerData(e.getKey().intValue(), e.getValue()); + } + for (Map.Entry e + : data.getServiceData().entrySet()) { + b.addServiceData(new android.os.ParcelUuid( + AndroidBluetooth.toPlatformUuid(e.getKey())), + e.getValue()); + } + } + return b.build(); + } + + private static int mapAdvertiseMode(AdvertiseMode mode) { + if (mode == AdvertiseMode.LOW_POWER) { + return android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_POWER; + } + if (mode == AdvertiseMode.LOW_LATENCY) { + return android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY; + } + return android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_BALANCED; + } + + private static int mapTxPower(TxPowerLevel level) { + if (level == TxPowerLevel.ULTRA_LOW) { + return android.bluetooth.le.AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW; + } + if (level == TxPowerLevel.LOW) { + return android.bluetooth.le.AdvertiseSettings.ADVERTISE_TX_POWER_LOW; + } + if (level == TxPowerLevel.HIGH) { + return android.bluetooth.le.AdvertiseSettings.ADVERTISE_TX_POWER_HIGH; + } + return android.bluetooth.le.AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM; + } + + @Override + public AsyncResource openL2capServer(final boolean secure) { + final AsyncResource out = new AsyncResource(); + final android.bluetooth.BluetoothAdapter a = + AndroidBluetooth.adapter(); + if (a == null || !AndroidL2capCompat.isSupported()) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "L2CAP channels require Android 10 (API 29) or newer")); + return out; + } + Thread t = new Thread(makeL2capServerRunnable(a, secure, out), + "CN1-L2CAP-listen"); + t.setDaemon(true); + t.start(); + return out; + } + + // Static so the Runnable doesn't carry a synthetic outer-AndroidBluetoothLE + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static Runnable makeL2capServerRunnable( + final android.bluetooth.BluetoothAdapter a, final boolean secure, + final AsyncResource out) { + return new Runnable() { + public void run() { + try { + android.bluetooth.BluetoothServerSocket serverSocket = + AndroidL2capCompat.listen(a, secure); + int psm = AndroidL2capCompat.psmOf(serverSocket); + out.complete(new AndroidL2capServer(psm, serverSocket)); + } catch (SecurityException se) { + out.error(new BluetoothException( + BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + } catch (IOException ioe) { + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "L2CAP listen failed: " + ioe.getMessage(), ioe)); + } catch (Throwable ex) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "L2CAP listen failed: " + ex, ex)); + } + } + }; + } + + /** Live advertisement handle; stop() detaches the platform callback. */ + private static final class AndroidAdvertisementHandle + extends BleAdvertisement { + + private final android.bluetooth.le.BluetoothLeAdvertiser advertiser; + volatile android.bluetooth.le.AdvertiseCallback callback; + private volatile boolean active; + + AndroidAdvertisementHandle( + android.bluetooth.le.BluetoothLeAdvertiser advertiser) { + this.advertiser = advertiser; + } + + void markActive() { + active = true; + } + + @Override + public void stop() { + if (!active) { + return; + } + active = false; + android.bluetooth.le.AdvertiseCallback cb = callback; + if (cb != null) { + try { + advertiser.stopAdvertising(cb); + } catch (Throwable ignore) { + } + } + } + + @Override + public boolean isActive() { + return active; + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidGattServerImpl.java b/Ports/Android/src/com/codename1/impl/android/AndroidGattServerImpl.java new file mode 100644 index 00000000000..e383897f88c --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidGattServerImpl.java @@ -0,0 +1,807 @@ +/* + * Copyright (c) 2012, 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.impl.android; + +import android.content.Context; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattStatus; +import com.codename1.bluetooth.le.server.BleCentral; +import com.codename1.bluetooth.le.server.GattLocalCharacteristic; +import com.codename1.bluetooth.le.server.GattLocalDescriptor; +import com.codename1.bluetooth.le.server.GattLocalService; +import com.codename1.bluetooth.le.server.GattReadRequest; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.bluetooth.le.server.GattWriteRequest; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; + +/** + * Android implementation of the local {@link GattServer} over + * {@code BluetoothGattServer}. + * + * Two operations require explicit serialization on Android and get it here: + * consecutive {@code addService} calls must wait for {@code onServiceAdded}, + * and consecutive notifications must wait for {@code onNotificationSent}. + * Client Characteristic Configuration descriptors are attached automatically + * to notifying characteristics and their writes surface as + * {@code subscriptionChanged} events rather than raw descriptor writes. + */ +class AndroidGattServerImpl extends GattServer { + + private final android.bluetooth.BluetoothGattServer server; + + private final Object serverLock = new Object(); + + // platform -> definition lookups for routing requests + private final HashMap + charMap = + new HashMap(); + private final HashMap + charReverse = + new HashMap(); + private final HashMap + descMap = + new HashMap(); + private final HashMap + serviceMap = + new HashMap(); + /** the CCCDs this implementation injected for notify/indicate chars */ + private final HashSet autoCccds = + new HashSet(); + + // connected centrals by address + private final HashMap centrals = + new HashMap(); + // per-characteristic subscriber addresses (from CCCD writes) + private final HashMap> + subscribers = + new HashMap>(); + + // ---- addService serialization (Android requires onServiceAdded + // between consecutive additions) + private static final class AddOp { + final android.bluetooth.BluetoothGattService service; + final AsyncResource out; + + AddOp(android.bluetooth.BluetoothGattService service, + AsyncResource out) { + this.service = service; + this.out = out; + } + } + + private AddOp currentAdd; + private final LinkedList pendingAdds = new LinkedList(); + + // ---- notification serialization (one notifyCharacteristicChanged per + // onNotificationSent) + private static final class NotifyOp { + final android.bluetooth.BluetoothGattCharacteristic characteristic; + final byte[] value; + final boolean confirm; + final List targets; + final AsyncResource out; + int index; + + NotifyOp(android.bluetooth.BluetoothGattCharacteristic characteristic, + byte[] value, boolean confirm, + List targets, + AsyncResource out) { + this.characteristic = characteristic; + this.value = value; + this.confirm = confirm; + this.targets = targets; + this.out = out; + } + } + + private NotifyOp currentNotify; + private final LinkedList pendingNotifies = + new LinkedList(); + + AndroidGattServerImpl(GattServerListener listener) { + super(listener); + Context ctx = AndroidImplementation.getContext(); + android.bluetooth.BluetoothManager mgr = AndroidBluetooth.manager(); + if (ctx == null || mgr == null) { + throw new RuntimeException("Bluetooth is unavailable"); + } + android.bluetooth.BluetoothGattServer s = + mgr.openGattServer(ctx, serverCallback); + if (s == null) { + throw new RuntimeException( + "The platform failed to open a GATT server " + + "(adapter off?)"); + } + server = s; + } + + // ------------------------------------------------------------------ + // port SPI + // ------------------------------------------------------------------ + + @Override + protected void doAddService(GattLocalService service, + AsyncResource out) { + android.bluetooth.BluetoothGattService ps = buildPlatformService( + service); + AddOp op = new AddOp(ps, out); + boolean startNow; + synchronized (serverLock) { + serviceMap.put(service, ps); + if (currentAdd == null) { + currentAdd = op; + startNow = true; + } else { + pendingAdds.add(op); + startNow = false; + } + } + if (startNow) { + startAdd(op); + } + } + + private void startAdd(AddOp op) { + boolean started; + try { + started = server.addService(op.service); + } catch (SecurityException se) { + started = false; + } catch (Throwable ex) { + started = false; + } + if (!started) { + if (!op.out.isDone()) { + op.out.error(new BluetoothException(BluetoothError.UNKNOWN, + "The platform rejected the service addition")); + } + advanceAdd(op); + } + } + + private void advanceAdd(AddOp finished) { + AddOp next; + synchronized (serverLock) { + if (currentAdd != finished) { + return; + } + currentAdd = pendingAdds.poll(); + next = currentAdd; + } + if (next != null) { + startAdd(next); + } + } + + private android.bluetooth.BluetoothGattService buildPlatformService( + GattLocalService service) { + android.bluetooth.BluetoothGattService ps = + new android.bluetooth.BluetoothGattService( + AndroidBluetooth.toPlatformUuid(service.getUuid()), + service.isPrimary() + ? android.bluetooth.BluetoothGattService.SERVICE_TYPE_PRIMARY + : android.bluetooth.BluetoothGattService.SERVICE_TYPE_SECONDARY); + for (GattLocalCharacteristic lc : service.getCharacteristics()) { + // the core PROPERTY_* / PERMISSION_* bits mirror Android's values + android.bluetooth.BluetoothGattCharacteristic pc = + new android.bluetooth.BluetoothGattCharacteristic( + AndroidBluetooth.toPlatformUuid(lc.getUuid()), + lc.getProperties(), lc.getPermissions()); + if (lc.getValue() != null) { + pc.setValue(lc.getValue()); + } + boolean hasCccd = false; + for (GattLocalDescriptor ld : lc.getDescriptors()) { + android.bluetooth.BluetoothGattDescriptor pd = + new android.bluetooth.BluetoothGattDescriptor( + AndroidBluetooth.toPlatformUuid(ld.getUuid()), + ld.getPermissions()); + if (ld.getValue() != null) { + pd.setValue(ld.getValue()); + } + pc.addDescriptor(pd); + if (AndroidBluetooth.CCCD_UUID.equals(pd.getUuid())) { + hasCccd = true; + synchronized (serverLock) { + autoCccds.add(pd); + } + } else { + synchronized (serverLock) { + descMap.put(pd, ld); + } + } + } + int notifyBits = GattCharacteristic.PROPERTY_NOTIFY + | GattCharacteristic.PROPERTY_INDICATE; + if (!hasCccd && (lc.getProperties() & notifyBits) != 0) { + // Android requires an explicit CCCD on the server side for + // centrals to arm notifications -- inject one + android.bluetooth.BluetoothGattDescriptor cccd = + new android.bluetooth.BluetoothGattDescriptor( + AndroidBluetooth.CCCD_UUID, + android.bluetooth.BluetoothGattDescriptor.PERMISSION_READ + | android.bluetooth.BluetoothGattDescriptor.PERMISSION_WRITE); + pc.addDescriptor(cccd); + synchronized (serverLock) { + autoCccds.add(cccd); + } + } + ps.addCharacteristic(pc); + synchronized (serverLock) { + charMap.put(pc, lc); + charReverse.put(lc, pc); + } + } + return ps; + } + + @Override + protected void doNotify(BleCentral central, + GattLocalCharacteristic characteristic, byte[] value, + boolean confirm, AsyncResource out) { + android.bluetooth.BluetoothGattCharacteristic pc; + synchronized (serverLock) { + pc = charReverse.get(characteristic); + } + if (pc == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "The characteristic was not added to this server")); + return; + } + ArrayList targets = + new ArrayList(); + synchronized (serverLock) { + if (central != null) { + AndroidCentral ac = centrals.get(central.getAddress()); + if (ac != null) { + targets.add(ac.device); + } + } else { + HashSet subs = subscribers.get(pc); + if (subs != null) { + for (String address : subs) { + AndroidCentral ac = centrals.get(address); + if (ac != null) { + targets.add(ac.device); + } + } + } + } + } + if (targets.isEmpty()) { + // nothing to send -- either no subscribed central or the target + // central disconnected + out.complete(Boolean.TRUE); + return; + } + byte[] copy; + if (value == null) { + copy = new byte[0]; + } else { + copy = new byte[value.length]; + System.arraycopy(value, 0, copy, 0, value.length); + } + NotifyOp op = new NotifyOp(pc, copy, confirm, targets, out); + boolean startNow; + synchronized (serverLock) { + if (currentNotify == null) { + currentNotify = op; + startNow = true; + } else { + pendingNotifies.add(op); + startNow = false; + } + } + if (startNow) { + sendCurrentNotification(op); + } + } + + private void sendCurrentNotification(NotifyOp op) { + android.bluetooth.BluetoothDevice target = op.targets.get(op.index); + boolean started; + try { + op.characteristic.setValue(op.value); + started = server.notifyCharacteristicChanged(target, + op.characteristic, op.confirm); + } catch (SecurityException se) { + started = false; + } catch (Throwable ex) { + started = false; + } + if (!started) { + if (!op.out.isDone()) { + op.out.error(new BluetoothException(BluetoothError.UNKNOWN, + "The platform rejected the notification")); + } + advanceNotify(op); + } + } + + private void advanceNotify(NotifyOp finished) { + NotifyOp next; + synchronized (serverLock) { + if (currentNotify != finished) { + return; + } + currentNotify = pendingNotifies.poll(); + next = currentNotify; + } + if (next != null) { + sendCurrentNotification(next); + } + } + + // ------------------------------------------------------------------ + // public surface + // ------------------------------------------------------------------ + + @Override + public void removeService(GattLocalService service) { + android.bluetooth.BluetoothGattService ps; + synchronized (serverLock) { + ps = serviceMap.remove(service); + if (ps != null) { + for (android.bluetooth.BluetoothGattCharacteristic pc + : ps.getCharacteristics()) { + GattLocalCharacteristic lc = charMap.remove(pc); + if (lc != null) { + charReverse.remove(lc); + } + subscribers.remove(pc); + for (android.bluetooth.BluetoothGattDescriptor pd + : pc.getDescriptors()) { + descMap.remove(pd); + autoCccds.remove(pd); + } + } + } + } + if (ps != null) { + try { + server.removeService(ps); + } catch (Throwable ignore) { + } + } + } + + @Override + public void close() { + synchronized (serverLock) { + serviceMap.clear(); + charMap.clear(); + charReverse.clear(); + descMap.clear(); + autoCccds.clear(); + subscribers.clear(); + centrals.clear(); + } + try { + server.close(); + } catch (Throwable ignore) { + } + } + + @Override + public List getConnectedCentrals() { + ArrayList out = new ArrayList(); + synchronized (serverLock) { + out.addAll(centrals.values()); + } + return out; + } + + // ------------------------------------------------------------------ + // platform callback -- binder threads + // ------------------------------------------------------------------ + + private AndroidCentral centralFor( + android.bluetooth.BluetoothDevice device) { + synchronized (serverLock) { + AndroidCentral c = centrals.get(device.getAddress()); + if (c == null) { + c = new AndroidCentral(device); + centrals.put(device.getAddress(), c); + } + return c; + } + } + + private void sendResponse(android.bluetooth.BluetoothDevice device, + int requestId, int status, int offset, byte[] value) { + try { + server.sendResponse(device, requestId, status, offset, value); + } catch (Throwable ignore) { + } + } + + private static byte[] slice(byte[] value, int offset) { + if (value == null) { + return new byte[0]; + } + if (offset <= 0) { + return value; + } + if (offset >= value.length) { + return new byte[0]; + } + byte[] out = new byte[value.length - offset]; + System.arraycopy(value, offset, out, 0, out.length); + return out; + } + + private final android.bluetooth.BluetoothGattServerCallback + serverCallback = new android.bluetooth.BluetoothGattServerCallback() { + + @Override + public void onConnectionStateChange( + android.bluetooth.BluetoothDevice device, int status, + int newState) { + if (newState + == android.bluetooth.BluetoothProfile.STATE_CONNECTED) { + fireCentralConnected(centralFor(device)); + } else if (newState + == android.bluetooth.BluetoothProfile.STATE_DISCONNECTED) { + AndroidCentral central; + synchronized (serverLock) { + central = centrals.remove(device.getAddress()); + for (HashSet subs : subscribers.values()) { + subs.remove(device.getAddress()); + } + } + if (central != null) { + fireCentralDisconnected(central); + } + } + } + + @Override + public void onServiceAdded(int status, + android.bluetooth.BluetoothGattService service) { + AddOp op; + synchronized (serverLock) { + op = currentAdd; + } + if (op == null) { + return; + } + if (!op.out.isDone()) { + if (status == android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + op.out.complete(Boolean.TRUE); + } else { + op.out.error(new BluetoothException( + BluetoothError.GATT_ERROR, + "The platform failed to register the service " + + "(status " + status + ")", status)); + } + } + advanceAdd(op); + } + + @Override + public void onCharacteristicReadRequest( + android.bluetooth.BluetoothDevice device, int requestId, + int offset, + android.bluetooth.BluetoothGattCharacteristic pc) { + GattLocalCharacteristic lc; + synchronized (serverLock) { + lc = charMap.get(pc); + } + if (lc == null) { + sendResponse(device, requestId, + GattStatus.INVALID_HANDLE.getAttCode(), offset, null); + return; + } + if (lc.getValue() != null) { + // static value -- serve without involving the listener + sendResponse(device, requestId, + android.bluetooth.BluetoothGatt.GATT_SUCCESS, offset, + slice(lc.getValue(), offset)); + return; + } + fireCharacteristicReadRequest(new AndroidReadRequest( + centralFor(device), lc, null, offset, device, requestId)); + } + + @Override + public void onCharacteristicWriteRequest( + android.bluetooth.BluetoothDevice device, int requestId, + android.bluetooth.BluetoothGattCharacteristic pc, + boolean preparedWrite, boolean responseNeeded, int offset, + byte[] value) { + GattLocalCharacteristic lc; + synchronized (serverLock) { + lc = charMap.get(pc); + } + if (lc == null) { + if (responseNeeded) { + sendResponse(device, requestId, + GattStatus.INVALID_HANDLE.getAttCode(), offset, + null); + } + return; + } + if (preparedWrite) { + // reliable/queued writes are not part of the portable API + if (responseNeeded) { + sendResponse(device, requestId, + GattStatus.REQUEST_NOT_SUPPORTED.getAttCode(), + offset, null); + } + return; + } + fireCharacteristicWriteRequest(new AndroidWriteRequest( + centralFor(device), lc, null, copy(value), offset, + responseNeeded, device, requestId)); + } + + @Override + public void onDescriptorReadRequest( + android.bluetooth.BluetoothDevice device, int requestId, + int offset, android.bluetooth.BluetoothGattDescriptor pd) { + boolean isCccd; + GattLocalDescriptor ld; + boolean subscribed; + synchronized (serverLock) { + isCccd = autoCccds.contains(pd); + ld = descMap.get(pd); + HashSet subs = subscribers.get(pd.getCharacteristic()); + subscribed = subs != null + && subs.contains(device.getAddress()); + } + if (isCccd) { + byte[] v = subscribed + ? android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + : android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE; + sendResponse(device, requestId, + android.bluetooth.BluetoothGatt.GATT_SUCCESS, offset, + slice(v, offset)); + return; + } + if (ld == null) { + sendResponse(device, requestId, + GattStatus.INVALID_HANDLE.getAttCode(), offset, null); + return; + } + if (ld.getValue() != null) { + sendResponse(device, requestId, + android.bluetooth.BluetoothGatt.GATT_SUCCESS, offset, + slice(ld.getValue(), offset)); + return; + } + fireDescriptorReadRequest(new AndroidReadRequest( + centralFor(device), null, ld, offset, device, requestId)); + } + + @Override + public void onDescriptorWriteRequest( + android.bluetooth.BluetoothDevice device, int requestId, + android.bluetooth.BluetoothGattDescriptor pd, + boolean preparedWrite, boolean responseNeeded, int offset, + byte[] value) { + boolean isCccd; + GattLocalDescriptor ld; + GattLocalCharacteristic lc; + synchronized (serverLock) { + isCccd = autoCccds.contains(pd); + ld = descMap.get(pd); + lc = charMap.get(pd.getCharacteristic()); + } + if (isCccd) { + boolean enable = value != null && value.length > 0 + && value[0] != 0; + boolean changed; + synchronized (serverLock) { + HashSet subs = + subscribers.get(pd.getCharacteristic()); + if (enable) { + if (subs == null) { + subs = new HashSet(); + subscribers.put(pd.getCharacteristic(), subs); + } + changed = subs.add(device.getAddress()); + } else { + changed = subs != null + && subs.remove(device.getAddress()); + } + } + if (responseNeeded) { + sendResponse(device, requestId, + android.bluetooth.BluetoothGatt.GATT_SUCCESS, + offset, value); + } + if (changed && lc != null) { + fireSubscriptionChanged(centralFor(device), lc, enable); + } + return; + } + if (ld == null) { + if (responseNeeded) { + sendResponse(device, requestId, + GattStatus.INVALID_HANDLE.getAttCode(), offset, + null); + } + return; + } + if (preparedWrite) { + if (responseNeeded) { + sendResponse(device, requestId, + GattStatus.REQUEST_NOT_SUPPORTED.getAttCode(), + offset, null); + } + return; + } + fireDescriptorWriteRequest(new AndroidWriteRequest( + centralFor(device), null, ld, copy(value), offset, + responseNeeded, device, requestId)); + } + + @Override + public void onExecuteWrite(android.bluetooth.BluetoothDevice device, + int requestId, boolean execute) { + // reliable writes are rejected at the prepare stage above + sendResponse(device, requestId, + GattStatus.REQUEST_NOT_SUPPORTED.getAttCode(), 0, null); + } + + @Override + public void onNotificationSent( + android.bluetooth.BluetoothDevice device, int status) { + NotifyOp op; + synchronized (serverLock) { + op = currentNotify; + } + if (op == null) { + return; + } + if (status != android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + if (!op.out.isDone()) { + op.out.error(new BluetoothException( + BluetoothError.GATT_ERROR, + "Notification failed (status " + status + ")", + status)); + } + advanceNotify(op); + return; + } + op.index++; + if (op.index < op.targets.size()) { + sendCurrentNotification(op); + } else { + if (!op.out.isDone()) { + op.out.complete(Boolean.TRUE); + } + advanceNotify(op); + } + } + + @Override + public void onMtuChanged(android.bluetooth.BluetoothDevice device, + int mtu) { + centralFor(device).updateMtu(mtu); + } + }; + + private static byte[] copy(byte[] value) { + if (value == null) { + return new byte[0]; + } + byte[] out = new byte[value.length]; + System.arraycopy(value, 0, out, 0, value.length); + return out; + } + + // ------------------------------------------------------------------ + // model wrappers + // ------------------------------------------------------------------ + + /** A connected central; identity is the platform device address. */ + static final class AndroidCentral extends BleCentral { + final android.bluetooth.BluetoothDevice device; + + AndroidCentral(android.bluetooth.BluetoothDevice device) { + this.device = device; + } + + @Override + public String getAddress() { + return device.getAddress(); + } + + void updateMtu(int mtu) { + setMtu(mtu); + } + } + + /** Read-request envelope routing respond/reject to sendResponse. */ + private final class AndroidReadRequest extends GattReadRequest { + private final android.bluetooth.BluetoothDevice device; + private final int requestId; + + AndroidReadRequest(BleCentral central, + GattLocalCharacteristic characteristic, + GattLocalDescriptor descriptor, int offset, + android.bluetooth.BluetoothDevice device, int requestId) { + super(central, characteristic, descriptor, offset); + this.device = device; + this.requestId = requestId; + } + + @Override + public void respond(byte[] value) { + // Android expects the long-read slice starting at the request + // offset -- apps respond with the full value + sendResponse(device, requestId, + android.bluetooth.BluetoothGatt.GATT_SUCCESS, getOffset(), + slice(value == null ? new byte[0] : value, getOffset())); + } + + @Override + public void reject(GattStatus status) { + sendResponse(device, requestId, + (status == null ? GattStatus.UNLIKELY_ERROR : status) + .getAttCode(), getOffset(), null); + } + } + + /** Write-request envelope routing respond/reject to sendResponse. */ + private final class AndroidWriteRequest extends GattWriteRequest { + private final android.bluetooth.BluetoothDevice device; + private final int requestId; + + AndroidWriteRequest(BleCentral central, + GattLocalCharacteristic characteristic, + GattLocalDescriptor descriptor, byte[] value, int offset, + boolean responseRequired, + android.bluetooth.BluetoothDevice device, int requestId) { + super(central, characteristic, descriptor, value, offset, + responseRequired); + this.device = device; + this.requestId = requestId; + } + + @Override + public void respond() { + sendResponse(device, requestId, + android.bluetooth.BluetoothGatt.GATT_SUCCESS, getOffset(), + getValue()); + } + + @Override + public void reject(GattStatus status) { + sendResponse(device, requestId, + (status == null ? GattStatus.UNLIKELY_ERROR : status) + .getAttCode(), getOffset(), null); + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index b20cc3baa41..71fccfe229d 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -53,24 +53,24 @@ import android.media.AudioManager; import android.net.Uri; import android.os.Vibrator; -import android.os.PowerManager; -import android.provider.Settings; +import android.os.PowerManager; +import android.provider.Settings; import android.telephony.TelephonyManager; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; import android.view.KeyEvent; import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityManager; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityManager; import android.view.Window; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.RelativeLayout; import android.widget.TextView; -import com.codename1.ui.BrowserComponent; -import com.codename1.ui.AccessibilityColorVisionDeficiency; +import com.codename1.ui.BrowserComponent; +import com.codename1.ui.AccessibilityColorVisionDeficiency; import com.codename1.ui.Component; import com.codename1.ui.Font; @@ -320,9 +320,9 @@ public static void setActivity(CodenameOneActivity aActivity) { } } - CodenameOneSurface myView = null; - private AndroidAccessibilityProvider accessibilityProvider; - private volatile boolean accessibilityTreeUpdateRequired; + CodenameOneSurface myView = null; + private AndroidAccessibilityProvider accessibilityProvider; + private volatile boolean accessibilityTreeUpdateRequired; CodenameOneTextPaint defaultFont; private final char[] tmpchar = new char[1]; private final Rect tmprect = new Rect(); @@ -1400,7 +1400,7 @@ public boolean isLargerTextEnabled() { } @Override - public float getLargerTextScale() { + public float getLargerTextScale() { try { Configuration configuration; if (getActivity() != null) { @@ -1582,16 +1582,16 @@ public void run() { } deinitializing = false; } - if (nativePeers.size() > 0) { - for (int i = 0; i < nativePeers.size(); i++) { - ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit(); - } - } - if (accessibilityProvider != null) { - accessibilityProvider.dispose(); - accessibilityProvider = null; - } - if (relativeLayout != null) { + if (nativePeers.size() > 0) { + for (int i = 0; i < nativePeers.size(); i++) { + ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit(); + } + } + if (accessibilityProvider != null) { + accessibilityProvider.dispose(); + accessibilityProvider = null; + } + if (relativeLayout != null) { relativeLayout.removeAllViews(); } relativeLayout = null; @@ -1637,18 +1637,18 @@ private void initSurface() { superPeerMode = true; myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); } - myView.getAndroidView().setVisibility(View.VISIBLE); - - if (Build.VERSION.SDK_INT >= 16) { - final View semanticHost = myView.getAndroidView(); - accessibilityProvider = new AndroidAccessibilityProvider(semanticHost, this); - semanticHost.setAccessibilityDelegate(new View.AccessibilityDelegate() { - @Override - public android.view.accessibility.AccessibilityNodeProvider getAccessibilityNodeProvider(View host) { - return accessibilityProvider; - } - }); - } + myView.getAndroidView().setVisibility(View.VISIBLE); + + if (Build.VERSION.SDK_INT >= 16) { + final View semanticHost = myView.getAndroidView(); + accessibilityProvider = new AndroidAccessibilityProvider(semanticHost, this); + semanticHost.setAccessibilityDelegate(new View.AccessibilityDelegate() { + @Override + public android.view.accessibility.AccessibilityNodeProvider getAccessibilityNodeProvider(View host) { + return accessibilityProvider; + } + }); + } relativeLayout.addView(myView.getAndroidView()); myView.getAndroidView().setVisibility(View.VISIBLE); @@ -7430,6 +7430,7 @@ public void printStackTraceToStream(Throwable t, Writer o) { private AndroidBiometrics biometrics; private AndroidSecureStorage secureStorage; private AndroidNfc nfc; + private AndroidBluetooth bluetooth; @Override public com.codename1.security.Biometrics getBiometrics() { @@ -7455,6 +7456,14 @@ public com.codename1.nfc.Nfc getNfc() { return nfc; } + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + if (bluetooth == null) { + bluetooth = new AndroidBluetooth(); + } + return bluetooth; + } + /** * This method returns the platform Location Control * @@ -13015,7 +13024,7 @@ public void run() { } @Override - public void announceForAccessibility(final Component cmp, final String text) { + public void announceForAccessibility(final Component cmp, final String text) { final Activity act = getActivity(); if (act == null) { return; @@ -13049,124 +13058,124 @@ public void run() { } } }); - } - - @Override - public boolean isHighContrastEnabled() { - try { - AccessibilityManager manager = (AccessibilityManager)getContext() - .getSystemService(Context.ACCESSIBILITY_SERVICE); - if (android.os.Build.VERSION.SDK_INT >= 21 && manager != null) { - Object enabled = AccessibilityManager.class.getMethod("isHighTextContrastEnabled") - .invoke(manager); - return enabled instanceof Boolean && ((Boolean)enabled).booleanValue(); - } - } catch (Throwable t) { - // Fall through to the secure settings used by older Android stubs. - } - return secureSettingEnabled("high_text_contrast_enabled") - || secureSettingEnabled("accessibility_display_high_text_contrast_enabled"); - } - - @Override - public boolean isDifferentiateWithoutColorEnabled() { - return secureSettingEnabled("accessibility_display_daltonizer_enabled"); - } - - @Override - public AccessibilityColorVisionDeficiency getColorVisionDeficiency() { - if (!secureSettingEnabled("accessibility_display_daltonizer_enabled")) { - return AccessibilityColorVisionDeficiency.NONE; - } - try { - int mode = Settings.Secure.getInt(getContext().getContentResolver(), - "accessibility_display_daltonizer"); - switch (mode) { - case 0: return AccessibilityColorVisionDeficiency.MONOCHROMACY; - case 11: return AccessibilityColorVisionDeficiency.PROTANOPIA; - case 12: return AccessibilityColorVisionDeficiency.DEUTERANOPIA; - case 13: return AccessibilityColorVisionDeficiency.TRITANOPIA; - default: return AccessibilityColorVisionDeficiency.UNKNOWN; - } - } catch (Throwable t) { - return AccessibilityColorVisionDeficiency.UNKNOWN; - } - } - - @Override - public boolean isReduceMotionEnabled() { - try { - return Settings.Global.getFloat(getContext().getContentResolver(), - Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f; - } catch (Throwable t) { - return false; - } - } - - @Override - public boolean isBoldTextEnabled() { - try { - Object value = Configuration.class.getField("fontWeightAdjustment") - .get(getContext().getResources().getConfiguration()); - return value instanceof Integer && ((Integer)value).intValue() >= 300; - } catch (Throwable t) { - return false; - } - } - - @Override - public boolean isInvertColorsEnabled() { - return secureSettingEnabled("accessibility_display_inversion_enabled"); - } - - @Override - public boolean isGrayscaleEnabled() { - return getColorVisionDeficiency() == AccessibilityColorVisionDeficiency.MONOCHROMACY; - } - - @Override - public boolean isScreenReaderEnabled() { - try { - AccessibilityManager manager = (AccessibilityManager)getContext() - .getSystemService(Context.ACCESSIBILITY_SERVICE); - return manager != null && manager.isEnabled() && manager.isTouchExplorationEnabled(); - } catch (Throwable t) { - return false; - } - } - - private boolean secureSettingEnabled(String key) { - try { - return Settings.Secure.getInt(getContext().getContentResolver(), key, 0) == 1; - } catch (Throwable t) { - return false; - } - } - - @Override - public void accessibilityTreeChanged(final int changeType) { - final Activity act = getActivity(); - if (act == null || accessibilityProvider == null) return; - act.runOnUiThread(new Runnable() { - public void run() { - if (accessibilityProvider != null) accessibilityProvider.invalidate(changeType); - } - }); - } - - @Override - public boolean isAccessibilityTreeSupported() { - return Build.VERSION.SDK_INT >= 16; - } - - @Override - public boolean isAccessibilityTreeUpdateRequired() { - return accessibilityTreeUpdateRequired; - } - - void setAccessibilityTreeUpdateRequired(boolean required) { - accessibilityTreeUpdateRequired = required; - } + } + + @Override + public boolean isHighContrastEnabled() { + try { + AccessibilityManager manager = (AccessibilityManager)getContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + if (android.os.Build.VERSION.SDK_INT >= 21 && manager != null) { + Object enabled = AccessibilityManager.class.getMethod("isHighTextContrastEnabled") + .invoke(manager); + return enabled instanceof Boolean && ((Boolean)enabled).booleanValue(); + } + } catch (Throwable t) { + // Fall through to the secure settings used by older Android stubs. + } + return secureSettingEnabled("high_text_contrast_enabled") + || secureSettingEnabled("accessibility_display_high_text_contrast_enabled"); + } + + @Override + public boolean isDifferentiateWithoutColorEnabled() { + return secureSettingEnabled("accessibility_display_daltonizer_enabled"); + } + + @Override + public AccessibilityColorVisionDeficiency getColorVisionDeficiency() { + if (!secureSettingEnabled("accessibility_display_daltonizer_enabled")) { + return AccessibilityColorVisionDeficiency.NONE; + } + try { + int mode = Settings.Secure.getInt(getContext().getContentResolver(), + "accessibility_display_daltonizer"); + switch (mode) { + case 0: return AccessibilityColorVisionDeficiency.MONOCHROMACY; + case 11: return AccessibilityColorVisionDeficiency.PROTANOPIA; + case 12: return AccessibilityColorVisionDeficiency.DEUTERANOPIA; + case 13: return AccessibilityColorVisionDeficiency.TRITANOPIA; + default: return AccessibilityColorVisionDeficiency.UNKNOWN; + } + } catch (Throwable t) { + return AccessibilityColorVisionDeficiency.UNKNOWN; + } + } + + @Override + public boolean isReduceMotionEnabled() { + try { + return Settings.Global.getFloat(getContext().getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f; + } catch (Throwable t) { + return false; + } + } + + @Override + public boolean isBoldTextEnabled() { + try { + Object value = Configuration.class.getField("fontWeightAdjustment") + .get(getContext().getResources().getConfiguration()); + return value instanceof Integer && ((Integer)value).intValue() >= 300; + } catch (Throwable t) { + return false; + } + } + + @Override + public boolean isInvertColorsEnabled() { + return secureSettingEnabled("accessibility_display_inversion_enabled"); + } + + @Override + public boolean isGrayscaleEnabled() { + return getColorVisionDeficiency() == AccessibilityColorVisionDeficiency.MONOCHROMACY; + } + + @Override + public boolean isScreenReaderEnabled() { + try { + AccessibilityManager manager = (AccessibilityManager)getContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + return manager != null && manager.isEnabled() && manager.isTouchExplorationEnabled(); + } catch (Throwable t) { + return false; + } + } + + private boolean secureSettingEnabled(String key) { + try { + return Settings.Secure.getInt(getContext().getContentResolver(), key, 0) == 1; + } catch (Throwable t) { + return false; + } + } + + @Override + public void accessibilityTreeChanged(final int changeType) { + final Activity act = getActivity(); + if (act == null || accessibilityProvider == null) return; + act.runOnUiThread(new Runnable() { + public void run() { + if (accessibilityProvider != null) accessibilityProvider.invalidate(changeType); + } + }); + } + + @Override + public boolean isAccessibilityTreeSupported() { + return Build.VERSION.SDK_INT >= 16; + } + + @Override + public boolean isAccessibilityTreeUpdateRequired() { + return accessibilityTreeUpdateRequired; + } + + void setAccessibilityTreeUpdateRequired(boolean required) { + accessibilityTreeUpdateRequired = required; + } // ================================================================ // Crypto bridge -- routes com.codename1.security onto the standard diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidL2capCompat.java b/Ports/Android/src/com/codename1/impl/android/AndroidL2capCompat.java new file mode 100644 index 00000000000..3a1bc6a47cf --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidL2capCompat.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2012, 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.impl.android; + +import android.os.Build; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Reflection bridge to the Android 10 (API 29) L2CAP connection-oriented + * channel APIs. The Android port compiles against the API 27 android.jar + * from cn1-binaries, so the API 29 symbols + * ({@code BluetoothDevice.createL2capChannel}, + * {@code BluetoothAdapter.listenUsingL2capChannel}, + * {@code BluetoothServerSocket.getPsm}) cannot be referenced directly and + * are looked up reflectively here. All lookups are cached; on devices below + * API 29 the feature reports itself as unsupported. + */ +final class AndroidL2capCompat { + + private static boolean initialized; + private static Method createSecureChannel; + private static Method createInsecureChannel; + private static Method listenSecure; + private static Method listenInsecure; + private static Method getPsm; + + private AndroidL2capCompat() { + } + + private static synchronized void init() { + if (initialized) { + return; + } + initialized = true; + if (Build.VERSION.SDK_INT < 29) { + return; + } + try { + createSecureChannel = android.bluetooth.BluetoothDevice.class + .getMethod("createL2capChannel", int.class); + createInsecureChannel = android.bluetooth.BluetoothDevice.class + .getMethod("createInsecureL2capChannel", int.class); + listenSecure = android.bluetooth.BluetoothAdapter.class + .getMethod("listenUsingL2capChannel"); + listenInsecure = android.bluetooth.BluetoothAdapter.class + .getMethod("listenUsingInsecureL2capChannel"); + getPsm = android.bluetooth.BluetoothServerSocket.class + .getMethod("getPsm"); + } catch (Throwable t) { + createSecureChannel = null; + createInsecureChannel = null; + listenSecure = null; + listenInsecure = null; + getPsm = null; + } + } + + /** + * True when this device exposes the L2CAP channel APIs (Android 10+). + */ + static boolean isSupported() { + init(); + return createSecureChannel != null && listenSecure != null + && getPsm != null; + } + + /** + * Opens an outgoing (client) L2CAP channel socket to the given PSM. + * The returned socket is not yet connected. + */ + static android.bluetooth.BluetoothSocket openChannel( + android.bluetooth.BluetoothDevice device, int psm, + boolean secure) throws IOException { + init(); + Method m = secure ? createSecureChannel : createInsecureChannel; + if (m == null) { + throw new IOException( + "L2CAP channels require Android 10 (API 29) or newer"); + } + return (android.bluetooth.BluetoothSocket) invoke(m, device, + new Object[]{Integer.valueOf(psm)}); + } + + /** + * Opens a listening L2CAP server socket with a dynamically assigned PSM. + */ + static android.bluetooth.BluetoothServerSocket listen( + android.bluetooth.BluetoothAdapter adapter, boolean secure) + throws IOException { + init(); + Method m = secure ? listenSecure : listenInsecure; + if (m == null) { + throw new IOException( + "L2CAP channels require Android 10 (API 29) or newer"); + } + return (android.bluetooth.BluetoothServerSocket) invoke(m, adapter, + new Object[0]); + } + + /** + * Returns the PSM the stack assigned to the given listening socket. + */ + static int psmOf(android.bluetooth.BluetoothServerSocket serverSocket) + throws IOException { + init(); + if (getPsm == null) { + throw new IOException( + "L2CAP channels require Android 10 (API 29) or newer"); + } + Object v = invoke(getPsm, serverSocket, new Object[0]); + return ((Integer) v).intValue(); + } + + private static Object invoke(Method m, Object target, Object[] args) + throws IOException { + try { + return m.invoke(target, args); + } catch (InvocationTargetException ite) { + Throwable cause = ite.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new IOException("L2CAP call failed: " + cause); + } catch (IllegalAccessException iae) { + throw new IOException("L2CAP call failed: " + iae); + } + } +} + +/** + * L2CAP channel over a platform BluetoothSocket (client-opened or accepted + * from an {@link AndroidL2capServer}). + */ +class AndroidL2capChannel extends L2capChannel { + + private final android.bluetooth.BluetoothSocket socket; + + AndroidL2capChannel(int psm, android.bluetooth.BluetoothSocket socket) { + super(psm); + this.socket = socket; + } + + @Override + public InputStream getInputStream() throws IOException { + return socket.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return socket.getOutputStream(); + } + + @Override + public void close() throws IOException { + socket.close(); + } + + @Override + public boolean isOpen() { + return socket.isConnected(); + } +} + +/** + * Listening L2CAP endpoint wrapping a platform BluetoothServerSocket. The + * blocking {@code accept()} runs on a dedicated daemon thread -- never on + * the EDT. + */ +class AndroidL2capServer extends L2capServer { + + private final int psm; + private final android.bluetooth.BluetoothServerSocket serverSocket; + private volatile boolean closed; + + AndroidL2capServer(int psm, + android.bluetooth.BluetoothServerSocket serverSocket) { + this.psm = psm; + this.serverSocket = serverSocket; + } + + @Override + public int getPsm() { + return psm; + } + + @Override + public AsyncResource accept() { + final AsyncResource out = + new AsyncResource(); + if (closed) { + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "The L2CAP server is closed")); + return out; + } + Thread t = new Thread(new Runnable() { + public void run() { + try { + android.bluetooth.BluetoothSocket s = serverSocket.accept(); + out.complete(new AndroidL2capChannel(psm, s)); + } catch (IOException ioe) { + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "L2CAP accept failed: " + ioe.getMessage(), ioe)); + } catch (Throwable ex) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "L2CAP accept failed: " + ex, ex)); + } + } + }, "CN1-L2CAP-accept"); + t.setDaemon(true); + t.start(); + return out; + } + + @Override + public void close() { + closed = true; + try { + serverSocket.close(); + } catch (Throwable ignore) { + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidRfcomm.java b/Ports/Android/src/com/codename1/impl/android/AndroidRfcomm.java new file mode 100644 index 00000000000..68a0e53575b --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidRfcomm.java @@ -0,0 +1,638 @@ +/* + * Copyright (c) 2012, 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.impl.android; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.BondState; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.classic.BluetoothClassic; +import com.codename1.bluetooth.classic.ClassicDiscovery; +import com.codename1.bluetooth.classic.ClassicDiscoveryListener; +import com.codename1.bluetooth.classic.ClassicScanResult; +import com.codename1.bluetooth.classic.RfcommConnection; +import com.codename1.bluetooth.classic.RfcommServer; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Android implementation of classic Bluetooth (BR/EDR): inquiry discovery + * via {@code BluetoothAdapter.startDiscovery} broadcasts, bonding, the + * discoverability system dialog and RFCOMM stream connections. All blocking + * socket work (connect/accept) runs on dedicated daemon threads -- never on + * the EDT. + */ +class AndroidRfcomm extends BluetoothClassic { + + AndroidRfcomm() { + } + + // ------------------------------------------------------------------ + // discovery + // ------------------------------------------------------------------ + + @Override + public ClassicDiscovery startDiscovery( + final ClassicDiscoveryListener listener) { + final android.bluetooth.BluetoothAdapter adapter = + AndroidBluetooth.adapter(); + final Context ctx = AndroidImplementation.getContext(); + final boolean[] stopped = new boolean[1]; + final BroadcastReceiver[] receiverRef = new BroadcastReceiver[1]; + final ClassicDiscovery handle = + makeClassicDiscovery(adapter, ctx, stopped, receiverRef); + if (listener == null) { + handle.error(new BluetoothException(BluetoothError.UNKNOWN, + "startDiscovery requires a listener")); + return handle; + } + if (adapter == null || ctx == null) { + handle.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Classic Bluetooth is not available on this device")); + return handle; + } + if (!adapter.isEnabled()) { + handle.error(new BluetoothException(BluetoothError.POWERED_OFF, + "The Bluetooth adapter is powered off")); + return handle; + } + final Context appCtx = ctx.getApplicationContext() != null + ? ctx.getApplicationContext() : ctx; + BroadcastReceiver receiver = + makeDiscoveryReceiver(listener, stopped, appCtx, handle); + receiverRef[0] = receiver; + IntentFilter filter = new IntentFilter(); + filter.addAction(android.bluetooth.BluetoothDevice.ACTION_FOUND); + filter.addAction( + android.bluetooth.BluetoothAdapter.ACTION_DISCOVERY_FINISHED); + AndroidBluetooth.registerSystemReceiver(appCtx, receiver, filter); + boolean started; + try { + started = adapter.startDiscovery(); + } catch (SecurityException se) { + unregisterQuietly(appCtx, receiver); + handle.error(new BluetoothException(BluetoothError.UNAUTHORIZED, + "Missing Bluetooth scan permission", se)); + return handle; + } + if (!started) { + unregisterQuietly(appCtx, receiver); + handle.error(new BluetoothException(BluetoothError.SCAN_FAILED, + "The platform failed to start the inquiry scan")); + } + return handle; + } + + // Static so the ClassicDiscovery doesn't carry a synthetic + // outer-AndroidRfcomm reference (SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON). + private static ClassicDiscovery makeClassicDiscovery( + final android.bluetooth.BluetoothAdapter adapter, + final Context ctx, final boolean[] stopped, + final BroadcastReceiver[] receiverRef) { + return new ClassicDiscovery() { + protected void onStop() { + synchronized (stopped) { + if (stopped[0]) { + return; + } + stopped[0] = true; + } + try { + adapter.cancelDiscovery(); + } catch (Throwable ignore) { + } + unregisterQuietly(ctx, receiverRef[0]); + } + }; + } + + // Static so the BroadcastReceiver doesn't carry a synthetic + // outer-AndroidRfcomm reference (SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON). + private static BroadcastReceiver makeDiscoveryReceiver( + final ClassicDiscoveryListener listener, final boolean[] stopped, + final Context appCtx, final ClassicDiscovery handle) { + return new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent intent) { + String action = intent.getAction(); + if (android.bluetooth.BluetoothDevice.ACTION_FOUND + .equals(action)) { + android.bluetooth.BluetoothDevice d = + (android.bluetooth.BluetoothDevice) intent + .getParcelableExtra( + android.bluetooth.BluetoothDevice.EXTRA_DEVICE); + if (d == null) { + return; + } + int rssi = intent.getShortExtra( + android.bluetooth.BluetoothDevice.EXTRA_RSSI, + Short.MIN_VALUE); + int major = 0; + int deviceClass = 0; + android.bluetooth.BluetoothClass cls = + (android.bluetooth.BluetoothClass) intent + .getParcelableExtra( + android.bluetooth.BluetoothDevice.EXTRA_CLASS); + if (cls != null) { + major = cls.getMajorDeviceClass(); + deviceClass = cls.getDeviceClass(); + } + final ClassicScanResult result = new ClassicScanResult( + new ClassicDevice(d), rssi, major, deviceClass); + Display.getInstance().callSerially(new Runnable() { + public void run() { + listener.deviceDiscovered(result); + } + }); + } else if (android.bluetooth.BluetoothAdapter + .ACTION_DISCOVERY_FINISHED.equals(action)) { + synchronized (stopped) { + if (stopped[0]) { + return; + } + stopped[0] = true; + } + unregisterQuietly(appCtx, this); + if (!handle.isDone()) { + handle.complete(Boolean.TRUE); + } + } + } + }; + } + + private static void unregisterQuietly(Context ctx, + BroadcastReceiver receiver) { + if (ctx == null || receiver == null) { + return; + } + Context appCtx = ctx.getApplicationContext() != null + ? ctx.getApplicationContext() : ctx; + try { + appCtx.unregisterReceiver(receiver); + } catch (Throwable ignore) { + } + } + + // ------------------------------------------------------------------ + // bonded devices / bonding / discoverability + // ------------------------------------------------------------------ + + @Override + public List getBondedDevices() { + ArrayList out = + new ArrayList(); + android.bluetooth.BluetoothAdapter adapter = + AndroidBluetooth.adapter(); + if (adapter == null) { + return out; + } + Set bonded; + try { + bonded = adapter.getBondedDevices(); + } catch (SecurityException se) { + return out; + } + if (bonded == null) { + return out; + } + for (android.bluetooth.BluetoothDevice d : bonded) { + int type; + try { + type = d.getType(); + } catch (SecurityException se) { + type = android.bluetooth.BluetoothDevice.DEVICE_TYPE_UNKNOWN; + } + if (type != android.bluetooth.BluetoothDevice.DEVICE_TYPE_LE) { + out.add(new ClassicDevice(d)); + } + } + return out; + } + + @Override + public AsyncResource createBond( + com.codename1.bluetooth.BluetoothDevice device) { + AsyncResource out = new AsyncResource(); + android.bluetooth.BluetoothAdapter adapter = + AndroidBluetooth.adapter(); + if (adapter == null || device == null) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Classic Bluetooth is not available on this device")); + return out; + } + android.bluetooth.BluetoothDevice platformDevice; + if (device instanceof ClassicDevice) { + platformDevice = ((ClassicDevice) device).device; + } else if (device instanceof AndroidBlePeripheral) { + platformDevice = ((AndroidBlePeripheral) device) + .getPlatformDevice(); + } else { + try { + platformDevice = adapter.getRemoteDevice(device.getAddress()); + } catch (IllegalArgumentException iae) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "Invalid Bluetooth address: " + device.getAddress())); + return out; + } + } + AndroidBluetooth.createBondImpl(platformDevice, out); + return out; + } + + @Override + public AsyncResource requestDiscoverable( + final int durationSeconds) { + final AsyncResource out = new AsyncResource(); + if (AndroidBluetooth.adapter() == null + || AndroidImplementation.getActivity() == null) { + out.complete(Boolean.FALSE); + return out; + } + Display.getInstance().callSerially( + makeRequestDiscoverableRunnable(durationSeconds, out)); + return out; + } + + // Static so the Runnable doesn't carry a synthetic outer-AndroidRfcomm + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static Runnable makeRequestDiscoverableRunnable( + final int durationSeconds, final AsyncResource out) { + return new Runnable() { + public void run() { + if (Build.VERSION.SDK_INT >= 31 + && !AndroidImplementation.checkForPermission( + AndroidBluetooth.PERMISSION_ADVERTISE, + "This is required to make the device " + + "discoverable")) { + out.complete(Boolean.FALSE); + return; + } + try { + Intent intent = new Intent( + android.bluetooth.BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); + if (durationSeconds > 0) { + intent.putExtra( + android.bluetooth.BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, + durationSeconds); + } + AndroidNativeUtil.startActivityForResult(intent, + new IntentResultListener() { + public void onActivityResult(int requestCode, + int resultCode, Intent data) { + // the result code is the granted duration, or + // RESULT_CANCELED when the user declined + out.complete( + resultCode != Activity.RESULT_CANCELED + ? Boolean.TRUE : Boolean.FALSE); + } + }); + } catch (RuntimeException ex) { + out.complete(Boolean.FALSE); + } + } + }; + } + + // ------------------------------------------------------------------ + // RFCOMM + // ------------------------------------------------------------------ + + @Override + public AsyncResource connect( + com.codename1.bluetooth.BluetoothDevice device, + BluetoothUuid serviceUuid, boolean secure) { + AsyncResource out = + new AsyncResource(); + if (device == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "connect requires a device")); + return out; + } + return connect(device.getAddress(), serviceUuid, secure); + } + + @Override + public AsyncResource connect(String address, + BluetoothUuid serviceUuid, final boolean secure) { + final AsyncResource out = + new AsyncResource(); + final android.bluetooth.BluetoothAdapter adapter = + AndroidBluetooth.adapter(); + if (adapter == null) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Classic Bluetooth is not available on this device")); + return out; + } + if (!adapter.isEnabled()) { + out.error(new BluetoothException(BluetoothError.POWERED_OFF, + "The Bluetooth adapter is powered off")); + return out; + } + final android.bluetooth.BluetoothDevice platformDevice; + try { + platformDevice = adapter.getRemoteDevice(address); + } catch (IllegalArgumentException iae) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "Invalid Bluetooth address: " + address)); + return out; + } + final java.util.UUID uuid = AndroidBluetooth.toPlatformUuid( + serviceUuid == null ? BluetoothUuid.SPP : serviceUuid); + Thread t = new Thread(makeConnectRunnable(adapter, platformDevice, + uuid, secure, out), "CN1-RFCOMM-connect"); + t.setDaemon(true); + t.start(); + return out; + } + + // Static so the Runnable doesn't carry a synthetic outer-AndroidRfcomm + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static Runnable makeConnectRunnable( + final android.bluetooth.BluetoothAdapter adapter, + final android.bluetooth.BluetoothDevice platformDevice, + final java.util.UUID uuid, final boolean secure, + final AsyncResource out) { + return new Runnable() { + public void run() { + android.bluetooth.BluetoothSocket socket = null; + try { + // an active inquiry scan badly degrades RFCOMM connects + try { + adapter.cancelDiscovery(); + } catch (Throwable ignore) { + } + if (secure) { + socket = platformDevice + .createRfcommSocketToServiceRecord(uuid); + } else { + socket = platformDevice + .createInsecureRfcommSocketToServiceRecord( + uuid); + } + socket.connect(); + out.complete(new AndroidRfcommConnection( + new ClassicDevice(platformDevice), socket)); + } catch (SecurityException se) { + closeQuietly(socket); + out.error(new BluetoothException( + BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + } catch (IOException ioe) { + closeQuietly(socket); + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "RFCOMM connect failed: " + ioe.getMessage(), + ioe)); + } catch (Throwable ex) { + closeQuietly(socket); + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "RFCOMM connect failed: " + ex, ex)); + } + } + }; + } + + @Override + public AsyncResource listen(final String serviceName, + BluetoothUuid serviceUuid, final boolean secure) { + final AsyncResource out = + new AsyncResource(); + final android.bluetooth.BluetoothAdapter adapter = + AndroidBluetooth.adapter(); + if (adapter == null) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Classic Bluetooth is not available on this device")); + return out; + } + final BluetoothUuid effectiveUuid = + serviceUuid == null ? BluetoothUuid.SPP : serviceUuid; + final java.util.UUID uuid = + AndroidBluetooth.toPlatformUuid(effectiveUuid); + Thread t = new Thread(makeListenRunnable(adapter, serviceName, + effectiveUuid, uuid, secure, out), "CN1-RFCOMM-listen"); + t.setDaemon(true); + t.start(); + return out; + } + + // Static so the Runnable doesn't carry a synthetic outer-AndroidRfcomm + // reference (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static Runnable makeListenRunnable( + final android.bluetooth.BluetoothAdapter adapter, + final String serviceName, final BluetoothUuid effectiveUuid, + final java.util.UUID uuid, final boolean secure, + final AsyncResource out) { + return new Runnable() { + public void run() { + try { + android.bluetooth.BluetoothServerSocket serverSocket; + String name = serviceName == null + ? "CodenameOne" : serviceName; + if (secure) { + serverSocket = adapter + .listenUsingRfcommWithServiceRecord(name, + uuid); + } else { + serverSocket = adapter + .listenUsingInsecureRfcommWithServiceRecord( + name, uuid); + } + out.complete(new AndroidRfcommServer(effectiveUuid, + serverSocket)); + } catch (SecurityException se) { + out.error(new BluetoothException( + BluetoothError.UNAUTHORIZED, + "Missing BLUETOOTH_CONNECT permission", se)); + } catch (IOException ioe) { + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "RFCOMM listen failed: " + ioe.getMessage(), + ioe)); + } catch (Throwable ex) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "RFCOMM listen failed: " + ex, ex)); + } + } + }; + } + + static void closeQuietly(android.bluetooth.BluetoothSocket socket) { + if (socket != null) { + try { + socket.close(); + } catch (Throwable ignore) { + } + } + } + + // ------------------------------------------------------------------ + // wrappers + // ------------------------------------------------------------------ + + /** Identity wrapper for a classic (or unknown transport) device. */ + static final class ClassicDevice + extends com.codename1.bluetooth.BluetoothDevice { + final android.bluetooth.BluetoothDevice device; + + ClassicDevice(android.bluetooth.BluetoothDevice device) { + this.device = device; + } + + @Override + public String getAddress() { + return device.getAddress(); + } + + @Override + public String getName() { + try { + return device.getName(); + } catch (SecurityException se) { + return null; + } + } + + @Override + public DeviceType getType() { + try { + return AndroidBluetooth.mapDeviceType(device.getType()); + } catch (SecurityException se) { + return DeviceType.UNKNOWN; + } + } + + @Override + public BondState getBondState() { + try { + return AndroidBluetooth.mapBondState(device.getBondState()); + } catch (SecurityException se) { + return BondState.NONE; + } + } + } +} + +/** RFCOMM stream connection over a platform BluetoothSocket. */ +class AndroidRfcommConnection extends RfcommConnection { + + private final android.bluetooth.BluetoothSocket socket; + + AndroidRfcommConnection(com.codename1.bluetooth.BluetoothDevice device, + android.bluetooth.BluetoothSocket socket) { + super(device); + this.socket = socket; + } + + @Override + public InputStream getInputStream() throws IOException { + return socket.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return socket.getOutputStream(); + } + + @Override + public void close() throws IOException { + socket.close(); + } + + @Override + public boolean isOpen() { + return socket.isConnected(); + } +} + +/** + * Listening RFCOMM endpoint over a platform BluetoothServerSocket. The + * blocking {@code accept()} runs on a dedicated daemon thread. + */ +class AndroidRfcommServer extends RfcommServer { + + private final android.bluetooth.BluetoothServerSocket serverSocket; + private volatile boolean closed; + + AndroidRfcommServer(BluetoothUuid serviceUuid, + android.bluetooth.BluetoothServerSocket serverSocket) { + super(serviceUuid); + this.serverSocket = serverSocket; + } + + @Override + public AsyncResource accept() { + final AsyncResource out = + new AsyncResource(); + if (closed) { + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "The RFCOMM server is closed")); + return out; + } + Thread t = new Thread(new Runnable() { + public void run() { + try { + android.bluetooth.BluetoothSocket socket = + serverSocket.accept(); + out.complete(new AndroidRfcommConnection( + new AndroidRfcomm.ClassicDevice( + socket.getRemoteDevice()), socket)); + } catch (IOException ioe) { + out.error(new BluetoothException(BluetoothError.IO_ERROR, + "RFCOMM accept failed: " + ioe.getMessage(), + ioe)); + } catch (Throwable ex) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "RFCOMM accept failed: " + ex, ex)); + } + } + }, "CN1-RFCOMM-accept"); + t.setDaemon(true); + t.start(); + return out; + } + + @Override + public void close() { + closed = true; + try { + serverSocket.close(); + } catch (Throwable ignore) { + } + } +} diff --git a/Ports/JavaSE/native/cn1-ble-helper/.gitignore b/Ports/JavaSE/native/cn1-ble-helper/.gitignore new file mode 100644 index 00000000000..ea8c4bf7f35 --- /dev/null +++ b/Ports/JavaSE/native/cn1-ble-helper/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Ports/JavaSE/native/cn1-ble-helper/Cargo.lock b/Ports/JavaSE/native/cn1-ble-helper/Cargo.lock new file mode 100644 index 00000000000..d9438fa496d --- /dev/null +++ b/Ports/JavaSE/native/cn1-ble-helper/Cargo.lock @@ -0,0 +1,958 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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 = "1.0.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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "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", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[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 = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +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 = "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.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" +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 = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "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 = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[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 = "xml" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Ports/JavaSE/native/cn1-ble-helper/Cargo.toml b/Ports/JavaSE/native/cn1-ble-helper/Cargo.toml new file mode 100644 index 00000000000..386b1606475 --- /dev/null +++ b/Ports/JavaSE/native/cn1-ble-helper/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cn1-ble-helper" +version = "1.0.0" +edition = "2021" +description = "Codename One JavaSE-port BLE helper: bridges the host radio (via btleplug) to the simulator over line-delimited JSON on stdin/stdout. See PROTOCOL.md." +license = "GPL-2.0-only WITH Classpath-exception-2.0" + +[[bin]] +name = "cn1-ble-helper" +path = "src/main.rs" + +[dependencies] +btleplug = "0.11" +tokio = { version = "1", features = ["macros", "rt", "io-std", "io-util", "sync", "time"] } +futures = "0.3" +serde_json = "1" +base64 = "0.22" +uuid = "1" + +[profile.release] +# Smaller helper = smaller javase jar shipped to apps. +opt-level = "z" +lto = true +codegen-units = 1 +strip = true diff --git a/Ports/JavaSE/native/cn1-ble-helper/PROTOCOL.md b/Ports/JavaSE/native/cn1-ble-helper/PROTOCOL.md new file mode 100644 index 00000000000..55805d4c2d9 --- /dev/null +++ b/Ports/JavaSE/native/cn1-ble-helper/PROTOCOL.md @@ -0,0 +1,151 @@ +# cn1-ble-helper JSON protocol (version 1) + +Communication between the JavaSE port's +`com.codename1.impl.javase.bluetooth.NativeBleBackend` and this Rust helper +executable (a [btleplug](https://github.com/deviceplug/btleplug) bridge: +CoreBluetooth on macOS, BlueZ over D-Bus on Linux, WinRT on Windows). + +One JSON object per line, UTF-8: commands on the helper's **stdin**, events +on its **stdout**. Helper diagnostics go to stderr and are pumped into the +JVM's `System.err` with a `[Cn1BleHelper]` prefix. + +## 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 a lowercase +string and both sides treat that opaque string as the wire `address`. +Service/characteristic/descriptor UUIDs are normalized to the lowercase +dashed 128-bit form (`0000180a-0000-1000-8000-00805f9b34fb`) in every event; +commands may also send the bare 16-/32-bit assigned-number form. Binary +values travel as standard base64. + +## Request/response model + +Every command carries a numeric `id` (positive, monotonic). The helper +answers each command with **exactly one terminal event** echoing that value +as `requestId` — either the success event listed below or an `error` event. +Events without a `requestId` are unsolicited (spontaneous state changes, +scan sightings, notifications, remote disconnects). + +## Commands (Java → helper) + +``` +{"cmd":"scanStart","id":1,"services":["",...]} → scanStarted +{"cmd":"scanStop","id":2} → scanStopped +{"cmd":"connect","id":3,"address":"..."} → connected +{"cmd":"disconnect","id":4,"address":"..."} → disconnected +{"cmd":"discover","id":5,"address":"..."} → discovered +{"cmd":"read","id":6,"address":"...","service":"...","characteristic":"..."} + → readResult +{"cmd":"write","id":7,"address":"...","service":"...","characteristic":"...", + "value":"","noResponse":false} → writeResult +{"cmd":"subscribe","id":8,"address":"...","service":"...","characteristic":"..."} + → subscribed +{"cmd":"unsubscribe","id":9,"address":"...","service":"...","characteristic":"..."} + → unsubscribed +{"cmd":"readDescriptor","id":10,"address":"...","service":"...", + "characteristic":"...","descriptor":"..."} → descriptorReadResult +{"cmd":"writeDescriptor","id":11,"address":"...","service":"...", + "characteristic":"...","descriptor":"...","value":""} + → descriptorWriteResult +{"cmd":"readRssi","id":12,"address":"..."} → rssiResult +{"cmd":"shutdown"} → helper exits 0 +``` + +`scanStart.services` is optional; when present only peripherals advertising +one of the listed service UUIDs are reported (btleplug `ScanFilter`). +`write.noResponse:true` selects write-without-response. + +## Terminal success events (helper → Java) + +``` +{"event":"scanStarted","requestId":1} +{"event":"scanStopped","requestId":2} +{"event":"connected","requestId":3,"address":"...","name":"..."} +{"event":"disconnected","requestId":4,"address":"..."} +{"event":"discovered","requestId":5,"address":"...","name":"...", + "services":[{"uuid":"...","primary":true, + "characteristics":[{"uuid":"...", + "properties":["broadcast"|"read"|"writeWithoutResponse"|"write"| + "notify"|"indicate"|"signedWrite"|"extendedProps",...], + "descriptors":[{"uuid":"..."},...]}]}]} +{"event":"readResult","requestId":6,"address":"...","service":"...", + "characteristic":"...","value":""} +{"event":"writeResult","requestId":7,"address":"...","service":"...", + "characteristic":"..."} +{"event":"subscribed","requestId":8,"address":"...","service":"...", + "characteristic":"..."} +{"event":"unsubscribed","requestId":9,"address":"...","service":"...", + "characteristic":"..."} +{"event":"descriptorReadResult","requestId":10,"address":"...","service":"...", + "characteristic":"...","descriptor":"...","value":""} +{"event":"descriptorWriteResult","requestId":11,"address":"...","service":"...", + "characteristic":"...","descriptor":"..."} +{"event":"rssiResult","requestId":12,"address":"...","rssi":-58,"source":"lastSeen"} +``` + +`rssiResult.source` is `"lastSeen"` — btleplug has no live RSSI read, so the +value is the most recent advertisement sighting (also declared in the +`capabilities.rssi` field). + +## Error event + +``` +{"event":"error","requestId":6,"command":"read","address":"...", + "code":"notConnected","message":"human-readable detail"} +``` + +`code` is one of: + +| code | meaning | Java maps to `BluetoothError` | +|-------------------------|----------------------------------------------------|-------------------------------| +| `notSupported` | feature absent on this platform/helper build | `NOT_SUPPORTED` | +| `unauthorized` | OS denied Bluetooth permission | `UNAUTHORIZED` | +| `poweredOff` | adapter is off | `POWERED_OFF` | +| `scanFailed` | the OS refused/aborted the scan | `SCAN_FAILED` | +| `connectFailed` | connect attempt failed | `CONNECTION_FAILED` | +| `notConnected` | GATT op on a disconnected peripheral | `NOT_CONNECTED` | +| `unknownPeripheral` | address never sighted by a scan | `CONNECTION_FAILED` | +| `unknownCharacteristic` | uuid not in the discovered database | `GATT_ERROR` | +| `unknownDescriptor` | uuid not in the discovered database | `GATT_ERROR` | +| `timeout` | the platform stack timed out | `TIMEOUT` | +| `badRequest` | malformed command (missing args, bad base64) | `UNKNOWN` | +| `ioError` | any other transport/stack failure | `IO_ERROR` | + +An `error` without a `requestId` is informational (e.g. the central event +stream failed) and is only logged by the Java side. + +## Unsolicited events + +``` +{"event":"capabilities","version":1,"helperVersion":"1.0.0", + "platform":"macos|linux|windows","descriptors":true,"rssi":"lastSeen", + "bonding":false} +{"event":"stateChanged","state":"poweredOn|poweredOff|unsupported|unknown"} +{"event":"scanResult","address":"...","name":"...","rssi":-45,"txPower":4, + "serviceUuids":["..."],"manufacturerData":{"76":""}, + "serviceData":{"":""}} +{"event":"notification","address":"...","service":"...", + "characteristic":"...","value":""} +{"event":"connected","address":"...","name":"..."} +{"event":"disconnected","address":"...","reason":"..."} +``` + +- `capabilities` is the **first line** the helper ever writes; the Java side + gates features (descriptor I/O, RSSI semantics, bonding) on it and can + reject an incompatible `version`. +- `stateChanged` fires at startup with either `poweredOn` (btleplug handed + us an adapter) or `unsupported` (no adapter / no BlueZ / permission + denied); afterwards it mirrors btleplug's `CentralEvent::StateUpdate` + stream on platforms that emit runtime transitions. +- `scanResult` sightings flow only while a scan is active, but the helper + keeps its peripheral cache fresh in the background so connect-by-address + works between scans. `txPower` is omitted when not advertised. +- Unsolicited `connected`/`disconnected` (no `requestId`) reflect + OS-observed link transitions — including remote disconnects. + +## Shutdown + +The Java side sends `{"cmd":"shutdown"}` and closes stdin; the helper exits +with status 0. If the JVM dies without warning, the helper detects stdin EOF +and exits 0 on its own — it never outlives the JVM. diff --git a/Ports/JavaSE/native/cn1-ble-helper/src/main.rs b/Ports/JavaSE/native/cn1-ble-helper/src/main.rs new file mode 100644 index 00000000000..380db9e0618 --- /dev/null +++ b/Ports/JavaSE/native/cn1-ble-helper/src/main.rs @@ -0,0 +1,1058 @@ +// cn1-ble-helper — cross-platform BLE bridge for the Codename One JavaSE +// port's native Bluetooth backend. See PROTOCOL.md (next to Cargo.toml) for +// the exact JSON-line command/event format shared with the Java side +// (com.codename1.impl.javase.bluetooth.NativeBleBackend). +// +// Architecture +// ------------ +// One tokio runtime, several concurrent tasks: +// * stdin reader -> parses JSON-lines into commands and spawns one task +// per command so slow BLE operations never block the +// command stream; +// * central events -> translates btleplug's CentralEvent stream into +// stateChanged / scanResult / connected / disconnected +// wire events; +// * per-peripheral notification pumps (one per connected peripheral) -> +// translate ValueNotification streams into +// "notification" events; +// * writer -> drains a single mpsc channel so every line is +// written atomically without locking stdout. +// +// Every command carries a numeric "id"; the helper answers with exactly one +// terminal event echoing it as "requestId" (either the success event for +// that command or an "error" event with a typed "code"). + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use btleplug::api::{ + Central, CentralEvent, CentralState, CharPropFlags, Manager as _, Peripheral as _, + ScanFilter, WriteType, +}; +use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; +use futures::stream::StreamExt; +use serde_json::{json, Map as JsonMap, 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; + +const PROTOCOL_VERSION: u64 = 1; + +// ---------------- wire 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 = JsonMap::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)); +} + +/// Success terminal event for the command carrying `request_id`. +fn emit_terminal(sink: &EventSink, name: &str, request_id: u64, payload: Value) { + let mut obj = JsonMap::new(); + obj.insert("event".into(), Value::String(name.into())); + obj.insert("requestId".into(), Value::from(request_id)); + if let Value::Object(extra) = payload { + for (k, v) in extra { + obj.insert(k, v); + } + } + emit(sink, Value::Object(obj)); +} + +/// Failure terminal event: `code` is one of the typed error codes from +/// PROTOCOL.md that the Java side maps onto BluetoothError values. +fn emit_error( + sink: &EventSink, + request_id: Option, + command: &str, + address: Option<&str>, + code: &str, + message: &str, +) { + let mut obj = JsonMap::new(); + obj.insert("event".into(), Value::String("error".into())); + if let Some(id) = request_id { + obj.insert("requestId".into(), Value::from(id)); + } + obj.insert("command".into(), Value::from(command)); + if let Some(a) = address { + obj.insert("address".into(), Value::from(a)); + } + obj.insert("code".into(), Value::from(code)); + obj.insert("message".into(), Value::from(message)); + emit(sink, Value::Object(obj)); +} + +/// Maps a btleplug failure to a wire error code. +fn error_code(e: &btleplug::Error) -> &'static str { + use btleplug::Error as E; + match e { + E::PermissionDenied => "unauthorized", + E::DeviceNotFound => "unknownPeripheral", + E::NotConnected => "notConnected", + E::NotSupported(_) => "notSupported", + E::TimedOut(_) => "timeout", + _ => "ioError", + } +} + +/// btleplug exposes a [`Uuid`]; the wire format is always lowercase dashed +/// 128-bit, which is exactly what `Uuid::to_string` produces. +fn fmt_uuid(u: &Uuid) -> String { + u.to_string() +} + +/// Normalize whatever string the Java side sends — the 128-bit dashed form +/// ("0000180a-…") or the bare 16-/32-bit assigned-number form ("180a") — +/// 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-/32-bit assigned number: expand using the Bluetooth Base UUID. + if s.len() <= 8 && s.chars().all(|c| c.is_ascii_hexdigit()) { + let padded = format!("{:0>8}-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 a `BluetoothAddress`. The `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() +} + +fn host_platform() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "unknown" + } +} + +/// The very first line the helper writes: what this build can do, so the +/// Java side gates features without trial-and-error round trips. +fn emit_capabilities(sink: &EventSink) { + emit_event( + sink, + "capabilities", + json!({ + "version": PROTOCOL_VERSION, + "helperVersion": env!("CARGO_PKG_VERSION"), + "platform": host_platform(), + "descriptors": true, + // btleplug has no live RSSI read; readRssi answers with the + // last value seen in an advertisement. + "rssi": "lastSeen", + // btleplug is central-only and has no bonding API. + "bonding": false, + }), + ); +} + +// ---------------- state ---------------- + +struct State { + adapter: Adapter, + peripherals: HashMap, + /// One notification pump per connected peripheral address. + notif_pumps: HashMap>, + scanning: bool, +} + +type SharedState = Arc>; + +async fn lookup_peripheral(state: &SharedState, address: &str) -> Option { + state.lock().await.peripherals.get(address).cloned() +} + +// ---------------- command dispatch ---------------- + +async fn handle_command(state: SharedState, sink: EventSink, cmd: Value) { + let 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 name.as_str() { + "scanStart" => cmd_scan_start(&state, &sink, id, &cmd).await, + "scanStop" => cmd_scan_stop(&state, &sink, id).await, + "connect" => cmd_connect(&state, &sink, id, &cmd).await, + "disconnect" => cmd_disconnect(&state, &sink, id, &cmd).await, + "discover" => cmd_discover(&state, &sink, id, &cmd).await, + "read" => cmd_read(&state, &sink, id, &cmd).await, + "write" => cmd_write(&state, &sink, id, &cmd).await, + "subscribe" => cmd_subscribe(&state, &sink, id, &cmd).await, + "unsubscribe" => cmd_unsubscribe(&state, &sink, id, &cmd).await, + "readDescriptor" => cmd_descriptor(&state, &sink, id, &cmd, false).await, + "writeDescriptor" => cmd_descriptor(&state, &sink, id, &cmd, true).await, + "readRssi" => cmd_read_rssi(&state, &sink, id, &cmd).await, + "shutdown" => std::process::exit(0), + other => { + eprintln!("cn1-ble-helper: unknown command '{}'", other); + emit_error( + &sink, + id, + other, + None, + "badRequest", + &format!("unknown command '{}'", other), + ); + } + } +} + +/// Extracts the "address" argument or answers with a badRequest error. +fn require_address<'a>( + sink: &EventSink, + id: Option, + command: &str, + cmd: &'a Value, +) -> Option<&'a str> { + match cmd.get("address").and_then(|v| v.as_str()) { + Some(a) => Some(a), + None => { + emit_error(sink, id, command, None, "badRequest", "missing address"); + None + } + } +} + +/// Extracts the peripheral for the command's "address" or answers with a +/// typed error. +async fn require_peripheral( + state: &SharedState, + sink: &EventSink, + id: Option, + command: &str, + cmd: &Value, +) -> Option<(Peripheral, String)> { + let address = require_address(sink, id, command, cmd)?.to_string(); + match lookup_peripheral(state, &address).await { + Some(p) => Some((p, address)), + None => { + emit_error( + sink, + id, + command, + Some(&address), + "unknownPeripheral", + "peripheral was never sighted by a scan", + ); + None + } + } +} + +async fn cmd_scan_start(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + 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(u) = v.as_str().and_then(parse_uuid) { + services.push(u); + } + } + } + let mut s = state.lock().await; + match s.adapter.start_scan(ScanFilter { services }).await { + Ok(()) => { + s.scanning = true; + drop(s); + if let Some(id) = id { + emit_terminal(sink, "scanStarted", id, json!({})); + } + } + Err(e) => { + drop(s); + emit_error(sink, id, "scanStart", None, "scanFailed", &e.to_string()); + } + } +} + +async fn cmd_scan_stop(state: &SharedState, sink: &EventSink, id: Option) { + let mut s = state.lock().await; + let res = s.adapter.stop_scan().await; + s.scanning = false; + drop(s); + match res { + Ok(()) => { + if let Some(id) = id { + emit_terminal(sink, "scanStopped", id, json!({})); + } + } + Err(e) => emit_error(sink, id, "scanStop", None, error_code(&e), &e.to_string()), + } +} + +async fn cmd_connect(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, address)) = require_peripheral(state, sink, id, "connect", cmd).await else { + return; + }; + match p.connect().await { + Ok(()) => { + ensure_notification_pump(state, sink, &address, &p).await; + let name = peripheral_name(&p).await; + if let Some(id) = id { + emit_terminal( + sink, + "connected", + id, + json!({"address": address, "name": name}), + ); + } + } + Err(e) => { + let code = match e { + btleplug::Error::TimedOut(_) => "timeout", + btleplug::Error::PermissionDenied => "unauthorized", + _ => "connectFailed", + }; + emit_error(sink, id, "connect", Some(&address), code, &e.to_string()); + } + } +} + +async fn cmd_disconnect(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, address)) = require_peripheral(state, sink, id, "disconnect", cmd).await else { + return; + }; + match p.disconnect().await { + Ok(()) => { + if let Some(id) = id { + emit_terminal(sink, "disconnected", id, json!({"address": address})); + } + } + Err(e) => emit_error( + sink, + id, + "disconnect", + Some(&address), + error_code(&e), + &e.to_string(), + ), + } +} + +async fn cmd_discover(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, address)) = require_peripheral(state, sink, id, "discover", cmd).await else { + return; + }; + if let Err(e) = p.discover_services().await { + emit_error( + sink, + id, + "discover", + Some(&address), + error_code(&e), + &e.to_string(), + ); + return; + } + let payload = build_discovered_payload(&p, &address).await; + if let Some(id) = id { + emit_terminal(sink, "discovered", id, payload); + } +} + +async fn build_discovered_payload(p: &Peripheral, address: &str) -> Value { + let name = peripheral_name(p).await; + 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::BROADCAST) { + props.push("broadcast"); + } + if flags.contains(CharPropFlags::READ) { + props.push("read"); + } + if flags.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) { + props.push("writeWithoutResponse"); + } + if flags.contains(CharPropFlags::WRITE) { + props.push("write"); + } + if flags.contains(CharPropFlags::NOTIFY) { + props.push("notify"); + } + if flags.contains(CharPropFlags::INDICATE) { + props.push("indicate"); + } + if flags.contains(CharPropFlags::AUTHENTICATED_SIGNED_WRITES) { + props.push("signedWrite"); + } + if flags.contains(CharPropFlags::EXTENDED_PROPERTIES) { + props.push("extendedProps"); + } + let descriptors: Vec = ch + .descriptors + .iter() + .map(|d| json!({"uuid": fmt_uuid(&d.uuid)})) + .collect(); + ch_arr.push(json!({ + "uuid": fmt_uuid(&ch.uuid), + "properties": props, + "descriptors": descriptors, + })); + } + svc_arr.push(json!({ + "uuid": fmt_uuid(&svc.uuid), + "primary": svc.primary, + "characteristics": ch_arr, + })); + } + json!({ + "address": address, + "name": name, + "services": svc_arr, + }) +} + +/// Locates the canonical btleplug characteristic for a command's +/// address/service/characteristic triple, answering a typed error when any +/// piece is missing. +async fn resolve_characteristic( + state: &SharedState, + sink: &EventSink, + id: Option, + command: &str, + cmd: &Value, +) -> Option<(Peripheral, btleplug::api::Characteristic, String, String, String)> { + let (p, address) = require_peripheral(state, sink, id, command, cmd).await?; + let svc_raw = cmd.get("service").and_then(|v| v.as_str()).unwrap_or(""); + let ch_raw = cmd + .get("characteristic") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let (Some(svc_uuid), Some(ch_uuid)) = (parse_uuid(svc_raw), parse_uuid(ch_raw)) else { + emit_error( + sink, + id, + command, + Some(&address), + "badRequest", + "missing or malformed service/characteristic uuid", + ); + return None; + }; + let found = p + .characteristics() + .into_iter() + .find(|c| c.service_uuid == svc_uuid && c.uuid == ch_uuid); + match found { + Some(c) => Some((p, c, address, fmt_uuid(&svc_uuid), fmt_uuid(&ch_uuid))), + None => { + emit_error( + sink, + id, + command, + Some(&address), + "unknownCharacteristic", + "characteristic not in the discovered database", + ); + None + } + } +} + +async fn cmd_read(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, ch, address, svc, chr)) = + resolve_characteristic(state, sink, id, "read", cmd).await + else { + return; + }; + match p.read(&ch).await { + Ok(value) => { + if let Some(id) = id { + emit_terminal( + sink, + "readResult", + id, + json!({ + "address": address, + "service": svc, + "characteristic": chr, + "value": B64.encode(&value), + }), + ); + } + } + Err(e) => emit_error( + sink, + id, + "read", + Some(&address), + error_code(&e), + &e.to_string(), + ), + } +} + +async fn cmd_write(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, ch, address, svc, chr)) = + resolve_characteristic(state, sink, id, "write", cmd).await + else { + return; + }; + let value_b64 = cmd.get("value").and_then(|v| v.as_str()).unwrap_or(""); + let Ok(value) = B64.decode(value_b64) else { + emit_error( + sink, + id, + "write", + Some(&address), + "badRequest", + "value is not valid base64", + ); + 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 + }; + match p.write(&ch, &value, write_type).await { + Ok(()) => { + if let Some(id) = id { + emit_terminal( + sink, + "writeResult", + id, + json!({ + "address": address, + "service": svc, + "characteristic": chr, + }), + ); + } + } + Err(e) => emit_error( + sink, + id, + "write", + Some(&address), + error_code(&e), + &e.to_string(), + ), + } +} + +async fn cmd_subscribe(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, ch, address, svc, chr)) = + resolve_characteristic(state, sink, id, "subscribe", cmd).await + else { + return; + }; + match p.subscribe(&ch).await { + Ok(()) => { + ensure_notification_pump(state, sink, &address, &p).await; + if let Some(id) = id { + emit_terminal( + sink, + "subscribed", + id, + json!({ + "address": address, + "service": svc, + "characteristic": chr, + }), + ); + } + } + Err(e) => emit_error( + sink, + id, + "subscribe", + Some(&address), + error_code(&e), + &e.to_string(), + ), + } +} + +async fn cmd_unsubscribe(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, ch, address, svc, chr)) = + resolve_characteristic(state, sink, id, "unsubscribe", cmd).await + else { + return; + }; + match p.unsubscribe(&ch).await { + Ok(()) => { + if let Some(id) = id { + emit_terminal( + sink, + "unsubscribed", + id, + json!({ + "address": address, + "service": svc, + "characteristic": chr, + }), + ); + } + } + Err(e) => emit_error( + sink, + id, + "unsubscribe", + Some(&address), + error_code(&e), + &e.to_string(), + ), + } +} + +async fn cmd_descriptor( + state: &SharedState, + sink: &EventSink, + id: Option, + cmd: &Value, + write: bool, +) { + let command = if write { "writeDescriptor" } else { "readDescriptor" }; + let Some((p, ch, address, svc, chr)) = + resolve_characteristic(state, sink, id, command, cmd).await + else { + return; + }; + let desc_raw = cmd.get("descriptor").and_then(|v| v.as_str()).unwrap_or(""); + let Some(desc_uuid) = parse_uuid(desc_raw) else { + emit_error( + sink, + id, + command, + Some(&address), + "badRequest", + "missing or malformed descriptor uuid", + ); + return; + }; + let Some(descriptor) = ch.descriptors.iter().find(|d| d.uuid == desc_uuid).cloned() else { + emit_error( + sink, + id, + command, + Some(&address), + "unknownDescriptor", + "descriptor not in the discovered database", + ); + return; + }; + let base = json!({ + "address": address, + "service": svc, + "characteristic": chr, + "descriptor": fmt_uuid(&desc_uuid), + }); + if write { + let value_b64 = cmd.get("value").and_then(|v| v.as_str()).unwrap_or(""); + let Ok(value) = B64.decode(value_b64) else { + emit_error( + sink, + id, + command, + Some(&address), + "badRequest", + "value is not valid base64", + ); + return; + }; + match p.write_descriptor(&descriptor, &value).await { + Ok(()) => { + if let Some(id) = id { + emit_terminal(sink, "descriptorWriteResult", id, base); + } + } + Err(e) => emit_error( + sink, + id, + command, + Some(&address), + error_code(&e), + &e.to_string(), + ), + } + } else { + match p.read_descriptor(&descriptor).await { + Ok(value) => { + if let Some(id) = id { + let mut payload = base; + payload["value"] = Value::from(B64.encode(&value)); + emit_terminal(sink, "descriptorReadResult", id, payload); + } + } + Err(e) => emit_error( + sink, + id, + command, + Some(&address), + error_code(&e), + &e.to_string(), + ), + } + } +} + +async fn cmd_read_rssi(state: &SharedState, sink: &EventSink, id: Option, cmd: &Value) { + let Some((p, address)) = require_peripheral(state, sink, id, "readRssi", cmd).await else { + return; + }; + // btleplug exposes no live RSSI read; answer with the last value seen in + // an advertisement (declared as rssi="lastSeen" in the capabilities). + let rssi = p.properties().await.ok().flatten().and_then(|p| p.rssi); + match rssi { + Some(rssi) => { + if let Some(id) = id { + emit_terminal( + sink, + "rssiResult", + id, + json!({"address": address, "rssi": rssi, "source": "lastSeen"}), + ); + } + } + None => emit_error( + sink, + id, + "readRssi", + Some(&address), + "notSupported", + "no RSSI observed for this peripheral", + ), + } +} + +async fn peripheral_name(p: &Peripheral) -> String { + p.properties() + .await + .ok() + .flatten() + .and_then(|p| p.local_name) + .unwrap_or_default() +} + +// ---------------- notification pump ---------------- + +/// Starts (once per peripheral) the task that forwards ValueNotifications +/// as "notification" events. btleplug delivers all subscribed +/// characteristics of a peripheral through a single stream. +async fn ensure_notification_pump( + state: &SharedState, + sink: &EventSink, + address: &str, + peripheral: &Peripheral, +) { + let mut s = state.lock().await; + if let Some(existing) = s.notif_pumps.get(address) { + if !existing.is_finished() { + return; + } + } + let p = peripheral.clone(); + let addr = address.to_string(); + let sink = sink.clone(); + let task = tokio::spawn(async move { + let mut stream = match p.notifications().await { + Ok(stream) => stream, + Err(e) => { + eprintln!( + "cn1-ble-helper: notification stream failed for {}: {}", + addr, e + ); + return; + } + }; + while let Some(n) = stream.next().await { + let service = p + .characteristics() + .into_iter() + .find(|c| c.uuid == n.uuid) + .map(|c| fmt_uuid(&c.service_uuid)) + .unwrap_or_default(); + emit_event( + &sink, + "notification", + json!({ + "address": addr, + "service": service, + "characteristic": fmt_uuid(&n.uuid), + "value": B64.encode(&n.value), + }), + ); + } + }); + s.notif_pumps.insert(address.to_string(), task); +} + +// ---------------- central-event listener ---------------- + +fn central_state_name(state: CentralState) -> &'static str { + match state { + CentralState::PoweredOn => "poweredOn", + CentralState::PoweredOff => "poweredOff", + _ => "unknown", + } +} + +async fn emit_scan_result(sink: &EventSink, address: &str, p: &Peripheral) { + let props = p.properties().await.ok().flatten(); + let mut obj = JsonMap::new(); + obj.insert("address".into(), Value::from(address)); + match props { + Some(prop) => { + obj.insert( + "name".into(), + Value::from(prop.local_name.unwrap_or_default()), + ); + obj.insert("rssi".into(), Value::from(prop.rssi.unwrap_or(-127))); + if let Some(tx) = prop.tx_power_level { + obj.insert("txPower".into(), Value::from(tx)); + } + let services: Vec = prop + .services + .iter() + .map(|u| Value::from(fmt_uuid(u))) + .collect(); + obj.insert("serviceUuids".into(), Value::from(services)); + let mut manuf = JsonMap::new(); + for (company, data) in &prop.manufacturer_data { + manuf.insert(company.to_string(), Value::from(B64.encode(data))); + } + obj.insert("manufacturerData".into(), Value::Object(manuf)); + let mut svc_data = JsonMap::new(); + for (uuid, data) in &prop.service_data { + svc_data.insert(fmt_uuid(uuid), Value::from(B64.encode(data))); + } + obj.insert("serviceData".into(), Value::Object(svc_data)); + } + None => { + obj.insert("name".into(), Value::from("")); + obj.insert("rssi".into(), Value::from(-127)); + } + } + emit_event(sink, "scanResult", Value::Object(obj)); +} + +async fn central_event_loop(state: SharedState, sink: EventSink, adapter: Adapter) { + let mut events = match adapter.events().await { + Ok(e) => e, + Err(err) => { + emit_error( + &sink, + None, + "adapterEvents", + None, + "ioError", + &err.to_string(), + ); + return; + } + }; + while let Some(event) = events.next().await { + match event { + CentralEvent::StateUpdate(new_state) => { + emit_event( + &sink, + "stateChanged", + json!({"state": central_state_name(new_state)}), + ); + } + CentralEvent::DeviceDiscovered(id) | CentralEvent::DeviceUpdated(id) => { + let address = id_to_address(&id); + let Ok(p) = adapter.peripheral(&id).await else { + continue; + }; + let scanning = { + let mut s = state.lock().await; + s.peripherals.insert(address.clone(), p.clone()); + s.scanning + }; + // The peripheral cache is refreshed even between scans (so + // connect-by-address keeps working) but sightings only flow + // to the Java side while a scan is active. + if scanning { + emit_scan_result(&sink, &address, &p).await; + } + } + CentralEvent::DeviceConnected(id) => { + let address = id_to_address(&id); + let name = match adapter.peripheral(&id).await { + Ok(p) => peripheral_name(&p).await, + Err(_) => String::new(), + }; + emit_event( + &sink, + "connected", + json!({"address": address, "name": name}), + ); + } + CentralEvent::DeviceDisconnected(id) => { + let address = id_to_address(&id); + // Tear down the notification pump tied to this address. + let mut s = state.lock().await; + if let Some(task) = s.notif_pumps.remove(&address) { + task.abort(); + } + 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.trim().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 — the JVM is gone; exit cleanly. + 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 mut stdout = tokio::io::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> { + let (tx, rx) = mpsc::unbounded_channel::(); + tokio::spawn(writer_loop(rx)); + emit_capabilities(&tx); + + // 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 without BT hardware + // hit this all the time. + 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) => { + // Surface the underlying reason on stderr for diagnostics; 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. + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + loop { + match reader.next_line().await { + Ok(Some(_)) => continue, + _ => return Ok(()), + } + } + } + }; + + let state = Arc::new(Mutex::new(State { + adapter: adapter.clone(), + peripherals: HashMap::new(), + notif_pumps: HashMap::new(), + scanning: false, + })); + + tokio::spawn(central_event_loop(state.clone(), tx.clone(), adapter)); + + // Having an adapter at all is our "ready" signal; platforms that stream + // true state transitions keep updating via CentralEvent::StateUpdate. + emit_event(&tx, "stateChanged", json!({"state": "poweredOn"})); + + stdin_loop(state, tx).await; + Ok(()) +} diff --git a/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties new file mode 100644 index 00000000000..b6a76b53e35 --- /dev/null +++ b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties @@ -0,0 +1,31 @@ +# Simulator hooks of the JavaSE port's simulated Bluetooth stack. +# Loaded by com.codename1.impl.javase.simulator.SimulatorHookLoader; the +# labeled items surface as the simulator's "Bluetooth" menu, every item is +# also callable cross-platform via CN.execute("bluetooth:itemN"). +name=Bluetooth +namespace=bluetooth + +item1=com.codename1.impl.javase.BluetoothSimulatorHooks#toggleAdapter +label1=Toggle Adapter + +item2=com.codename1.impl.javase.BluetoothSimulatorHooks#addDemoPeripheral +label2=Add Demo Peripheral + +item3=com.codename1.impl.javase.BluetoothSimulatorHooks#pushDemoNotification +label3=Push Demo Notification + +item4=com.codename1.impl.javase.BluetoothSimulatorHooks#disconnectAll +label4=Disconnect All + +item5=com.codename1.impl.javase.BluetoothSimulatorHooks#clearPeripherals +label5=Clear Peripherals + +item6=com.codename1.impl.javase.BluetoothSimulatorHooks#switchToSimulatorBackend +label6=Use Simulator Backend + +item7=com.codename1.impl.javase.BluetoothSimulatorHooks#switchToNativeBackend +label7=Use Native Backend + +# API-only (no label): arms the next GATT read to fail. For tests/scripts: +# CN.execute("bluetooth:item8") +item8=com.codename1.impl.javase.BluetoothSimulatorHooks#primeReadFailure diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BluetoothSimulation.java b/Ports/JavaSE/src/com/codename1/impl/javase/BluetoothSimulation.java new file mode 100644 index 00000000000..9c996ec6f47 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BluetoothSimulation.java @@ -0,0 +1,1564 @@ +/* + * Copyright (c) 2012, 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.impl.javase; + +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.server.GattLocalCharacteristic; +import com.codename1.bluetooth.le.server.GattLocalDescriptor; +import com.codename1.bluetooth.le.server.GattLocalService; +import com.codename1.impl.javase.bluetooth.BluetoothFixture; +import com.codename1.impl.javase.bluetooth.BluetoothSimulator; +import com.codename1.impl.javase.bluetooth.FixtureRecorder; +import com.codename1.impl.javase.bluetooth.JavaSEBluetooth; +import com.codename1.impl.javase.bluetooth.SimulatedBluetoothStack; +import com.codename1.impl.javase.bluetooth.StackEventListener; +import com.codename1.impl.javase.bluetooth.VirtualCharacteristic; +import com.codename1.impl.javase.bluetooth.VirtualDescriptor; +import com.codename1.impl.javase.bluetooth.VirtualPeripheral; +import com.codename1.impl.javase.bluetooth.VirtualService; + +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Toolkit; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.prefs.Preferences; + +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.JToolBar; +import javax.swing.JTree; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.TreeSelectionEvent; +import javax.swing.event.TreeSelectionListener; +import javax.swing.table.DefaultTableModel; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import javax.swing.tree.TreeSelectionModel; + +/** + * Simulator window over the virtual Bluetooth world managed by + * {@link BluetoothSimulator}: a tree of the simulated adapter, virtual + * peripherals and the app's own peripheral role, detail editors for the + * selected node, and a live event log fed by a {@link StackEventListener}. + * Everything the window does goes through the scriptable + * {@code com.codename1.impl.javase.bluetooth} facade, so anything staged + * here can equally be staged from tests. + * + *

Swing runs on the AWT EDT while the stack dispatches its callbacks on + * its own scheduler thread; every stack-originated UI update is marshaled + * through {@link SwingUtilities#invokeLater(Runnable)}.

+ */ +public class BluetoothSimulation extends JFrame { + + private static final int MAX_LOG_ROWS = 2000; + + /** Operation keys accepted by {@code SimulatedBluetoothStack.failNext}. */ + private static final String[] FAILURE_OPS = { + "connect", "disconnect", "read", "write", "discover", "subscribe", + "scan", "rssi", "mtu", "bond", "rfcommConnect", "l2cap" + }; + + private static final String CARD_EMPTY = "empty"; + private static final String CARD_ADAPTER = "adapter"; + private static final String CARD_PERIPHERAL = "peripheral"; + private static final String CARD_CHARACTERISTIC = "characteristic"; + private static final String CARD_INFO = "info"; + + private final Preferences pref = + Preferences.userNodeForPackage(BluetoothSimulation.class); + + // ------------------------------------------------------------------ + // tree + // ------------------------------------------------------------------ + + private final DefaultMutableTreeNode rootNode = + new DefaultMutableTreeNode("Bluetooth"); + private final DefaultTreeModel treeModel = new DefaultTreeModel(rootNode); + private final JTree tree = new JTree(treeModel); + private final Timer treeRefreshTimer; + + // ------------------------------------------------------------------ + // right-hand cards + // ------------------------------------------------------------------ + + private final CardLayout cardLayout = new CardLayout(); + private final JPanel cards = new JPanel(cardLayout); + private boolean updatingUi; + private boolean rebuildingTree; + private String shownCardKey; + + // adapter card + private final JCheckBox adapterEnabledCheck = + new JCheckBox("Adapter enabled"); + private final JSpinner latencySpinner = new JSpinner( + new SpinnerNumberModel( + (int) SimulatedBluetoothStack.DEFAULT_LATENCY_MILLIS, + 0, 60000, 10)); + private final JComboBox failureOpCombo = + new JComboBox(FAILURE_OPS); + private final JComboBox failureErrorCombo = + new JComboBox(BluetoothError.values()); + private final JTextField failureMessageField = new JTextField(20); + private final JComboBox backendCombo = new JComboBox( + new String[] {JavaSEBluetooth.BACKEND_SIMULATOR, + JavaSEBluetooth.BACKEND_NATIVE}); + + // peripheral card + private final JLabel peripheralAddressLabel = new JLabel(); + private final JTextField peripheralNameField = new JTextField(20); + private final JSpinner peripheralRssiSpinner = + new JSpinner(new SpinnerNumberModel(-60, -127, 20, 1)); + private final JCheckBox peripheralConnectableCheck = + new JCheckBox("Connectable"); + private final JLabel peripheralStateLabel = new JLabel(" "); + private VirtualPeripheral selectedPeripheral; + + // characteristic card + private final JLabel characteristicTitleLabel = new JLabel(); + private final JLabel characteristicSubscribedLabel = new JLabel(" "); + private final JTextArea characteristicValueArea = new JTextArea(4, 30); + private final JRadioButton hexModeRadio = new JRadioButton("Hex", true); + private final JRadioButton utf8ModeRadio = new JRadioButton("UTF-8"); + private String selectedCharacteristicAddress; + private BluetoothUuid selectedCharacteristicService; + private VirtualCharacteristic selectedCharacteristic; + + // generic info card (services, descriptors, app-as-peripheral nodes) + private final JTextArea infoArea = new JTextArea(); + + // ------------------------------------------------------------------ + // event log + // ------------------------------------------------------------------ + + private final DefaultTableModel logModel = new DefaultTableModel( + new Object[] {"Time", "Operation", "Detail"}, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + private final JTable logTable = new JTable(logModel); + private final SimpleDateFormat timeFormat = + new SimpleDateFormat("HH:mm:ss.SSS"); + private StackEventListener stackListener; + + /** + * Central subscriptions observed through the event feed while the + * window is open ({@code centralSubscribe} events); keyed by central + * address. The stack has no introspection API for these yet. + */ + private final Map> centralSubscriptions = + new HashMap>(); + + public BluetoothSimulation() { + super("Bluetooth Simulation"); + setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); + setLayout(new BorderLayout()); + + applyPersistedStackPrefs(); + + treeRefreshTimer = new Timer(200, new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + refreshTree(); + } + }); + treeRefreshTimer.setRepeats(false); + + add(buildToolbar(), BorderLayout.NORTH); + + tree.setRootVisible(false); + tree.setShowsRootHandles(true); + tree.getSelectionModel().setSelectionMode( + TreeSelectionModel.SINGLE_TREE_SELECTION); + tree.addTreeSelectionListener(new TreeSelectionListener() { + @Override + public void valueChanged(TreeSelectionEvent e) { + showCardForSelection(); + } + }); + JScrollPane treeScroll = new JScrollPane(tree); + treeScroll.setPreferredSize(new Dimension(320, 380)); + + buildCards(); + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, + treeScroll, cards); + split.setResizeWeight(0.35); + + JSplitPane vertical = new JSplitPane(JSplitPane.VERTICAL_SPLIT, + split, buildLogPanel()); + vertical.setResizeWeight(0.7); + add(vertical, BorderLayout.CENTER); + + addComponentListener(new ComponentAdapter() { + @Override + public void componentShown(ComponentEvent e) { + attachStackListener(); + refreshTree(); + } + + @Override + public void componentHidden(ComponentEvent e) { + detachStackListener(); + } + + @Override + public void componentMoved(ComponentEvent e) { + persistBounds(); + } + + @Override + public void componentResized(ComponentEvent e) { + persistBounds(); + } + }); + + refreshTree(); + expandDefaultRows(); + + pack(); + restoreBounds(); + } + + // ------------------------------------------------------------------ + // toolbar + // ------------------------------------------------------------------ + + private JToolBar buildToolbar() { + JToolBar toolbar = new JToolBar(); + toolbar.setFloatable(false); + toolbar.add(button("Add Peripheral", new Runnable() { + @Override + public void run() { + showAddPeripheralDialog(); + } + })); + toolbar.add(button("Add Demo Peripheral", new Runnable() { + @Override + public void run() { + BluetoothSimulator.addPeripheral( + BluetoothSimulatorHooks.createDemoPeripheral()); + scheduleTreeRefresh(); + } + })); + toolbar.add(button("Record from real hardware", new Runnable() { + @Override + public void run() { + showRecordFixtureDialog(); + } + })); + toolbar.addSeparator(); + toolbar.add(button("Reset", new Runnable() { + @Override + public void run() { + int answer = JOptionPane.showConfirmDialog( + BluetoothSimulation.this, + "Reset the simulated Bluetooth stack?\nAll " + + "peripherals, connections and scripted " + + "failures are cleared.", + "Reset Bluetooth Simulation", + JOptionPane.OK_CANCEL_OPTION); + if (answer == JOptionPane.OK_OPTION) { + centralSubscriptions.clear(); + BluetoothSimulator.reset(); + scheduleTreeRefresh(); + } + } + })); + return toolbar; + } + + private JButton button(String label, final Runnable action) { + JButton b = new JButton(label); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + action.run(); + } + }); + return b; + } + + private void showAddPeripheralDialog() { + JTextField address = new JTextField("00:11:22:33:44:55", 17); + JTextField name = new JTextField("Virtual Device", 17); + JSpinner rssi = new JSpinner(new SpinnerNumberModel(-60, -127, 20, 1)); + JCheckBox demoService = new JCheckBox( + "Include demo GATT service (0x180D)", true); + + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(2, 4, 2, 4); + gbc.anchor = GridBagConstraints.WEST; + addRow(panel, gbc, 0, "Address:", address); + addRow(panel, gbc, 1, "Name:", name); + addRow(panel, gbc, 2, "RSSI (dBm):", rssi); + gbc.gridx = 0; + gbc.gridy = 3; + gbc.gridwidth = 2; + panel.add(demoService, gbc); + + int answer = JOptionPane.showConfirmDialog(this, panel, + "Add Peripheral", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE); + if (answer != JOptionPane.OK_OPTION) { + return; + } + try { + VirtualPeripheral p = + new VirtualPeripheral(address.getText().trim()) + .setName(name.getText().trim()) + .setRssi(((Number) rssi.getValue()).intValue()); + if (demoService.isSelected()) { + p.addAdvertisedServiceUuid( + BluetoothSimulatorHooks.DEMO_SERVICE); + p.withService(BluetoothSimulatorHooks.createDemoService()); + } + BluetoothSimulator.addPeripheral(p); + scheduleTreeRefresh(); + } catch (IllegalArgumentException ex) { + JOptionPane.showMessageDialog(this, ex.getMessage(), + "Invalid peripheral", JOptionPane.ERROR_MESSAGE); + } + } + + /** + * "Record from real hardware": scans this machine's real radio for a + * chosen duration through a fresh {@code NativeBleBackend} + * ({@link FixtureRecorder}), scrambles the trace with the chosen seed + * ({@code FixtureScrambler}) and imports the resulting devices as + * virtual peripherals -- optionally also saving the fixture JSON. + * The capture runs on a worker thread; Swing only shows the results. + */ + private void showRecordFixtureDialog() { + JSpinner duration = new JSpinner( + new SpinnerNumberModel(10, 1, 300, 1)); + JTextField seedField = new JTextField("42", 12); + final JCheckBox saveCheck = new JCheckBox("Save fixture JSON to:"); + JTextField savePath = new JTextField(new java.io.File( + System.getProperty("user.home", "."), + "bluetooth-fixture.json").getAbsolutePath(), 28); + + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(2, 4, 2, 4); + gbc.anchor = GridBagConstraints.WEST; + addRow(panel, gbc, 0, "Scan duration (s):", duration); + addRow(panel, gbc, 1, "Scramble seed:", seedField); + addRow(panel, gbc, 2, "", saveCheck); + addRow(panel, gbc, 3, "", savePath); + + int answer = JOptionPane.showConfirmDialog(this, panel, + "Record from real hardware", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE); + if (answer != JOptionPane.OK_OPTION) { + return; + } + final long seed; + try { + seed = Long.parseLong(seedField.getText().trim()); + } catch (NumberFormatException ex) { + JOptionPane.showMessageDialog(this, + "The scramble seed must be a number", + "Invalid seed", JOptionPane.ERROR_MESSAGE); + return; + } + final long scanMillis = + ((Number) duration.getValue()).longValue() * 1000L; + final String saveTo = saveCheck.isSelected() + ? savePath.getText().trim() : null; + Thread worker = new Thread(new Runnable() { + @Override + public void run() { + recordFixture(scanMillis, seed, saveTo); + } + }, "cn1-bluetooth-fixture-recorder"); + worker.setDaemon(true); + worker.start(); + } + + /** Worker-thread body of the record-from-real-hardware flow. */ + private void recordFixture(long scanMillis, long seed, String saveTo) { + FixtureRecorder recorder = null; + try { + recorder = FixtureRecorder.forNativeBackend(); + BluetoothFixture fixture = recorder.recordScrambled( + scanMillis, null, false, seed); + if (saveTo != null && saveTo.length() > 0) { + java.io.Writer w = new java.io.OutputStreamWriter( + new java.io.FileOutputStream(saveTo), "UTF-8"); + try { + w.write(fixture.toJson()); + } finally { + w.close(); + } + } + BluetoothSimulator.loadFixture(fixture); + final int count = fixture.getDevices().size(); + final String path = saveTo; + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + scheduleTreeRefresh(); + JOptionPane.showMessageDialog(BluetoothSimulation.this, + "Recorded " + count + " device(s) from the " + + "real radio (identities scrambled)." + + "\nTheir advertisement timelines are " + + "replaying into the simulation now." + + (path == null ? "" + : "\nFixture saved to " + path), + "Recording complete", + JOptionPane.INFORMATION_MESSAGE); + } + }); + } catch (final Exception ex) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + JOptionPane.showMessageDialog(BluetoothSimulation.this, + "Recording from the real radio failed:\n" + + ex.getMessage() + + "\n\nThe native backend needs the " + + "bundled cn1-ble-helper binary and " + + "OS Bluetooth permission for the " + + "JVM process.", + "Recording failed", JOptionPane.ERROR_MESSAGE); + } + }); + } finally { + if (recorder != null) { + recorder.close(); + } + } + } + + private void addRow(JPanel panel, GridBagConstraints gbc, int row, + String label, Component field) { + gbc.gridwidth = 1; + gbc.gridx = 0; + gbc.gridy = row; + panel.add(new JLabel(label), gbc); + gbc.gridx = 1; + panel.add(field, gbc); + } + + // ------------------------------------------------------------------ + // tree building + // ------------------------------------------------------------------ + + /** Marker user objects carrying a stable key for expansion restore. */ + private abstract static class Node { + abstract String key(); + } + + private static final class AdapterNode extends Node { + final boolean enabled; + + AdapterNode(boolean enabled) { + this.enabled = enabled; + } + + @Override + String key() { + return "adapter"; + } + + @Override + public String toString() { + return "Adapter (" + (enabled ? "on" : "off") + ")"; + } + } + + private static final class PeripheralNode extends Node { + final VirtualPeripheral peripheral; + final boolean connected; + + PeripheralNode(VirtualPeripheral peripheral, boolean connected) { + this.peripheral = peripheral; + this.connected = connected; + } + + @Override + String key() { + return "p:" + peripheral.getAddress(); + } + + @Override + public String toString() { + String name = peripheral.getName(); + return peripheral.getAddress() + + (name == null ? "" : " — " + name) + + (connected ? " [connected]" : ""); + } + } + + private static final class ServiceNode extends Node { + final String address; + final VirtualService service; + + ServiceNode(String address, VirtualService service) { + this.address = address; + this.service = service; + } + + @Override + String key() { + return "s:" + address + ":" + service.getUuid(); + } + + @Override + public String toString() { + return "Service " + uuidLabel(service.getUuid()) + + (service.isPrimary() ? "" : " (secondary)"); + } + } + + private static final class CharacteristicNode extends Node { + final String address; + final BluetoothUuid serviceUuid; + final VirtualCharacteristic characteristic; + + CharacteristicNode(String address, BluetoothUuid serviceUuid, + VirtualCharacteristic characteristic) { + this.address = address; + this.serviceUuid = serviceUuid; + this.characteristic = characteristic; + } + + @Override + String key() { + return "c:" + address + ":" + serviceUuid + ":" + + characteristic.getUuid(); + } + + @Override + public String toString() { + StringBuilder props = new StringBuilder(); + if (characteristic.canRead()) { + props.append("R"); + } + if (characteristic.canWrite()) { + props.append("W"); + } + if (characteristic.canNotifyOrIndicate()) { + props.append("N"); + } + return "Characteristic " + uuidLabel(characteristic.getUuid()) + + (props.length() == 0 ? "" : " [" + props + "]"); + } + } + + private static final class DescriptorNode extends Node { + final String address; + final BluetoothUuid serviceUuid; + final BluetoothUuid characteristicUuid; + final VirtualDescriptor descriptor; + + DescriptorNode(String address, BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, + VirtualDescriptor descriptor) { + this.address = address; + this.serviceUuid = serviceUuid; + this.characteristicUuid = characteristicUuid; + this.descriptor = descriptor; + } + + @Override + String key() { + return "d:" + address + ":" + serviceUuid + ":" + + characteristicUuid + ":" + descriptor.getUuid(); + } + + @Override + public String toString() { + return "Descriptor " + uuidLabel(descriptor.getUuid()); + } + } + + private static final class AppNode extends Node { + @Override + String key() { + return "app"; + } + + @Override + public String toString() { + return "App as Peripheral"; + } + } + + private static final class InfoNode extends Node { + final String label; + final String keySuffix; + final String details; + + InfoNode(String keySuffix, String label, String details) { + this.keySuffix = keySuffix; + this.label = label; + this.details = details; + } + + @Override + String key() { + return "i:" + keySuffix; + } + + @Override + public String toString() { + return label; + } + } + + private static String uuidLabel(BluetoothUuid uuid) { + if (uuid.isShortUuid()) { + return String.format("0x%04X", Integer.valueOf( + uuid.getShortValue())); + } + return uuid.toString(); + } + + private void scheduleTreeRefresh() { + treeRefreshTimer.restart(); + } + + private void refreshTree() { + rebuildingTree = true; + try { + Set expanded = new HashSet(); + for (int i = 0; i < tree.getRowCount(); i++) { + TreePath path = tree.getPathForRow(i); + if (tree.isExpanded(path)) { + expanded.add(pathKey(path)); + } + } + TreePath selection = tree.getSelectionPath(); + String selectedKey = selection == null ? null : pathKey(selection); + + rootNode.removeAllChildren(); + rootNode.add(buildAdapterSubtree()); + rootNode.add(buildAppSubtree()); + treeModel.reload(); + + // restore expansion + selection; expanding grows the row count + // so the loop naturally visits newly revealed children too + for (int i = 0; i < tree.getRowCount(); i++) { + TreePath path = tree.getPathForRow(i); + String key = pathKey(path); + if (expanded.contains(key)) { + tree.expandPath(path); + } + if (key.equals(selectedKey)) { + tree.setSelectionPath(path); + } + } + } finally { + rebuildingTree = false; + } + if (tree.getSelectionPath() == null) { + // the previously selected node disappeared (or none was selected) + showCardForSelection(); + } + updateAdapterWidgets(); + } + + private void expandDefaultRows() { + for (int i = tree.getRowCount() - 1; i >= 0; i--) { + tree.expandRow(i); + } + } + + private String pathKey(TreePath path) { + StringBuilder sb = new StringBuilder(); + Object[] parts = path.getPath(); + for (int i = 0; i < parts.length; i++) { + Object userObject = + ((DefaultMutableTreeNode) parts[i]).getUserObject(); + sb.append('/'); + if (userObject instanceof Node) { + sb.append(((Node) userObject).key()); + } else { + sb.append(String.valueOf(userObject)); + } + } + return sb.toString(); + } + + private DefaultMutableTreeNode buildAdapterSubtree() { + SimulatedBluetoothStack stack = BluetoothSimulator.getStack(); + DefaultMutableTreeNode adapter = new DefaultMutableTreeNode( + new AdapterNode(stack.isAdapterEnabled())); + for (String address : stack.getPeripheralAddresses()) { + VirtualPeripheral p = stack.getPeripheral(address); + if (p == null) { + continue; + } + DefaultMutableTreeNode peripheralNode = new DefaultMutableTreeNode( + new PeripheralNode(p, stack.isConnected(address))); + for (VirtualService s : p.getServices()) { + DefaultMutableTreeNode serviceNode = + new DefaultMutableTreeNode( + new ServiceNode(address, s)); + for (VirtualCharacteristic c : s.getCharacteristics()) { + DefaultMutableTreeNode characteristicNode = + new DefaultMutableTreeNode(new CharacteristicNode( + address, s.getUuid(), c)); + for (VirtualDescriptor d : c.getDescriptors()) { + characteristicNode.add(new DefaultMutableTreeNode( + new DescriptorNode(address, s.getUuid(), + c.getUuid(), d))); + } + serviceNode.add(characteristicNode); + } + peripheralNode.add(serviceNode); + } + adapter.add(peripheralNode); + } + return adapter; + } + + private DefaultMutableTreeNode buildAppSubtree() { + SimulatedBluetoothStack stack = BluetoothSimulator.getStack(); + DefaultMutableTreeNode app = + new DefaultMutableTreeNode(new AppNode()); + + List payloads = stack.getAdvertisingPayloads(); + String advertising = payloads.isEmpty() ? "Advertising: off" + : "Advertising: on (" + payloads.size() + " payload(s))"; + app.add(new DefaultMutableTreeNode(new InfoNode("advertising", + advertising, describeAdvertising(payloads)))); + + for (GattLocalService s : stack.getAppServices()) { + DefaultMutableTreeNode serviceNode = new DefaultMutableTreeNode( + new InfoNode("as:" + s.getUuid(), + "Service " + uuidLabel(s.getUuid()) + + (s.isPrimary() ? "" : " (secondary)"), + describeAppService(s))); + for (GattLocalCharacteristic c : s.getCharacteristics()) { + DefaultMutableTreeNode characteristicNode = + new DefaultMutableTreeNode(new InfoNode( + "ac:" + s.getUuid() + ":" + c.getUuid(), + "Characteristic " + uuidLabel(c.getUuid()), + describeAppCharacteristic(c))); + for (GattLocalDescriptor d : c.getDescriptors()) { + characteristicNode.add(new DefaultMutableTreeNode( + new InfoNode("ad:" + s.getUuid() + ":" + + c.getUuid() + ":" + d.getUuid(), + "Descriptor " + uuidLabel(d.getUuid()), + "Descriptor " + d.getUuid() + "\nValue: " + + toHex(d.getValue())))); + } + serviceNode.add(characteristicNode); + } + app.add(serviceNode); + } + + DefaultMutableTreeNode centrals = new DefaultMutableTreeNode( + new InfoNode("centrals", "Connected centrals", + "Virtual centrals connected to the app's GATT " + + "server.")); + for (String address : stack.getConnectedCentralAddresses()) { + Set subs = centralSubscriptions.get(address); + DefaultMutableTreeNode centralNode = new DefaultMutableTreeNode( + new InfoNode("central:" + address, address, + describeCentral(address, subs))); + if (subs != null) { + for (String sub : subs) { + centralNode.add(new DefaultMutableTreeNode(new InfoNode( + "sub:" + address + ":" + sub, + "Subscribed: " + sub, + "Central " + address + + " is subscribed to " + sub))); + } + } + centrals.add(centralNode); + } + app.add(centrals); + return app; + } + + private String describeAdvertising(List payloads) { + StringBuilder sb = new StringBuilder(); + sb.append("App advertising state\n\n"); + if (payloads.isEmpty()) { + sb.append("Not advertising."); + } else { + sb.append(payloads.size()).append(" live advertisement(s):\n"); + for (Object payload : payloads) { + sb.append(" • ").append(payload).append('\n'); + } + } + return sb.toString(); + } + + private String describeAppService(GattLocalService s) { + StringBuilder sb = new StringBuilder(); + sb.append("App GATT service ").append(s.getUuid()).append('\n'); + sb.append("Primary: ").append(s.isPrimary()).append('\n'); + sb.append("Characteristics: ") + .append(s.getCharacteristics().size()); + return sb.toString(); + } + + private String describeAppCharacteristic(GattLocalCharacteristic c) { + StringBuilder sb = new StringBuilder(); + sb.append("App characteristic ").append(c.getUuid()).append('\n'); + sb.append("Properties: 0x") + .append(Integer.toHexString(c.getProperties())).append('\n'); + sb.append("Permissions: 0x") + .append(Integer.toHexString(c.getPermissions())).append('\n'); + byte[] value = c.getValue(); + sb.append(value == null + ? "Value: dynamic (served by read requests)" + : "Value: " + toHex(value)); + return sb.toString(); + } + + private String describeCentral(String address, Set subs) { + StringBuilder sb = new StringBuilder(); + sb.append("Virtual central ").append(address).append('\n'); + if (subs == null || subs.isEmpty()) { + sb.append("No subscriptions observed while this window was " + + "open."); + } else { + sb.append("Subscriptions:\n"); + for (String sub : subs) { + sb.append(" • ").append(sub).append('\n'); + } + } + return sb.toString(); + } + + // ------------------------------------------------------------------ + // cards + // ------------------------------------------------------------------ + + private void buildCards() { + JPanel empty = new JPanel(new BorderLayout()); + empty.add(new JLabel("Select a node in the tree", JLabel.CENTER), + BorderLayout.CENTER); + cards.add(empty, CARD_EMPTY); + cards.add(buildAdapterCard(), CARD_ADAPTER); + cards.add(buildPeripheralCard(), CARD_PERIPHERAL); + cards.add(buildCharacteristicCard(), CARD_CHARACTERISTIC); + + infoArea.setEditable(false); + infoArea.setLineWrap(true); + infoArea.setWrapStyleWord(true); + infoArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + cards.add(new JScrollPane(infoArea), CARD_INFO); + } + + private JPanel buildAdapterCard() { + JPanel card = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + gbc.gridx = 0; + gbc.gridy = 0; + gbc.gridwidth = 2; + + adapterEnabledCheck.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (updatingUi) { + return; + } + boolean enabled = adapterEnabledCheck.isSelected(); + BluetoothSimulator.setAdapterEnabled(enabled); + pref.putBoolean("BluetoothSim.adapterEnabled", enabled); + scheduleTreeRefresh(); + } + }); + card.add(adapterEnabledCheck, gbc); + + gbc.gridy++; + gbc.gridwidth = 1; + card.add(new JLabel("Latency (ms):"), gbc); + gbc.gridx = 1; + latencySpinner.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + if (updatingUi) { + return; + } + int millis = ((Number) latencySpinner.getValue()).intValue(); + BluetoothSimulator.setLatencyMillis(millis); + pref.putLong("BluetoothSim.latencyMillis", millis); + } + }); + card.add(latencySpinner, gbc); + + gbc.gridx = 0; + gbc.gridy++; + card.add(new JLabel("Backend:"), gbc); + gbc.gridx = 1; + backendCombo.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (updatingUi) { + return; + } + switchBackendFromCombo(); + } + }); + card.add(backendCombo, gbc); + + JPanel failure = new JPanel(new GridBagLayout()); + failure.setBorder( + BorderFactory.createTitledBorder("Failure injection")); + GridBagConstraints fgbc = new GridBagConstraints(); + fgbc.insets = new Insets(2, 4, 2, 4); + fgbc.anchor = GridBagConstraints.WEST; + fgbc.gridx = 0; + fgbc.gridy = 0; + failure.add(new JLabel("Operation:"), fgbc); + fgbc.gridx = 1; + failure.add(failureOpCombo, fgbc); + fgbc.gridx = 0; + fgbc.gridy = 1; + failure.add(new JLabel("Error:"), fgbc); + fgbc.gridx = 1; + failure.add(failureErrorCombo, fgbc); + fgbc.gridx = 0; + fgbc.gridy = 2; + failure.add(new JLabel("Message:"), fgbc); + fgbc.gridx = 1; + fgbc.fill = GridBagConstraints.HORIZONTAL; + failure.add(failureMessageField, fgbc); + fgbc.gridx = 1; + fgbc.gridy = 3; + fgbc.fill = GridBagConstraints.NONE; + failure.add(button("Arm next failure", new Runnable() { + @Override + public void run() { + String op = (String) failureOpCombo.getSelectedItem(); + BluetoothError error = + (BluetoothError) failureErrorCombo.getSelectedItem(); + String message = failureMessageField.getText().trim(); + BluetoothSimulator.failNext(op, error, + message.length() == 0 ? null : message); + } + }), fgbc); + + gbc.gridx = 0; + gbc.gridy++; + gbc.gridwidth = 2; + gbc.fill = GridBagConstraints.HORIZONTAL; + card.add(failure, gbc); + + // push everything to the top-left + gbc.gridy++; + gbc.weightx = 1; + gbc.weighty = 1; + card.add(new JPanel(), gbc); + return card; + } + + private void switchBackendFromCombo() { + String name = (String) backendCombo.getSelectedItem(); + JavaSEBluetooth impl = bluetoothImpl(); + if (impl == null) { + JOptionPane.showMessageDialog(this, + "The Bluetooth port is not initialized yet -- open the " + + "Bluetooth API from the app first.", + "Backend unavailable", JOptionPane.ERROR_MESSAGE); + updateAdapterWidgets(); + return; + } + try { + impl.switchBackend(name); + } catch (RuntimeException ex) { + JOptionPane.showMessageDialog(this, ex.getMessage(), + "Backend unavailable", JOptionPane.ERROR_MESSAGE); + } + updateAdapterWidgets(); + } + + private JavaSEBluetooth bluetoothImpl() { + try { + Bluetooth bt = Bluetooth.getInstance(); + if (bt instanceof JavaSEBluetooth) { + return (JavaSEBluetooth) bt; + } + } catch (Throwable t) { + // Display not initialized yet + } + return null; + } + + /** Syncs the adapter-card widgets with the live stack state. */ + private void updateAdapterWidgets() { + updatingUi = true; + try { + SimulatedBluetoothStack stack = BluetoothSimulator.getStack(); + adapterEnabledCheck.setSelected(stack.isAdapterEnabled()); + latencySpinner.setValue( + Integer.valueOf((int) stack.getLatencyMillis())); + JavaSEBluetooth impl = bluetoothImpl(); + if (impl != null) { + backendCombo.setSelectedItem(impl.activeBackendName()); + } + } finally { + updatingUi = false; + } + } + + private JPanel buildPeripheralCard() { + JPanel card = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + gbc.gridx = 0; + gbc.gridy = 0; + card.add(new JLabel("Address:"), gbc); + gbc.gridx = 1; + card.add(peripheralAddressLabel, gbc); + + gbc.gridx = 0; + gbc.gridy++; + card.add(new JLabel("Name:"), gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + peripheralNameField.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + applyPeripheralName(); + } + }); + peripheralNameField.addFocusListener( + new java.awt.event.FocusAdapter() { + @Override + public void focusLost(java.awt.event.FocusEvent e) { + applyPeripheralName(); + } + }); + card.add(peripheralNameField, gbc); + gbc.fill = GridBagConstraints.NONE; + + gbc.gridx = 0; + gbc.gridy++; + card.add(new JLabel("RSSI (dBm):"), gbc); + gbc.gridx = 1; + peripheralRssiSpinner.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + if (updatingUi || selectedPeripheral == null) { + return; + } + selectedPeripheral.setRssi(((Number) + peripheralRssiSpinner.getValue()).intValue()); + } + }); + card.add(peripheralRssiSpinner, gbc); + + gbc.gridx = 0; + gbc.gridy++; + gbc.gridwidth = 2; + peripheralConnectableCheck.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (updatingUi || selectedPeripheral == null) { + return; + } + selectedPeripheral.setConnectable( + peripheralConnectableCheck.isSelected()); + } + }); + card.add(peripheralConnectableCheck, gbc); + + gbc.gridy++; + card.add(peripheralStateLabel, gbc); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT)); + buttons.add(button("Disconnect from remote", new Runnable() { + @Override + public void run() { + if (selectedPeripheral != null) { + BluetoothSimulator.disconnectFromRemote( + selectedPeripheral.getAddress()); + } + } + })); + buttons.add(button("Remove", new Runnable() { + @Override + public void run() { + if (selectedPeripheral != null) { + BluetoothSimulator.removePeripheral( + selectedPeripheral.getAddress()); + scheduleTreeRefresh(); + } + } + })); + gbc.gridy++; + card.add(buttons, gbc); + + gbc.gridy++; + gbc.weightx = 1; + gbc.weighty = 1; + card.add(new JPanel(), gbc); + return card; + } + + private void applyPeripheralName() { + if (updatingUi || selectedPeripheral == null) { + return; + } + String name = peripheralNameField.getText().trim(); + selectedPeripheral.setName(name.length() == 0 ? null : name); + scheduleTreeRefresh(); + } + + private JPanel buildCharacteristicCard() { + JPanel card = new JPanel(new BorderLayout(8, 8)); + card.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + JPanel north = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(2, 0, 2, 0); + gbc.anchor = GridBagConstraints.WEST; + gbc.gridx = 0; + gbc.gridy = 0; + north.add(characteristicTitleLabel, gbc); + gbc.gridy++; + north.add(characteristicSubscribedLabel, gbc); + card.add(north, BorderLayout.NORTH); + + JPanel center = new JPanel(new BorderLayout(4, 4)); + JPanel modes = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + ButtonGroup group = new ButtonGroup(); + group.add(hexModeRadio); + group.add(utf8ModeRadio); + ActionListener remodel = new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + renderCharacteristicValue(); + } + }; + hexModeRadio.addActionListener(remodel); + utf8ModeRadio.addActionListener(remodel); + modes.add(new JLabel("Value as:")); + modes.add(hexModeRadio); + modes.add(utf8ModeRadio); + center.add(modes, BorderLayout.NORTH); + center.add(new JScrollPane(characteristicValueArea), + BorderLayout.CENTER); + card.add(center, BorderLayout.CENTER); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT)); + buttons.add(button("Set Value", new Runnable() { + @Override + public void run() { + byte[] value = parseValueEditor(); + if (value != null && selectedCharacteristic != null) { + selectedCharacteristic.setValue(value); + BluetoothSimulator.getStack().logEvent("edit", + "characteristic " + + selectedCharacteristic.getUuid() + + " value set to " + value.length + + " byte(s) from the Simulate window"); + } + } + })); + buttons.add(button("Push Notify", new Runnable() { + @Override + public void run() { + byte[] value = parseValueEditor(); + if (value != null && selectedCharacteristic != null) { + selectedCharacteristic.setValue(value); + BluetoothSimulator.pushNotification( + selectedCharacteristicAddress, + selectedCharacteristicService, + selectedCharacteristic.getUuid(), value); + } + } + })); + card.add(buttons, BorderLayout.SOUTH); + return card; + } + + /** Renders the selected characteristic's live value into the editor. */ + private void renderCharacteristicValue() { + if (selectedCharacteristic == null) { + characteristicValueArea.setText(""); + return; + } + byte[] value = selectedCharacteristic.getValue(); + if (hexModeRadio.isSelected()) { + characteristicValueArea.setText(toHex(value)); + } else { + try { + characteristicValueArea.setText(new String(value, "UTF-8")); + } catch (java.io.UnsupportedEncodingException ex) { + characteristicValueArea.setText(""); + } + } + } + + /** Parses the value editor; shows an error dialog and returns null on bad hex. */ + private byte[] parseValueEditor() { + String text = characteristicValueArea.getText(); + if (utf8ModeRadio.isSelected()) { + try { + return text.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException ex) { + return text.getBytes(); + } + } + String compact = text.replaceAll("[\\s,]", ""); + if (compact.startsWith("0x") || compact.startsWith("0X")) { + compact = compact.substring(2); + } + if (compact.length() % 2 != 0 + || !compact.matches("[0-9a-fA-F]*")) { + JOptionPane.showMessageDialog(this, + "Enter an even number of hex digits, e.g. \"00 48\"", + "Invalid hex value", JOptionPane.ERROR_MESSAGE); + return null; + } + byte[] out = new byte[compact.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt( + compact.substring(i * 2, i * 2 + 2), 16); + } + return out; + } + + private static String toHex(byte[] value) { + if (value == null || value.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(value.length * 3); + for (int i = 0; i < value.length; i++) { + if (i > 0) { + sb.append(' '); + } + sb.append(String.format("%02X", Integer.valueOf(value[i] & 0xFF))); + } + return sb.toString(); + } + + // ------------------------------------------------------------------ + // selection routing + // ------------------------------------------------------------------ + + private void showCardForSelection() { + TreePath path = tree.getSelectionPath(); + if (rebuildingTree) { + // transient selection churn while the tree rebuilds: ignore the + // momentary null and keep the editors (with any in-progress + // typing) when the same node is re-selected afterwards + if (path == null || pathKey(path).equals(shownCardKey)) { + return; + } + } + shownCardKey = path == null ? null : pathKey(path); + Object userObject = null; + if (path != null) { + userObject = ((DefaultMutableTreeNode) + path.getLastPathComponent()).getUserObject(); + } + selectedPeripheral = null; + selectedCharacteristic = null; + selectedCharacteristicAddress = null; + selectedCharacteristicService = null; + + if (userObject instanceof AdapterNode) { + updateAdapterWidgets(); + cardLayout.show(cards, CARD_ADAPTER); + } else if (userObject instanceof PeripheralNode) { + showPeripheralCard((PeripheralNode) userObject); + } else if (userObject instanceof CharacteristicNode) { + showCharacteristicCard((CharacteristicNode) userObject); + } else if (userObject instanceof ServiceNode) { + ServiceNode node = (ServiceNode) userObject; + infoArea.setText("GATT service " + node.service.getUuid() + + "\nOn peripheral: " + node.address + + "\nPrimary: " + node.service.isPrimary() + + "\nCharacteristics: " + + node.service.getCharacteristics().size()); + cardLayout.show(cards, CARD_INFO); + } else if (userObject instanceof DescriptorNode) { + DescriptorNode node = (DescriptorNode) userObject; + infoArea.setText("Descriptor " + node.descriptor.getUuid() + + "\nOn characteristic: " + node.characteristicUuid + + "\nValue: " + toHex(node.descriptor.getValue())); + cardLayout.show(cards, CARD_INFO); + } else if (userObject instanceof AppNode) { + infoArea.setText(describeAppRole()); + cardLayout.show(cards, CARD_INFO); + } else if (userObject instanceof InfoNode) { + infoArea.setText(((InfoNode) userObject).details); + cardLayout.show(cards, CARD_INFO); + } else { + cardLayout.show(cards, CARD_EMPTY); + } + } + + private String describeAppRole() { + SimulatedBluetoothStack stack = BluetoothSimulator.getStack(); + StringBuilder sb = new StringBuilder(); + sb.append("The app's own peripheral role.\n\n"); + sb.append("Published GATT services: ") + .append(stack.getAppServices().size()).append('\n'); + sb.append("Advertising: ") + .append(stack.isAdvertising() ? "on" : "off").append('\n'); + sb.append("Connected virtual centrals: ") + .append(stack.getConnectedCentralAddresses().size()); + return sb.toString(); + } + + private void showPeripheralCard(PeripheralNode node) { + selectedPeripheral = node.peripheral; + updatingUi = true; + try { + peripheralAddressLabel.setText(node.peripheral.getAddress()); + String name = node.peripheral.getName(); + peripheralNameField.setText(name == null ? "" : name); + peripheralRssiSpinner.setValue( + Integer.valueOf(node.peripheral.getRssi())); + peripheralConnectableCheck.setSelected( + node.peripheral.isConnectable()); + SimulatedBluetoothStack stack = BluetoothSimulator.getStack(); + String address = node.peripheral.getAddress(); + peripheralStateLabel.setText("Connected: " + + stack.isConnected(address) + " Bonded: " + + stack.isBonded(address)); + } finally { + updatingUi = false; + } + cardLayout.show(cards, CARD_PERIPHERAL); + } + + private void showCharacteristicCard(CharacteristicNode node) { + selectedCharacteristic = node.characteristic; + selectedCharacteristicAddress = node.address; + selectedCharacteristicService = node.serviceUuid; + characteristicTitleLabel.setText("Characteristic " + + node.characteristic.getUuid() + " on " + node.address); + boolean subscribed = BluetoothSimulator.getStack().isSubscribed( + node.address, node.serviceUuid, + node.characteristic.getUuid()); + characteristicSubscribedLabel.setText( + "App subscribed: " + (subscribed ? "yes" : "no")); + renderCharacteristicValue(); + cardLayout.show(cards, CARD_CHARACTERISTIC); + } + + // ------------------------------------------------------------------ + // event log + // ------------------------------------------------------------------ + + private JPanel buildLogPanel() { + JPanel panel = new JPanel(new BorderLayout()); + panel.setBorder(BorderFactory.createTitledBorder("Event log")); + + logTable.getColumnModel().getColumn(0).setPreferredWidth(90); + logTable.getColumnModel().getColumn(0).setMaxWidth(120); + logTable.getColumnModel().getColumn(1).setPreferredWidth(120); + logTable.getColumnModel().getColumn(1).setMaxWidth(180); + logTable.setFillsViewportHeight(true); + JScrollPane scroll = new JScrollPane(logTable); + scroll.setPreferredSize(new Dimension(640, 160)); + panel.add(scroll, BorderLayout.CENTER); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 2)); + buttons.add(button("Clear", new Runnable() { + @Override + public void run() { + logModel.setRowCount(0); + } + })); + buttons.add(button("Copy", new Runnable() { + @Override + public void run() { + copyLogToClipboard(); + } + })); + panel.add(buttons, BorderLayout.SOUTH); + return panel; + } + + private void copyLogToClipboard() { + StringBuilder sb = new StringBuilder(); + int rows = logModel.getRowCount(); + for (int i = 0; i < rows; i++) { + sb.append(logModel.getValueAt(i, 0)).append('\t') + .append(logModel.getValueAt(i, 1)).append('\t') + .append(logModel.getValueAt(i, 2)).append('\n'); + } + Toolkit.getDefaultToolkit().getSystemClipboard().setContents( + new StringSelection(sb.toString()), null); + } + + private void appendLog(String op, String detail) { + while (logModel.getRowCount() >= MAX_LOG_ROWS) { + logModel.removeRow(0); + } + logModel.addRow(new Object[] { + timeFormat.format(new Date()), op, detail}); + int last = logTable.getRowCount() - 1; + if (last >= 0) { + logTable.scrollRectToVisible( + logTable.getCellRect(last, 0, true)); + } + } + + private void attachStackListener() { + if (stackListener != null) { + return; + } + stackListener = new StackEventListener() { + @Override + public void event(final String op, final String detail) { + // stack scheduler thread -> AWT EDT + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + appendLog(op, detail); + trackCentralSubscriptions(op, detail); + scheduleTreeRefresh(); + } + }); + } + }; + BluetoothSimulator.addEventListener(stackListener); + } + + private void detachStackListener() { + if (stackListener != null) { + BluetoothSimulator.removeEventListener(stackListener); + stackListener = null; + } + } + + /** + * Best-effort mirror of the virtual centrals' subscriptions from the + * event feed ({@code "centralSubscribe"} events look like + * {@code "
subscribe="}). + */ + private void trackCentralSubscriptions(String op, String detail) { + if ("centralSubscribe".equals(op)) { + String[] parts = detail.split(" "); + if (parts.length >= 3) { + String address = parts[0]; + String characteristic = parts[1]; + boolean subscribe = "subscribe=true".equals(parts[2]); + Set subs = centralSubscriptions.get(address); + if (subs == null) { + subs = new TreeSet(); + centralSubscriptions.put(address, subs); + } + if (subscribe) { + subs.add(characteristic); + } else { + subs.remove(characteristic); + } + } + } else if ("central".equals(op) && detail.startsWith("disconnected ")) { + centralSubscriptions.remove( + detail.substring("disconnected ".length())); + } else if ("reset".equals(op) + || ("gattServer".equals(op) && "close".equals(detail))) { + centralSubscriptions.clear(); + } + } + + // ------------------------------------------------------------------ + // preferences + // ------------------------------------------------------------------ + + /** Re-applies the persisted adapter/latency settings to the stack. */ + private void applyPersistedStackPrefs() { + boolean enabled = pref.getBoolean("BluetoothSim.adapterEnabled", true); + long latency = pref.getLong("BluetoothSim.latencyMillis", + SimulatedBluetoothStack.DEFAULT_LATENCY_MILLIS); + SimulatedBluetoothStack stack = BluetoothSimulator.getStack(); + if (enabled != stack.isAdapterEnabled()) { + stack.setAdapterEnabled(enabled); + } + if (latency != stack.getLatencyMillis()) { + stack.setLatencyMillis(latency); + } + } + + private void restoreBounds() { + int w = pref.getInt("BluetoothSim.w", -1); + int h = pref.getInt("BluetoothSim.h", -1); + if (w > 100 && h > 100) { + setBounds(pref.getInt("BluetoothSim.x", 100), + pref.getInt("BluetoothSim.y", 100), w, h); + } else { + setLocationByPlatform(true); + } + } + + private void persistBounds() { + if (!isShowing()) { + return; + } + pref.putInt("BluetoothSim.x", getX()); + pref.putInt("BluetoothSim.y", getY()); + pref.putInt("BluetoothSim.w", getWidth()); + pref.putInt("BluetoothSim.h", getHeight()); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BluetoothSimulatorHooks.java b/Ports/JavaSE/src/com/codename1/impl/javase/BluetoothSimulatorHooks.java new file mode 100644 index 00000000000..f5a770265dc --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BluetoothSimulatorHooks.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2012, 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.impl.javase; + +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.components.ToastBar; +import com.codename1.impl.javase.bluetooth.BluetoothSimulator; +import com.codename1.impl.javase.bluetooth.JavaSEBluetooth; +import com.codename1.impl.javase.bluetooth.VirtualCharacteristic; +import com.codename1.impl.javase.bluetooth.VirtualDescriptor; +import com.codename1.impl.javase.bluetooth.VirtualPeripheral; +import com.codename1.impl.javase.bluetooth.VirtualService; +import com.codename1.ui.Display; + +import java.util.List; + +/** + * Cross-platform scripting hooks of the simulated Bluetooth stack. Declared + * in {@code META-INF/codenameone/simulator-hooks.properties} so the + * simulator surfaces them as the "Bluetooth" menu and registers them with + * {@link com.codename1.system.SimulatorHookExecutor} -- test code drives + * them via {@code CN.execute("bluetooth:itemN")} on any platform. + * + *

All methods are {@code public static void} with no arguments (the + * contract of {@link com.codename1.impl.javase.simulator.SimulatorHookLoader}) + * and are invoked on the Codename One EDT.

+ */ +public final class BluetoothSimulatorHooks { + + /** Address of the canonical demo peripheral. */ + public static final String DEMO_ADDRESS = "AA:BB:CC:DD:EE:01"; + + /** Name of the canonical demo peripheral. */ + public static final String DEMO_NAME = "SimulatedSensor"; + + /** Service UUID (0x180D, Heart Rate) of the demo peripheral. */ + public static final BluetoothUuid DEMO_SERVICE = + BluetoothUuid.fromShort(0x180D); + + /** The demo read/notify characteristic (0x2A37) with a CCCD. */ + public static final BluetoothUuid DEMO_NOTIFY_CHARACTERISTIC = + BluetoothUuid.fromShort(0x2A37); + + /** The demo write characteristic (0x2A39). */ + public static final BluetoothUuid DEMO_WRITE_CHARACTERISTIC = + BluetoothUuid.fromShort(0x2A39); + + private static int demoNotificationCounter; + + private BluetoothSimulatorHooks() { + } + + /** + * Builds the canonical demo device shared by the Simulate window's + * "Add Demo Peripheral" button and the {@link #addDemoPeripheral()} + * hook: address {@value #DEMO_ADDRESS}, name {@value #DEMO_NAME}, one + * 0x180D service with a read/write/notify 0x2A37 characteristic + * (carrying a CCCD) and a writable 0x2A39 characteristic. + */ + public static VirtualPeripheral createDemoPeripheral() { + return new VirtualPeripheral(DEMO_ADDRESS) + .setName(DEMO_NAME) + .addAdvertisedServiceUuid(DEMO_SERVICE) + .withService(createDemoService()); + } + + /** + * The demo GATT service alone, for the "Add Peripheral" dialog's + * "include demo GATT service" option. + */ + public static VirtualService createDemoService() { + return new VirtualService(DEMO_SERVICE) + .withCharacteristic(new VirtualCharacteristic( + DEMO_NOTIFY_CHARACTERISTIC, + GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_WRITE + | GattCharacteristic.PROPERTY_NOTIFY, + new byte[] {0, 72}) + .withDescriptor(new VirtualDescriptor( + BluetoothUuid.CCCD, new byte[] {0, 0}))) + .withCharacteristic(new VirtualCharacteristic( + DEMO_WRITE_CHARACTERISTIC, + GattCharacteristic.PROPERTY_WRITE, + new byte[] {0})); + } + + /** Flips the simulated adapter between on and off. */ + public static void toggleAdapter() { + boolean enable = !BluetoothSimulator.isAdapterEnabled(); + BluetoothSimulator.setAdapterEnabled(enable); + toast("Bluetooth adapter " + (enable ? "enabled" : "disabled")); + } + + /** Registers (or re-registers) the canonical demo peripheral. */ + public static void addDemoPeripheral() { + BluetoothSimulator.addPeripheral(createDemoPeripheral()); + toast(DEMO_NAME + " (" + DEMO_ADDRESS + ") registered"); + } + + /** Drops every live connection from the remote side. */ + public static void disconnectAll() { + List connected = + BluetoothSimulator.getStack().getConnectedAddresses(); + int size = connected.size(); + for (int i = 0; i < size; i++) { + BluetoothSimulator.disconnectFromRemote(connected.get(i)); + } + toast(size == 0 ? "No Bluetooth connections to drop" + : "Dropped " + size + " Bluetooth connection(s)"); + } + + /** + * Pushes a rolling one-byte notification from the demo peripheral's + * 0x2A37 characteristic (delivered only while the app is connected and + * subscribed). + */ + public static void pushDemoNotification() { + byte payload; + synchronized (BluetoothSimulatorHooks.class) { + payload = (byte) (demoNotificationCounter++ & 0xFF); + } + BluetoothSimulator.pushNotification(DEMO_ADDRESS, DEMO_SERVICE, + DEMO_NOTIFY_CHARACTERISTIC, new byte[] {payload}); + toast("Pushed demo notification [" + (payload & 0xFF) + "]"); + } + + /** Removes every registered virtual peripheral. */ + public static void clearPeripherals() { + BluetoothSimulator.clearPeripherals(); + toast("Cleared all virtual Bluetooth peripherals"); + } + + /** Switches the JavaSE port to the (future) native host-radio backend. */ + public static void switchToNativeBackend() { + switchBackend(JavaSEBluetooth.BACKEND_NATIVE); + } + + /** Switches the JavaSE port back to the simulated stack backend. */ + public static void switchToSimulatorBackend() { + switchBackend(JavaSEBluetooth.BACKEND_SIMULATOR); + } + + /** + * Arms the next GATT read to fail with {@code GATT_ERROR}. API-only + * hook (no menu label): drive it from tests via + * {@code CN.execute("bluetooth:item8")}. + */ + public static void primeReadFailure() { + BluetoothSimulator.failNext("read", BluetoothError.GATT_ERROR, + "Primed by simulator hook"); + } + + private static void switchBackend(String name) { + Bluetooth bt = Bluetooth.getInstance(); + if (!(bt instanceof JavaSEBluetooth)) { + toast("Bluetooth backend switching is unavailable here"); + return; + } + JavaSEBluetooth impl = (JavaSEBluetooth) bt; + try { + impl.switchBackend(name); + toast("Bluetooth backend: " + impl.activeBackendName()); + } catch (RuntimeException ex) { + toast("Cannot switch Bluetooth backend: " + ex.getMessage()); + } + } + + /** + * ToastBar feedback, guarded so API-driven invocations in minimal test + * harnesses (no current form yet) degrade to a console line. + */ + private static void toast(String message) { + try { + if (Display.isInitialized() + && Display.getInstance().getCurrent() != null) { + ToastBar.showInfoMessage(message); + return; + } + } catch (Throwable t) { + // fall through to the console + } + System.out.println("Bluetooth simulation: " + message); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index f64b2378e50..c41f83c36db 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -917,6 +917,7 @@ public static void setShowEDTViolationStacks(boolean aShowEDTViolationStacks) { static LocationSimulation locSimulation; static MotionSimulation motionSimulation; static ARSimulation arSimulation; + static BluetoothSimulation bluetoothSimulation; private JavaSEMotionSensorManager motionSensorManager; static PushSimulator pushSimulation; private static boolean blockMonitors; @@ -6063,6 +6064,18 @@ public void actionPerformed(ActionEvent ae) { }); simulateMenu.add(locationSim); + JMenuItem bluetoothSim = new JMenuItem("Bluetooth Simulation"); + bluetoothSim.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + if(bluetoothSimulation == null) { + bluetoothSimulation = new BluetoothSimulation(); + } + bluetoothSimulation.setVisible(true); + } + }); + simulateMenu.add(bluetoothSim); + JMenuItem motionSim = new JMenuItem("Motion / Gesture Simulation"); motionSim.addActionListener(new ActionListener() { @Override @@ -6694,6 +6707,7 @@ public void actionPerformed(ActionEvent e) { simulateMenu.add(appArg); simulateMenu.addSeparator(); simulateMenu.add(locationSim); + simulateMenu.add(bluetoothSim); simulateMenu.add(motionSim); simulateMenu.add(pushSim); simulateMenu.add(biometricMenu); @@ -14490,6 +14504,7 @@ public String[] getPlatformOverrides() { private JavaSEBiometrics biometrics; private JavaSESecureStorage secureStorage; private JavaSENfc nfc; + private com.codename1.bluetooth.Bluetooth bluetooth; private boolean biometricsBuildHintsInstalled; private boolean nfcBuildHintsInstalled; @@ -14520,6 +14535,14 @@ public com.codename1.nfc.Nfc getNfc() { return nfc; } + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + if (bluetooth == null) { + bluetooth = new com.codename1.impl.javase.bluetooth.JavaSEBluetooth(); + } + return bluetooth; + } + /** * The first time the app reaches the NFC API in the simulator, write * placeholders for ios.NFCReaderUsageDescription if the developer has diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/AutoScheduler.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/AutoScheduler.java new file mode 100644 index 00000000000..f81d562a6fa --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/AutoScheduler.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * The wall-clock {@link SimScheduler} used when the simulator actually + * runs: a single daemon thread named {@code cn1-bluetooth-sim}. This class + * is the only place in the simulated stack that touches real time -- all + * stack logic expresses time exclusively through + * {@link SimScheduler#postDelayed(Runnable, long)}. + */ +public final class AutoScheduler implements SimScheduler { + + private final ScheduledThreadPoolExecutor executor; + + public AutoScheduler() { + executor = new ScheduledThreadPoolExecutor(1, new ThreadFactory() { + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "cn1-bluetooth-sim"); + t.setDaemon(true); + return t; + } + }); + executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); + } + + public void post(Runnable task) { + postDelayed(task, 0); + } + + public void postDelayed(Runnable task, long millis) { + if (task == null) { + return; + } + try { + executor.schedule(task, Math.max(0, millis), + TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException ignored) { + // shut down -- drop the task, mirroring ManualScheduler + } + } + + public void shutdown() { + executor.shutdownNow(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothFixture.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothFixture.java new file mode 100644 index 00000000000..a5f25f76d46 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothFixture.java @@ -0,0 +1,566 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.helper.HelperBlePeripheral; +import com.codename1.bluetooth.helper.Wire; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A recorded Bluetooth trace: the versioned, in-memory model behind the + * record-and-replay fixture files under + * {@code maven/javase/src/test/resources/bluetooth-fixtures/}. A fixture + * captures what a scan (and optional GATT walks) observed on real + * hardware -- device identities, RSSI timelines, advertisement payloads + * and GATT databases with readable values -- so the exact same trace can + * be replayed deterministically into a {@link SimulatedBluetoothStack} + * via {@link SimulatedBluetoothStack#loadFixture(BluetoothFixture)}. + * + *

Fixtures produced by {@link FixtureRecorder} are scrambled with + * {@link FixtureScrambler} before they are written to disk, so committed + * traces never carry real device identities.

+ * + *

Serialization is plain JSON without external libraries: + * {@link #toJson()} hand-rolls the writer (indented, deterministic field + * order) and {@link #fromJson(InputStream)} parses with the core + * {@code com.codename1.io.JSONParser} through {@link Wire}. Format + * (version {@value #FORMAT_VERSION}):

+ * + *
+ * {"version": 1,
+ *  "platform": "Mac OS X 15.x (cn1-ble-helper)",
+ *  "devices": [
+ *    {"id": "SC:RA:MB:4E:11:83",
+ *     "name": "Device-2F41",
+ *     "connectable": true,
+ *     "txPower": 4,
+ *     "rssiTimeline": [{"relTimeMs": 120, "rssi": -58}, ...],
+ *     "serviceUuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
+ *     "manufacturerData": {"76": "<base64>"},
+ *     "serviceData": {"<uuid>": "<base64>"},
+ *     "gatt": [
+ *       {"uuid": "...", "primary": true, "characteristics": [
+ *         {"uuid": "...", "properties": ["read","notify"],
+ *          "value": "<base64>",
+ *          "descriptors": [{"uuid": "..."}]}]}]}]}
+ * 
+ * + *

Optional fields ({@code name}, {@code txPower}, {@code value}) are + * omitted when absent; {@code gatt} is omitted when the device carries no + * captured GATT database.

+ */ +public final class BluetoothFixture { + + /** The fixture file format version written by {@link #toJson()}. */ + public static final int FORMAT_VERSION = 1; + + /** One RSSI sighting at a time relative to the start of the capture. */ + public static final class RssiSample { + /** Milliseconds since the capture started. */ + public final long relTimeMs; + /** Signal strength in dBm. */ + public final int rssi; + + public RssiSample(long relTimeMs, int rssi) { + this.relTimeMs = relTimeMs; + this.rssi = rssi; + } + } + + /** A captured GATT descriptor (identity only). */ + public static final class DescriptorRecord { + public final BluetoothUuid uuid; + + public DescriptorRecord(BluetoothUuid uuid) { + if (uuid == null) { + throw new IllegalArgumentException("uuid is required"); + } + this.uuid = uuid; + } + } + + /** + * A captured GATT characteristic: identity, the + * {@link GattCharacteristic}{@code .PROPERTY_*} bitmask, descriptors + * and -- when the characteristic was readable during capture -- the + * value that was read. + */ + public static final class CharacteristicRecord { + public final BluetoothUuid uuid; + public final int properties; + /** The captured readable value, or {@code null} when none. */ + public byte[] value; + public final ArrayList descriptors = + new ArrayList(); + + public CharacteristicRecord(BluetoothUuid uuid, int properties) { + if (uuid == null) { + throw new IllegalArgumentException("uuid is required"); + } + this.uuid = uuid; + this.properties = properties; + } + } + + /** A captured GATT service. */ + public static final class ServiceRecord { + public final BluetoothUuid uuid; + public boolean primary = true; + public final ArrayList characteristics = + new ArrayList(); + + public ServiceRecord(BluetoothUuid uuid) { + if (uuid == null) { + throw new IllegalArgumentException("uuid is required"); + } + this.uuid = uuid; + } + } + + /** One observed device with its advertisement history and GATT DB. */ + public static final class Device { + /** The device identifier (scrambled in committed fixtures). */ + public final String id; + /** The advertised local name, or {@code null}. */ + public String name; + public boolean connectable = true; + /** The advertised TX power in dBm, or {@code null} when absent. */ + public Integer txPower; + /** RSSI sightings in capture order. */ + public final ArrayList rssiTimeline = + new ArrayList(); + public final ArrayList serviceUuids = + new ArrayList(); + /** Manufacturer payloads keyed by SIG company identifier. */ + public final LinkedHashMap manufacturerData = + new LinkedHashMap(); + /** Service-data payloads keyed by service UUID. */ + public final LinkedHashMap serviceData = + new LinkedHashMap(); + /** The captured GATT database; empty when never connected. */ + public final ArrayList gatt = + new ArrayList(); + + public Device(String id) { + if (id == null || id.length() == 0) { + throw new IllegalArgumentException("id is required"); + } + this.id = id; + } + + /** {@code true} when a GATT database was captured. */ + public boolean hasGatt() { + return !gatt.isEmpty(); + } + + /** + * Materializes this record as a {@link VirtualPeripheral} ready + * for {@link SimulatedBluetoothStack#addPeripheral}: the initial + * RSSI is the first timeline sample and the GATT records map onto + * the {@code Virtual*} model. + */ + public VirtualPeripheral toVirtualPeripheral() { + VirtualPeripheral p = new VirtualPeripheral(id) + .setName(name) + .setConnectable(connectable) + .setTxPower(txPower); + if (!rssiTimeline.isEmpty()) { + p.setRssi(rssiTimeline.get(0).rssi); + } + int size = serviceUuids.size(); + for (int i = 0; i < size; i++) { + p.addAdvertisedServiceUuid(serviceUuids.get(i)); + } + for (Map.Entry e : manufacturerData.entrySet()) { + p.addManufacturerData(e.getKey().intValue(), e.getValue()); + } + for (Map.Entry e + : serviceData.entrySet()) { + p.addServiceData(e.getKey(), e.getValue()); + } + size = gatt.size(); + for (int i = 0; i < size; i++) { + ServiceRecord sr = gatt.get(i); + VirtualService service = new VirtualService(sr.uuid) + .setPrimary(sr.primary); + int cs = sr.characteristics.size(); + for (int j = 0; j < cs; j++) { + CharacteristicRecord cr = sr.characteristics.get(j); + VirtualCharacteristic c = new VirtualCharacteristic( + cr.uuid, cr.properties, cr.value); + int ds = cr.descriptors.size(); + for (int k = 0; k < ds; k++) { + c.withDescriptor(new VirtualDescriptor( + cr.descriptors.get(k).uuid)); + } + service.withCharacteristic(c); + } + p.withService(service); + } + return p; + } + } + + private int version = FORMAT_VERSION; + private String platform; + private final ArrayList devices = new ArrayList(); + + public int getVersion() { + return version; + } + + /** A note about the capture platform, e.g. the host OS; fluent. */ + public BluetoothFixture setPlatform(String platform) { + this.platform = platform; + return this; + } + + public String getPlatform() { + return platform; + } + + /** Appends a device record; fluent. */ + public BluetoothFixture addDevice(Device device) { + if (device != null) { + devices.add(device); + } + return this; + } + + /** The device records in capture order (the live list). */ + public List getDevices() { + return devices; + } + + /** The device record with the given id, or {@code null}. */ + public Device getDevice(String id) { + int size = devices.size(); + for (int i = 0; i < size; i++) { + if (devices.get(i).id.equals(id)) { + return devices.get(i); + } + } + return null; + } + + // ------------------------------------------------------------------ + // JSON writing + // ------------------------------------------------------------------ + + /** Serializes the fixture as indented JSON (format sketched above). */ + public String toJson() { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"version\": ").append(version); + if (platform != null) { + sb.append(",\n \"platform\": \"") + .append(Wire.escape(platform)).append('"'); + } + sb.append(",\n \"devices\": ["); + int size = devices.size(); + for (int i = 0; i < size; i++) { + if (i > 0) { + sb.append(','); + } + sb.append('\n'); + writeDevice(sb, devices.get(i)); + } + sb.append(size == 0 ? "]\n" : "\n ]\n").append("}\n"); + return sb.toString(); + } + + private static void writeDevice(StringBuilder sb, Device d) { + sb.append(" {\"id\": \"").append(Wire.escape(d.id)).append('"'); + if (d.name != null) { + sb.append(",\n \"name\": \"").append(Wire.escape(d.name)) + .append('"'); + } + sb.append(",\n \"connectable\": ").append(d.connectable); + if (d.txPower != null) { + sb.append(",\n \"txPower\": ").append(d.txPower); + } + sb.append(",\n \"rssiTimeline\": ["); + int size = d.rssiTimeline.size(); + for (int i = 0; i < size; i++) { + RssiSample s = d.rssiTimeline.get(i); + if (i > 0) { + sb.append(", "); + } + sb.append("{\"relTimeMs\": ").append(s.relTimeMs) + .append(", \"rssi\": ").append(s.rssi).append('}'); + } + sb.append(']'); + sb.append(",\n \"serviceUuids\": ["); + size = d.serviceUuids.size(); + for (int i = 0; i < size; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('"').append(d.serviceUuids.get(i)).append('"'); + } + sb.append(']'); + sb.append(",\n \"manufacturerData\": {"); + boolean first = true; + for (Map.Entry e : d.manufacturerData.entrySet()) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append('"').append(e.getKey()).append("\": \"") + .append(Wire.encodeBase64(e.getValue())).append('"'); + } + sb.append('}'); + sb.append(",\n \"serviceData\": {"); + first = true; + for (Map.Entry e : d.serviceData.entrySet()) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append('"').append(e.getKey()).append("\": \"") + .append(Wire.encodeBase64(e.getValue())).append('"'); + } + sb.append('}'); + if (!d.gatt.isEmpty()) { + sb.append(",\n \"gatt\": ["); + int gs = d.gatt.size(); + for (int i = 0; i < gs; i++) { + if (i > 0) { + sb.append(','); + } + sb.append('\n'); + writeService(sb, d.gatt.get(i)); + } + sb.append("\n ]"); + } + sb.append('}'); + } + + private static void writeService(StringBuilder sb, ServiceRecord s) { + sb.append(" {\"uuid\": \"").append(s.uuid) + .append("\", \"primary\": ").append(s.primary) + .append(", \"characteristics\": ["); + int size = s.characteristics.size(); + for (int i = 0; i < size; i++) { + if (i > 0) { + sb.append(','); + } + sb.append('\n'); + writeCharacteristic(sb, s.characteristics.get(i)); + } + sb.append("]}"); + } + + private static void writeCharacteristic(StringBuilder sb, + CharacteristicRecord c) { + sb.append(" {\"uuid\": \"").append(c.uuid) + .append("\", \"properties\": ["); + String[] names = propertyNames(c.properties); + for (int i = 0; i < names.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('"').append(names[i]).append('"'); + } + sb.append(']'); + if (c.value != null) { + sb.append(", \"value\": \"").append(Wire.encodeBase64(c.value)) + .append('"'); + } + sb.append(", \"descriptors\": ["); + int size = c.descriptors.size(); + for (int i = 0; i < size; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append("{\"uuid\": \"").append(c.descriptors.get(i).uuid) + .append("\"}"); + } + sb.append("]}"); + } + + /** + * The wire names (see {@code PROTOCOL.md}) of a + * {@link GattCharacteristic}{@code .PROPERTY_*} bitmask; the inverse + * of {@link HelperBlePeripheral#propertiesMask(List)}. + */ + static String[] propertyNames(int mask) { + ArrayList out = new ArrayList(); + if ((mask & GattCharacteristic.PROPERTY_BROADCAST) != 0) { + out.add("broadcast"); + } + if ((mask & GattCharacteristic.PROPERTY_READ) != 0) { + out.add("read"); + } + if ((mask & GattCharacteristic.PROPERTY_WRITE_WITHOUT_RESPONSE) + != 0) { + out.add("writeWithoutResponse"); + } + if ((mask & GattCharacteristic.PROPERTY_WRITE) != 0) { + out.add("write"); + } + if ((mask & GattCharacteristic.PROPERTY_NOTIFY) != 0) { + out.add("notify"); + } + if ((mask & GattCharacteristic.PROPERTY_INDICATE) != 0) { + out.add("indicate"); + } + if ((mask & GattCharacteristic.PROPERTY_SIGNED_WRITE) != 0) { + out.add("signedWrite"); + } + if ((mask & GattCharacteristic.PROPERTY_EXTENDED_PROPS) != 0) { + out.add("extendedProps"); + } + return out.toArray(new String[out.size()]); + } + + // ------------------------------------------------------------------ + // JSON parsing + // ------------------------------------------------------------------ + + /** + * Parses a fixture from its JSON form. Throws {@link IOException} on + * malformed input or an unsupported format version. + */ + public static BluetoothFixture fromJson(InputStream in) + throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) >= 0) { + buffer.write(chunk, 0, n); + } + return fromJson(new String(buffer.toByteArray(), "UTF-8")); + } + + /** Parses a fixture from its JSON form. */ + public static BluetoothFixture fromJson(String json) throws IOException { + Map root = Wire.parse(json); + long version = Wire.longVal(root, "version", -1); + if (version != FORMAT_VERSION) { + throw new IOException("Unsupported fixture version " + version + + " (this build reads version " + FORMAT_VERSION + ")"); + } + BluetoothFixture fixture = new BluetoothFixture(); + fixture.platform = Wire.str(root, "platform", null); + List deviceArr = Wire.list(root, "devices"); + int size = deviceArr.size(); + for (int i = 0; i < size; i++) { + fixture.addDevice(parseDevice(Wire.map(deviceArr.get(i)))); + } + return fixture; + } + + private static Device parseDevice(Map obj) + throws IOException { + String id = Wire.str(obj, "id", ""); + if (id.length() == 0) { + throw new IOException("Fixture device without an id"); + } + Device d = new Device(id); + d.name = Wire.str(obj, "name", null); + d.connectable = Wire.boolVal(obj, "connectable", true); + if (obj.containsKey("txPower")) { + d.txPower = Integer.valueOf(Wire.intVal(obj, "txPower", 0)); + } + List timeline = Wire.list(obj, "rssiTimeline"); + int size = timeline.size(); + for (int i = 0; i < size; i++) { + Map s = Wire.map(timeline.get(i)); + d.rssiTimeline.add(new RssiSample( + Wire.longVal(s, "relTimeMs", 0), + Wire.intVal(s, "rssi", -127))); + } + List uuids = Wire.list(obj, "serviceUuids"); + size = uuids.size(); + for (int i = 0; i < size; i++) { + d.serviceUuids.add(parseUuid(String.valueOf(uuids.get(i)))); + } + Map manufacturer = + Wire.map(obj.get("manufacturerData")); + for (Map.Entry e : manufacturer.entrySet()) { + try { + d.manufacturerData.put(Integer.valueOf(e.getKey()), + Wire.decodeBase64(String.valueOf(e.getValue()))); + } catch (NumberFormatException ex) { + throw new IOException("Bad manufacturer company id: " + + e.getKey()); + } + } + Map serviceData = Wire.map(obj.get("serviceData")); + for (Map.Entry e : serviceData.entrySet()) { + d.serviceData.put(parseUuid(e.getKey()), + Wire.decodeBase64(String.valueOf(e.getValue()))); + } + List gatt = Wire.list(obj, "gatt"); + size = gatt.size(); + for (int i = 0; i < size; i++) { + d.gatt.add(parseService(Wire.map(gatt.get(i)))); + } + return d; + } + + private static ServiceRecord parseService(Map obj) + throws IOException { + ServiceRecord s = new ServiceRecord( + parseUuid(Wire.str(obj, "uuid", ""))); + s.primary = Wire.boolVal(obj, "primary", true); + List chars = Wire.list(obj, "characteristics"); + int size = chars.size(); + for (int i = 0; i < size; i++) { + Map ch = Wire.map(chars.get(i)); + CharacteristicRecord c = new CharacteristicRecord( + parseUuid(Wire.str(ch, "uuid", "")), + HelperBlePeripheral.propertiesMask( + Wire.list(ch, "properties"))); + if (ch.containsKey("value")) { + c.value = Wire.decodeBase64(Wire.str(ch, "value", "")); + } + List descriptors = Wire.list(ch, "descriptors"); + int ds = descriptors.size(); + for (int j = 0; j < ds; j++) { + c.descriptors.add(new DescriptorRecord(parseUuid(Wire.str( + Wire.map(descriptors.get(j)), "uuid", "")))); + } + s.characteristics.add(c); + } + return s; + } + + private static BluetoothUuid parseUuid(String s) throws IOException { + try { + return BluetoothUuid.fromString(s); + } catch (IllegalArgumentException ex) { + throw new IOException("Bad UUID in fixture: " + s); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothSimulator.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothSimulator.java new file mode 100644 index 00000000000..ba2b45ebe9d --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothSimulator.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; + +/** + * The scriptable facade of the JavaSE simulator's virtual Bluetooth world + * -- the API tests, sample apps and the future + * Simulate → Bluetooth menu use to stage devices and + * events: + * + *
+ * BluetoothSimulator.addPeripheral(
+ *         new VirtualPeripheral("AA:BB:CC:DD:EE:01")
+ *                 .setName("Heart Rate Strap")
+ *                 .addAdvertisedServiceUuid(BluetoothUuid.fromShort(0x180D))
+ *                 .withService(new VirtualService(
+ *                         BluetoothUuid.fromShort(0x180D))
+ *                         .withCharacteristic(new VirtualCharacteristic(
+ *                                 BluetoothUuid.fromShort(0x2A37),
+ *                                 GattCharacteristic.PROPERTY_READ
+ *                                         | GattCharacteristic.PROPERTY_NOTIFY,
+ *                                 new byte[] {0, 72}))));
+ * 
+ * + *

It is a thin static wrapper over one shared + * {@link SimulatedBluetoothStack} running on an {@link AutoScheduler}; + * {@link JavaSEBluetooth} routes the app-facing API onto the same + * instance. Tests that need full determinism construct their own stack + * with a {@link ManualScheduler} instead of going through this class.

+ */ +public final class BluetoothSimulator { + + private static SimulatedBluetoothStack stack; + + private BluetoothSimulator() { + } + + /** The shared virtual stack (created lazily), for advanced use. */ + public static synchronized SimulatedBluetoothStack getStack() { + if (stack == null) { + stack = new SimulatedBluetoothStack(new AutoScheduler()); + } + return stack; + } + + /** Restores the pristine simulation state. */ + public static void reset() { + getStack().reset(); + } + + /** Registers a virtual peripheral apps can discover and connect to. */ + public static void addPeripheral(VirtualPeripheral peripheral) { + getStack().addPeripheral(peripheral); + } + + /** Removes a virtual peripheral. */ + public static void removePeripheral(String address) { + getStack().removePeripheral(address); + } + + /** Removes every virtual peripheral. */ + public static void clearPeripherals() { + getStack().clearPeripherals(); + } + + public static boolean isPeripheralRegistered(String address) { + return getStack().isPeripheralRegistered(address); + } + + /** Turns the simulated adapter on or off. */ + public static void setAdapterEnabled(boolean enabled) { + getStack().setAdapterEnabled(enabled); + } + + public static boolean isAdapterEnabled() { + return getStack().isAdapterEnabled(); + } + + /** Sets the artificial latency applied to every async completion. */ + public static void setLatencyMillis(long millis) { + getStack().setLatencyMillis(millis); + } + + /** + * Scripts the next occurrence of an operation to fail -- see + * {@link SimulatedBluetoothStack#failNext(String, BluetoothError, + * String)} for the operation keys. + */ + public static void failNext(String op, BluetoothError error, + String message) { + getStack().failNext(op, error, message); + } + + /** + * Pushes a notification from a virtual peripheral to the app (only + * delivered while connected and subscribed). + */ + public static void pushNotification(String address, + BluetoothUuid serviceUuid, BluetoothUuid characteristicUuid, + byte[] value) { + getStack().pushNotification(address, serviceUuid, characteristicUuid, + value); + } + + /** Simulates the remote peripheral dropping the link. */ + public static void disconnectFromRemote(String address) { + getStack().disconnectFromRemote(address); + } + + /** + * Registers a virtual remote RFCOMM endpoint the app can connect to as + * a client. + */ + public static void addRfcommEndpoint(BluetoothUuid serviceUuid, + SimStreamHandler handler) { + getStack().addRfcommEndpoint(serviceUuid, handler); + } + + /** + * Connects a virtual RFCOMM client to the app's listener and returns + * the remote side of the channel. + */ + public static SimStreamChannel connectVirtualRfcommClient( + BluetoothUuid serviceUuid) { + return getStack().connectVirtualRfcommClient(serviceUuid); + } + + /** + * Connects a scripted virtual central to the app's GATT server; use + * the returned handle to read/write/subscribe against the services the + * app published. + */ + public static VirtualCentral connectVirtualCentral() { + return getStack().connectVirtualCentral(); + } + + /** + * Loads a recorded fixture (see {@link BluetoothFixture}) from its + * JSON form and replays it into the shared stack -- devices appear at + * their recorded first-sighting times and their RSSI timelines replay + * on the stack's scheduler. Returns the parsed fixture. + */ + public static BluetoothFixture loadFixture(java.io.InputStream in) + throws java.io.IOException { + BluetoothFixture fixture = BluetoothFixture.fromJson(in); + getStack().loadFixture(fixture); + return fixture; + } + + /** Replays an in-memory fixture into the shared stack. */ + public static void loadFixture(BluetoothFixture fixture) { + getStack().loadFixture(fixture); + } + + /** Registers an event-log listener (for the debug UI). */ + public static void addEventListener(StackEventListener listener) { + getStack().addEventListener(listener); + } + + /** Removes an event-log listener. */ + public static void removeEventListener(StackEventListener listener) { + getStack().removeEventListener(listener); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ByteArrays.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ByteArrays.java new file mode 100644 index 00000000000..f6f23a8822e --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ByteArrays.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +/** + * Byte array helpers shared by the simulated Bluetooth stack. Values cross + * the simulated "air gap" as defensive copies so neither side can mutate + * the other's buffers. + */ +final class ByteArrays { + + private ByteArrays() { + } + + /** A defensive copy; {@code null} becomes an empty array. */ + static byte[] copy(byte[] in) { + if (in == null) { + return new byte[0]; + } + byte[] out = new byte[in.length]; + System.arraycopy(in, 0, out, 0, in.length); + return out; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureCaptureMain.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureCaptureMain.java new file mode 100644 index 00000000000..f8789f14dac --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureCaptureMain.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothException; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.Device; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +/** + * Command-line capture of a scrambled {@link BluetoothFixture} from this + * machine's real radio -- the tool behind + * {@code scripts/bluetooth/capture-fixture.sh}. + * + *
+ * java com.codename1.impl.javase.bluetooth.FixtureCaptureMain \
+ *     --seconds 12 --seed 42 --out ambient-scan.json \
+ *     [--gatt <address> ...] [--gatt-strongest] [--helper <path>]
+ * 
+ * + *
    + *
  • {@code --seconds N} -- scan duration (default 12)
  • + *
  • {@code --seed S} -- {@link FixtureScrambler} seed (default 42)
  • + *
  • {@code --out file.json} -- output path (required)
  • + *
  • {@code --gatt address} -- also capture the GATT database of the + * sighted device with this (real, unscrambled) address; repeatable + *
  • + *
  • {@code --gatt-strongest} -- attempt a GATT capture on the + * connectable device with the strongest sighting
  • + *
  • {@code --helper path} -- explicit cn1-ble-helper binary (sets + * {@code cn1.bluetooth.helperPath})
  • + *
+ * + *

The written fixture is ALWAYS scrambled, and the scrambler's no-PII + * invariant ({@link FixtureScrambler#findLeaks}) is verified before the + * file is written; a leak aborts with exit code 3. Real identities only + * ever appear in this process's stderr (for the operator). Exit codes: + * 0 success, 1 bad usage, 2 capture failure, 3 leak detected.

+ */ +public final class FixtureCaptureMain { + + private FixtureCaptureMain() { + } + + public static void main(String[] args) { + long seconds = 12; + long seed = 42; + String out = null; + boolean gattStrongest = false; + ArrayList gattAddresses = new ArrayList(); + try { + for (int i = 0; i < args.length; i++) { + String a = args[i]; + if ("--seconds".equals(a)) { + seconds = Long.parseLong(args[++i]); + } else if ("--seed".equals(a)) { + seed = Long.parseLong(args[++i]); + } else if ("--out".equals(a)) { + out = args[++i]; + } else if ("--gatt".equals(a)) { + gattAddresses.add(args[++i]); + } else if ("--gatt-strongest".equals(a)) { + gattStrongest = true; + } else if ("--helper".equals(a)) { + System.setProperty( + HelperBinaryResolver.HELPER_PATH_PROPERTY, + args[++i]); + } else { + throw new IllegalArgumentException( + "Unknown argument: " + a); + } + } + if (out == null) { + throw new IllegalArgumentException("--out is required"); + } + } catch (RuntimeException ex) { + System.err.println("Usage: FixtureCaptureMain --out file.json " + + "[--seconds N] [--seed S] [--gatt address ...] " + + "[--gatt-strongest] [--helper path]"); + System.err.println(ex.getMessage()); + System.exit(1); + return; + } + + FixtureRecorder recorder = null; + try { + recorder = FixtureRecorder.forNativeBackend(); + System.err.println("Scanning for " + seconds + "s ..."); + BluetoothFixture raw = recorder.record(seconds * 1000, + gattAddresses, gattStrongest); + BluetoothFixture scrambled = + FixtureScrambler.scramble(raw, seed); + List leaks = FixtureScrambler.findLeaks(raw, scrambled); + if (!leaks.isEmpty()) { + System.err.println("LEAK: original identifiers survived " + + "scrambling: " + leaks + " -- nothing written"); + System.exit(3); + return; + } + writeFile(new File(out), scrambled.toJson()); + printSummary(raw, scrambled, out); + } catch (BluetoothException ex) { + System.err.println("Capture failed (" + ex.getError() + "): " + + ex.getMessage()); + System.exit(2); + } catch (IOException ex) { + System.err.println("Cannot write fixture: " + ex); + System.exit(2); + } finally { + if (recorder != null) { + recorder.close(); + } + } + } + + private static void writeFile(File file, String content) + throws IOException { + File parent = file.getAbsoluteFile().getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Cannot create " + parent); + } + Writer w = new OutputStreamWriter(new FileOutputStream(file), + "UTF-8"); + try { + w.write(content); + } finally { + w.close(); + } + } + + /** + * Operator summary: scrambled identities on stdout; the real-identity + * mapping goes to stderr only (never into a file). + */ + private static void printSummary(BluetoothFixture raw, + BluetoothFixture scrambled, String out) { + List rawDevices = raw.getDevices(); + List outDevices = scrambled.getDevices(); + System.out.println("Captured " + outDevices.size() + + " device(s) -> " + out); + for (int i = 0; i < outDevices.size(); i++) { + Device d = outDevices.get(i); + Device r = rawDevices.get(i); + System.out.println(" " + d.id + + (d.name == null ? "" : " \"" + d.name + "\"") + + " sightings=" + d.rssiTimeline.size() + + " advUuids=" + d.serviceUuids.size() + + " mfg=" + d.manufacturerData.size() + + " gatt=" + (d.hasGatt() + ? d.gatt.size() + " service(s)" : "no")); + System.err.println(" [mapping] " + r.id + " (" + + (r.name == null ? "?" : r.name) + ") -> " + d.id); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureRecorder.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureRecorder.java new file mode 100644 index 00000000000..b5bbcb8889a --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureRecorder.java @@ -0,0 +1,437 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.helper.BleBackend; +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.CharacteristicRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.DescriptorRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.Device; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.RssiSample; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.ServiceRecord; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Records a {@link BluetoothFixture} from a live {@link BleBackend} -- + * normally the {@link NativeBleBackend} driving this machine's real radio + * through the {@code cn1-ble-helper} subprocess. Capture is entirely + * Java-side over the existing helper protocol; nothing native is needed + * beyond the helper binary itself. + * + *

{@link #record(long, List, boolean)} scans for the requested + * duration, folding every sighting into per-device records (RSSI + * timeline with relative timestamps, advertisement payloads), then + * optionally connects to selected devices and captures their GATT + * database plus every readable characteristic value. GATT failures on a + * device (random-address peripherals routinely refuse connects) degrade + * to a scan-only record for that device instead of failing the capture. + *

+ * + *

Adapter problems surface as typed {@link BluetoothException}s -- + * {@code UNAUTHORIZED} when the OS denied Bluetooth permission, + * {@code POWERED_OFF}, {@code NOT_SUPPORTED} -- and every wait is + * bounded, so a capture never hangs.

+ * + *

Committed fixtures must be scrambled: + * {@link #recordScrambled(long, List, boolean, long)} chains into + * {@link FixtureScrambler}.

+ */ +public final class FixtureRecorder { + + /** How long to wait for the adapter handshake before failing. */ + public static final long ADAPTER_TIMEOUT_MILLIS = 10000; + + /** Per-operation GATT timeout (connect/discover/read). */ + public static final long GATT_TIMEOUT_MILLIS = 15000; + + private final BleBackend backend; + private final boolean ownsBackend; + + /** Records from an externally managed backend (not shut down here). */ + public FixtureRecorder(BleBackend backend) { + this(backend, false); + } + + private FixtureRecorder(BleBackend backend, boolean ownsBackend) { + if (backend == null) { + throw new IllegalArgumentException("backend is required"); + } + this.backend = backend; + this.ownsBackend = ownsBackend; + } + + /** + * Creates a recorder over a fresh {@link NativeBleBackend} (the real + * radio); {@link #close()} shuts it down. Throws a typed exception + * with the resolution trace when no helper binary exists for this + * host. + */ + public static FixtureRecorder forNativeBackend() + throws BluetoothException { + NativeBleBackend nativeBackend = new NativeBleBackend(); + if (!nativeBackend.isAvailable()) { + throw new BluetoothException(BluetoothError.NOT_SUPPORTED, + "The cn1-ble-helper binary was not found; tried: " + + nativeBackend.describeResolution()); + } + return new FixtureRecorder(nativeBackend, true); + } + + /** Releases the backend when this recorder created it. */ + public void close() { + if (ownsBackend) { + backend.shutdown(); + } + } + + /** + * Records and scrambles in one step -- the form used for fixtures + * that are going to be committed. + */ + public BluetoothFixture recordScrambled(long scanMillis, + List gattAddresses, boolean gattStrongest, long seed) + throws BluetoothException { + return FixtureScrambler.scramble( + record(scanMillis, gattAddresses, gattStrongest), seed); + } + + /** + * Scans for {@code scanMillis}, then attempts a GATT capture on every + * sighted device in {@code gattAddresses} (may be {@code null}) plus + * -- when {@code gattStrongest} -- the connectable device with the + * strongest sighting. The returned fixture is UNSCRAMBLED: it carries + * real identities and must go through {@link FixtureScrambler} before + * leaving this machine. + */ + public BluetoothFixture record(long scanMillis, + List gattAddresses, boolean gattStrongest) + throws BluetoothException { + awaitAdapterReady(); + + final LinkedHashMap drafts = + new LinkedHashMap(); + final AtomicReference scanFailure = + new AtomicReference(); + final long start = System.currentTimeMillis(); + backend.startScan(new BleBackend.ScanSink() { + public void onResult(ScanResult result) { + recordSighting(drafts, result, + System.currentTimeMillis() - start); + } + + public void onFailed(BluetoothException reason) { + scanFailure.set(reason); + } + }); + try { + long deadline = start + Math.max(0, scanMillis); + while (System.currentTimeMillis() < deadline) { + BluetoothException failure = scanFailure.get(); + if (failure != null) { + throw failure; + } + sleep(50); + } + } finally { + backend.stopScan(); + } + BluetoothException failure = scanFailure.get(); + if (failure != null) { + throw failure; + } + + BluetoothFixture fixture = new BluetoothFixture() + .setPlatform(System.getProperty("os.name", "unknown") + " " + + System.getProperty("os.version", "") + + " (cn1-ble-helper)"); + ArrayList devices; + synchronized (drafts) { + devices = new ArrayList(drafts.values()); + } + int size = devices.size(); + for (int i = 0; i < size; i++) { + fixture.addDevice(devices.get(i)); + } + + ArrayList targets = new ArrayList(); + if (gattAddresses != null) { + targets.addAll(gattAddresses); + } + if (gattStrongest) { + String strongest = strongestConnectable(devices); + if (strongest != null && !targets.contains(strongest)) { + targets.add(strongest); + } + } + int ts = targets.size(); + for (int i = 0; i < ts; i++) { + String address = targets.get(i); + Device device = fixture.getDevice(address); + if (device == null) { + System.err.println("FixtureRecorder: GATT capture skipped, " + + "device was never sighted: " + address); + continue; + } + captureGatt(device); + } + return fixture; + } + + /** Folds one scan sighting into the per-device draft. */ + private static void recordSighting(Map drafts, + ScanResult result, long relTimeMs) { + String address = result.getPeripheral().getAddress(); + AdvertisementData ad = result.getAdvertisementData(); + synchronized (drafts) { + Device d = drafts.get(address); + if (d == null) { + d = new Device(address); + drafts.put(address, d); + } + d.rssiTimeline.add(new RssiSample(Math.max(0, relTimeMs), + result.getRssi())); + d.connectable = result.isConnectable(); + if (ad == null) { + return; + } + if (ad.getLocalName() != null + && ad.getLocalName().length() > 0) { + d.name = ad.getLocalName(); + } + List uuids = ad.getServiceUuids(); + int size = uuids.size(); + for (int i = 0; i < size; i++) { + if (!d.serviceUuids.contains(uuids.get(i))) { + d.serviceUuids.add(uuids.get(i)); + } + } + int[] companyIds = ad.getManufacturerIds(); + for (int i = 0; i < companyIds.length; i++) { + byte[] payload = ad.getManufacturerData(companyIds[i]); + if (payload != null) { + d.manufacturerData.put(Integer.valueOf(companyIds[i]), + payload); + } + } + List dataUuids = ad.getServiceDataUuids(); + size = dataUuids.size(); + for (int i = 0; i < size; i++) { + byte[] payload = ad.getServiceData(dataUuids.get(i)); + if (payload != null) { + d.serviceData.put(dataUuids.get(i), payload); + } + } + if (ad.getTxPowerLevel() != null) { + d.txPower = ad.getTxPowerLevel(); + } + } + } + + /** The connectable device with the strongest sighting, or null. */ + static String strongestConnectable(List devices) { + String best = null; + int bestRssi = Integer.MIN_VALUE; + int size = devices.size(); + for (int i = 0; i < size; i++) { + Device d = devices.get(i); + if (!d.connectable) { + continue; + } + int rs = d.rssiTimeline.size(); + for (int j = 0; j < rs; j++) { + int rssi = d.rssiTimeline.get(j).rssi; + if (rssi > bestRssi) { + bestRssi = rssi; + best = d.id; + } + } + } + return best; + } + + /** + * Connects, discovers and reads every readable characteristic of the + * device, filling {@code device.gatt}. Returns whether the capture + * succeeded; failures leave the device scan-only. + */ + public boolean captureGatt(Device device) { + BlePeripheral p = backend.getPeripheral(device.id); + if (p == null) { + System.err.println("FixtureRecorder: no peripheral handle for " + + device.id); + return false; + } + try { + await("connect " + device.id, p.connect()); + List services = await("discover " + device.id, + p.discoverServices()); + ArrayList records = + new ArrayList(); + int size = services.size(); + for (int i = 0; i < size; i++) { + GattService s = services.get(i); + ServiceRecord sr = new ServiceRecord(s.getUuid()); + sr.primary = s.isPrimary(); + List chars = s.getCharacteristics(); + int cs = chars.size(); + for (int j = 0; j < cs; j++) { + GattCharacteristic c = chars.get(j); + CharacteristicRecord cr = new CharacteristicRecord( + c.getUuid(), c.getProperties()); + List descriptors = c.getDescriptors(); + int ds = descriptors.size(); + for (int k = 0; k < ds; k++) { + cr.descriptors.add(new DescriptorRecord( + descriptors.get(k).getUuid())); + } + if (c.canRead()) { + try { + cr.value = await("read " + c.getUuid(), + c.read()); + } catch (BluetoothException ex) { + // some readable characteristics reject reads + // (encryption required etc.) -- keep going + System.err.println("FixtureRecorder: read of " + + c.getUuid() + " failed: " + + ex.getMessage()); + } + } + sr.characteristics.add(cr); + } + records.add(sr); + } + device.gatt.clear(); + device.gatt.addAll(records); + return true; + } catch (BluetoothException ex) { + System.err.println("FixtureRecorder: GATT capture of " + + device.id + " failed: " + ex.getMessage()); + return false; + } finally { + try { + p.disconnect(); + } catch (RuntimeException ignored) { + } + } + } + + // ------------------------------------------------------------------ + // plumbing + // ------------------------------------------------------------------ + + /** + * Boots the backend (installing an adapter sink is its activation + * point) and waits -- bounded -- until the adapter reports a usable + * state. Anything but {@code POWERED_ON} becomes a typed failure. + */ + private void awaitAdapterReady() throws BluetoothException { + backend.setAdapterStateSink(new BleBackend.AdapterStateSink() { + public void adapterStateChanged(AdapterState newState) { + // polling below reads getAdapterState(); the sink only + // exists to activate the backend + } + }); + long deadline = System.currentTimeMillis() + ADAPTER_TIMEOUT_MILLIS; + while (backend.getAdapterState() == AdapterState.UNKNOWN + && System.currentTimeMillis() < deadline) { + sleep(20); + } + AdapterState state = backend.getAdapterState(); + if (state == AdapterState.POWERED_ON) { + return; + } + if (state == AdapterState.UNAUTHORIZED) { + throw new BluetoothException(BluetoothError.UNAUTHORIZED, + "The OS denied Bluetooth access to this process -- on " + + "macOS grant it under System Settings > " + + "Privacy & Security > Bluetooth"); + } + if (state == AdapterState.POWERED_OFF) { + throw new BluetoothException(BluetoothError.POWERED_OFF, + "The Bluetooth adapter is powered off"); + } + if (state == AdapterState.UNSUPPORTED) { + throw new BluetoothException(BluetoothError.NOT_SUPPORTED, + "No usable Bluetooth adapter (or the helper could not " + + "access it)"); + } + throw new BluetoothException(BluetoothError.IO_ERROR, + "The Bluetooth helper never completed its adapter " + + "handshake within " + ADAPTER_TIMEOUT_MILLIS + + "ms"); + } + + /** Bounded blocking wait on an {@link AsyncResource}. */ + private static T await(String what, AsyncResource op) + throws BluetoothException { + long deadline = System.currentTimeMillis() + GATT_TIMEOUT_MILLIS; + while (!op.isDone() && System.currentTimeMillis() < deadline) { + sleep(20); + } + if (!op.isDone()) { + throw new BluetoothException(BluetoothError.TIMEOUT, + "Timed out waiting for " + what); + } + final AtomicReference error = + new AtomicReference(); + op.except(new com.codename1.util.SuccessCallback() { + public void onSucess(Throwable t) { + error.set(t); + } + }); + Throwable t = error.get(); + if (t instanceof BluetoothException) { + throw (BluetoothException) t; + } + if (t != null) { + throw new BluetoothException(BluetoothError.UNKNOWN, + what + " failed: " + t, t); + } + return op.get(null); + } + + private static void sleep(long millis) throws BluetoothException { + try { + Thread.sleep(millis); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new BluetoothException(BluetoothError.UNKNOWN, + "Fixture capture was interrupted"); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureScrambler.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureScrambler.java new file mode 100644 index 00000000000..0c666299cef --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/FixtureScrambler.java @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.CharacteristicRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.Device; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.DescriptorRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.RssiSample; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.ServiceRecord; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +/** + * Deterministic, structure-preserving anonymization of a + * {@link BluetoothFixture} before it is committed: real-world traces must + * never leak device identities into the repository, but the replayed + * shape (payload lengths, company ids, SIG UUIDs, GATT topology, RSSI + * timelines) must stay exactly as captured so tests exercise realistic + * data. + * + *

{@link #scramble(BluetoothFixture, long)} is a pure function + * {@code fixture -> fixture}: the input is never mutated and the same + * seed always produces the identical output. Rules:

+ * + *
    + *
  • Device ids become synthetic {@code SC:RA:MB:xx:xx:xx} addresses + * -- a seeded substitution that is stable per device within the + * trace.
  • + *
  • Local names become {@code Device-XXXX} ({@code XXXX} from a + * seeded hash); the length class is preserved -- short names + * (≤ {@value #SHORT_NAME_LIMIT} chars) become the shorter + * {@code Dev-XXXX} form.
  • + *
  • Manufacturer-data payloads are replaced by seeded-random bytes + * of the same length; SIG company ids are kept.
  • + *
  • Service-data payloads are likewise length-preserving scrambled; + * their UUID keys follow the UUID rule below.
  • + *
  • Standard SIG short UUIDs ({@link BluetoothUuid#isShortUuid()}) + * are kept verbatim; custom 128-bit UUIDs are remapped through a + * seeded substitution that is stable within the trace (the same + * custom UUID maps to the same replacement wherever it occurs -- + * advertisement, service data, GATT).
  • + *
  • Captured characteristic values are replaced by seeded-random + * bytes of the same length.
  • + *
  • Timing (RSSI timelines), TX power, connectable flags, GATT + * properties/topology and the platform note are kept.
  • + *
+ */ +public final class FixtureScrambler { + + /** Names at most this long keep the short {@code Dev-XXXX} form. */ + public static final int SHORT_NAME_LIMIT = 7; + + private FixtureScrambler() { + } + + /** Scrambles the fixture (pure function; the input is not mutated). */ + public static BluetoothFixture scramble(BluetoothFixture in, long seed) { + BluetoothFixture out = new BluetoothFixture() + .setPlatform(in.getPlatform()); + HashMap uuidMap = + new HashMap(); + HashSet usedIds = new HashSet(); + List devices = in.getDevices(); + int size = devices.size(); + for (int i = 0; i < size; i++) { + out.addDevice(scrambleDevice(devices.get(i), seed, uuidMap, + usedIds)); + } + return out; + } + + private static Device scrambleDevice(Device in, long seed, + Map uuidMap, + HashSet usedIds) { + Device out = new Device(scrambleId(in.id, seed, usedIds)); + out.name = scrambleName(in.name, seed); + out.connectable = in.connectable; + out.txPower = in.txPower; + int size = in.rssiTimeline.size(); + for (int i = 0; i < size; i++) { + RssiSample s = in.rssiTimeline.get(i); + out.rssiTimeline.add(new RssiSample(s.relTimeMs, s.rssi)); + } + size = in.serviceUuids.size(); + for (int i = 0; i < size; i++) { + out.serviceUuids.add(scrambleUuid(in.serviceUuids.get(i), seed, + uuidMap)); + } + for (Map.Entry e : in.manufacturerData.entrySet()) { + out.manufacturerData.put(e.getKey(), scrambleBytes(seed, + in.id + "|mfg|" + e.getKey(), e.getValue())); + } + for (Map.Entry e + : in.serviceData.entrySet()) { + out.serviceData.put(scrambleUuid(e.getKey(), seed, uuidMap), + scrambleBytes(seed, in.id + "|svcdata|" + e.getKey(), + e.getValue())); + } + size = in.gatt.size(); + for (int i = 0; i < size; i++) { + out.gatt.add(scrambleService(in.gatt.get(i), in.id, seed, + uuidMap)); + } + return out; + } + + private static ServiceRecord scrambleService(ServiceRecord in, + String deviceId, long seed, + Map uuidMap) { + ServiceRecord out = new ServiceRecord( + scrambleUuid(in.uuid, seed, uuidMap)); + out.primary = in.primary; + int size = in.characteristics.size(); + for (int i = 0; i < size; i++) { + CharacteristicRecord c = in.characteristics.get(i); + CharacteristicRecord sc = new CharacteristicRecord( + scrambleUuid(c.uuid, seed, uuidMap), c.properties); + if (c.value != null) { + sc.value = scrambleBytes(seed, + deviceId + "|value|" + in.uuid + "|" + c.uuid, + c.value); + } + int ds = c.descriptors.size(); + for (int j = 0; j < ds; j++) { + sc.descriptors.add(new DescriptorRecord(scrambleUuid( + c.descriptors.get(j).uuid, seed, uuidMap))); + } + out.characteristics.add(sc); + } + return out; + } + + // ------------------------------------------------------------------ + // the individual scrambling rules + // ------------------------------------------------------------------ + + /** + * A synthetic {@code SC:RA:MB:xx:xx:xx} address derived from the + * original id -- stable per device, collision-resolved within the + * trace by re-hashing. + */ + static String scrambleId(String id, long seed, HashSet usedIds) { + for (int salt = 0; ; salt++) { + long h = mix(seed, id, salt); + String candidate = String.format("SC:RA:MB:%02X:%02X:%02X", + Long.valueOf(h & 0xFF), Long.valueOf((h >>> 8) & 0xFF), + Long.valueOf((h >>> 16) & 0xFF)); + if (usedIds.add(candidate)) { + return candidate; + } + } + } + + /** + * {@code Device-XXXX} (or {@code Dev-XXXX} for short originals) with + * four seeded hash characters; {@code null}/empty names pass through. + */ + static String scrambleName(String name, long seed) { + if (name == null || name.length() == 0) { + return name; + } + String hash = String.format("%04X", + Long.valueOf(mix(seed, name, 100) & 0xFFFF)); + return (name.length() <= SHORT_NAME_LIMIT ? "Dev-" : "Device-") + + hash; + } + + /** + * SIG short UUIDs pass through verbatim; custom 128-bit UUIDs map to + * a seeded replacement, memoized so every occurrence of the same UUID + * within the trace scrambles identically. + */ + static BluetoothUuid scrambleUuid(BluetoothUuid uuid, long seed, + Map uuidMap) { + if (uuid == null || uuid.isShortUuid()) { + return uuid; + } + BluetoothUuid mapped = uuidMap.get(uuid); + if (mapped == null) { + String key = uuid.toString(); + long msb = mix(seed, key, 200); + long lsb = mix(seed, key, 201); + mapped = new BluetoothUuid(msb, lsb); + if (mapped.isShortUuid()) { + // astronomically unlikely, but a scrambled custom UUID + // must never masquerade as a SIG assigned number + mapped = new BluetoothUuid(msb ^ 1, lsb); + } + uuidMap.put(uuid, mapped); + } + return mapped; + } + + /** Seeded-random replacement bytes of the same length. */ + static byte[] scrambleBytes(long seed, String context, byte[] original) { + if (original == null) { + return null; + } + byte[] out = new byte[original.length]; + long state = mix(seed, context, 300); + for (int i = 0; i < out.length; i++) { + state = splitmix(state); + out[i] = (byte) (state >>> 32); + } + return out; + } + + // ------------------------------------------------------------------ + // verification + // ------------------------------------------------------------------ + + /** + * The no-PII invariant check: returns every original identifier + * (device id, local name, custom UUID) that still appears verbatim in + * the scrambled fixture's JSON. Empty means clean; used by the tests + * and by {@link FixtureCaptureMain} before a fixture is written. + */ + public static List findLeaks(BluetoothFixture original, + BluetoothFixture scrambled) { + String json = scrambled.toJson(); + String lower = json.toLowerCase(); + ArrayList leaks = new ArrayList(); + List devices = original.getDevices(); + int size = devices.size(); + for (int i = 0; i < size; i++) { + Device d = devices.get(i); + checkLeak(lower, d.id, leaks); + checkLeak(lower, d.name, leaks); + int us = d.serviceUuids.size(); + for (int j = 0; j < us; j++) { + checkUuidLeak(lower, d.serviceUuids.get(j), leaks); + } + for (BluetoothUuid u : d.serviceData.keySet()) { + checkUuidLeak(lower, u, leaks); + } + int gs = d.gatt.size(); + for (int j = 0; j < gs; j++) { + ServiceRecord s = d.gatt.get(j); + checkUuidLeak(lower, s.uuid, leaks); + int cs = s.characteristics.size(); + for (int k = 0; k < cs; k++) { + checkUuidLeak(lower, s.characteristics.get(k).uuid, + leaks); + } + } + } + return leaks; + } + + private static void checkLeak(String scrambledJsonLower, String value, + List leaks) { + if (value == null || value.length() < 3) { + return; + } + if (scrambledJsonLower.contains(value.toLowerCase()) + && !leaks.contains(value)) { + leaks.add(value); + } + } + + private static void checkUuidLeak(String scrambledJsonLower, + BluetoothUuid uuid, List leaks) { + if (uuid == null || uuid.isShortUuid()) { + return; // SIG UUIDs are kept by design + } + checkLeak(scrambledJsonLower, uuid.toString(), leaks); + } + + // ------------------------------------------------------------------ + // deterministic hashing + // ------------------------------------------------------------------ + + /** A seeded 64-bit string hash (FNV walk + finalizer). */ + private static long mix(long seed, String s, int salt) { + long h = seed ^ (0x9E3779B97F4A7C15L * (salt + 1)); + int len = s.length(); + for (int i = 0; i < len; i++) { + h ^= s.charAt(i); + h *= 0x100000001B3L; + h ^= h >>> 29; + } + return finalize(h); + } + + /** One step of the splitmix64 sequence. */ + private static long splitmix(long state) { + return finalize(state + 0x9E3779B97F4A7C15L); + } + + private static long finalize(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/HelperBinaryResolver.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/HelperBinaryResolver.java new file mode 100644 index 00000000000..907a95bcbd9 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/HelperBinaryResolver.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * Locates the bundled {@code cn1-ble-helper} executable for the current + * host, so the JavaSE simulator and the native Windows/Linux ports resolve + * it identically. Resolution order: the {@link #HELPER_PATH_PROPERTY} + * system property, the OS/arch-keyed classpath resource under + * {@link #HELPER_RESOURCE_DIR} (extracted to a temp file), then a + * {@code PATH} lookup. {@code attempted} collects a human-readable trace of + * every location tried for error messages. + */ +public final class HelperBinaryResolver { + + private HelperBinaryResolver() { + } + + /** + * System property naming an explicit helper binary; checked before the + * classpath resource and the {@code PATH} lookup. + */ + public static final String HELPER_PATH_PROPERTY = + "cn1.bluetooth.helperPath"; + + /** Classpath directory of the OS-keyed bundled helper binaries. */ + public static final String HELPER_RESOURCE_DIR = + "/com/codename1/impl/javase/bluetooth/native/"; + + /** Base name of the helper executable. */ + public static final String HELPER_BASENAME = "cn1-ble-helper"; + + /** + * Resolution order: explicit system property, bundled classpath + * resource for the OS (extracted to a temp file), {@code PATH} lookup. + * Returns {@code null} when nothing was found; {@code attempted} + * collects a human-readable trace for error messages. + */ + public static File resolveHelperBinary(String propertyValue, String osName, + String osArch, String pathEnv, List attempted) { + if (propertyValue != null && propertyValue.length() > 0) { + File f = new File(propertyValue); + if (f.isFile()) { + attempted.add("system property " + HELPER_PATH_PROPERTY + + " => " + f.getAbsolutePath()); + return f; + } + attempted.add("system property " + HELPER_PATH_PROPERTY + "=" + + propertyValue + " (no such file)"); + } else { + attempted.add("system property " + HELPER_PATH_PROPERTY + + " (not set)"); + } + String resourcePath = helperResourcePath(osName, osArch); + if (resourcePath == null) { + attempted.add("no bundled helper for os.name=" + osName + + " os.arch=" + osArch); + } else { + File extracted = extractResource(resourcePath, attempted); + if (extracted != null) { + return extracted; + } + } + File onPath = resolveFromPathEnv(pathEnv, + helperExecutableName(osName), attempted); + if (onPath != null) { + return onPath; + } + return null; + } + + /** + * Classpath location of the helper binary for the OS and CPU + * architecture, or {@code null} when no binary is bundled for the + * combination. The binaries ship from the cn1-binaries repository via + * the maven/javase resource mapping, laid out as + * {@code ble/macos/cn1-ble-helper} (a universal Mach-O binary covering + * both architectures) and {@code ble/{linux,windows}/{x64,arm64}/} + * (ELF and PE have no fat-binary format, so those are per-arch). + */ + public static String helperResourcePath(String osName, String osArch) { + String os = osName == null ? "" : osName.toLowerCase(); + if (os.contains("mac") || os.contains("darwin")) { + // universal binary: one file serves x86_64 and arm64 + return HELPER_RESOURCE_DIR + "macos/" + HELPER_BASENAME; + } + String arch = normalizeArch(osArch); + if (arch == null) { + return null; + } + if (os.contains("linux")) { + return HELPER_RESOURCE_DIR + "linux/" + arch + "/" + + HELPER_BASENAME; + } + if (os.contains("windows")) { + return HELPER_RESOURCE_DIR + "windows/" + arch + "/" + + HELPER_BASENAME + ".exe"; + } + return null; + } + + /** + * Maps {@code os.arch} onto the directory names used by the bundled + * binaries, or {@code null} for architectures no binary is shipped for + * (32-bit x86 and 32-bit ARM among them) -- resolution then falls + * through to the {@code PATH} lookup, so a self-built helper still + * works there. + */ + public static String normalizeArch(String osArch) { + String arch = osArch == null ? "" : osArch.toLowerCase(); + if (arch.equals("amd64") || arch.equals("x86_64") + || arch.equals("x64")) { + return "x64"; + } + if (arch.equals("aarch64") || arch.equals("arm64")) { + return "arm64"; + } + return null; + } + + /** The platform file name of the helper executable. */ + public static String helperExecutableName(String osName) { + String os = osName == null ? "" : osName.toLowerCase(); + return os.contains("windows") ? HELPER_BASENAME + ".exe" + : HELPER_BASENAME; + } + + /** Extracts the bundled helper to a temp file, or null when absent. */ + private static File extractResource(String resourcePath, + List attempted) { + InputStream src = + HelperBinaryResolver.class.getResourceAsStream(resourcePath); + if (src == null) { + attempted.add("classpath resource " + resourcePath + + " (missing)"); + return null; + } + try { + boolean exe = resourcePath.endsWith(".exe"); + File out = File.createTempFile(HELPER_BASENAME + "-", + exe ? ".exe" : ""); + out.deleteOnExit(); + FileOutputStream sink = new FileOutputStream(out); + try { + byte[] buf = new byte[8192]; + int n; + while ((n = src.read(buf)) >= 0) { + sink.write(buf, 0, n); + } + } finally { + sink.close(); + } + out.setExecutable(true, true); + attempted.add("classpath resource " + resourcePath + + " => " + out.getAbsolutePath()); + return out; + } catch (IOException ex) { + attempted.add("classpath resource " + resourcePath + + " (extraction failed: " + ex + ")"); + return null; + } finally { + try { + src.close(); + } catch (IOException ignored) { + } + } + } + + /** Scans the given PATH-style value for the helper executable. */ + public static File resolveFromPathEnv(String pathEnv, String executableName, + List attempted) { + if (pathEnv != null) { + String[] dirs = pathEnv.split(File.pathSeparator); + for (int i = 0; i < dirs.length; i++) { + if (dirs[i].length() == 0) { + continue; + } + File candidate = new File(dirs[i], executableName); + if (candidate.isFile()) { + attempted.add("PATH lookup => " + + candidate.getAbsolutePath()); + return candidate; + } + } + } + attempted.add("PATH lookup for " + executableName + " (not found)"); + return null; + } + + /** Joins the attempted-location trace into one description string. */ + public static String join(List parts) { + StringBuilder sb = new StringBuilder(); + int size = parts.size(); + for (int i = 0; i < size; i++) { + if (i > 0) { + sb.append("; "); + } + sb.append(parts.get(i)); + } + return sb.toString(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetooth.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetooth.java new file mode 100644 index 00000000000..63fa9147f80 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetooth.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothPermission; +import com.codename1.bluetooth.helper.BleBackend; +import com.codename1.util.AsyncResource; + +/** + * JavaSE-simulator backing for {@link Bluetooth}. The heavy lifting lives + * behind the {@link BleBackend} seam: by default the scriptable + * {@link SimulatorBleBackend} over {@link BluetoothSimulator}'s shared + * virtual stack; {@link #switchBackend(String)} is the mount point for the + * future native (host radio) backend. The initial backend comes from the + * {@code cn1.bluetooth.backend} system property (default + * {@code "simulator"}). + * + *

Simulation toggles follow the {@code JavaSENfc} convention of public + * static flags the Simulate menu can flip.

+ */ +public class JavaSEBluetooth extends Bluetooth { + + /** + * Toggle from the future Simulate menu: when {@code true} (the + * default) permission checks and requests are granted; when + * {@code false} they are denied. + */ + public static volatile boolean simGrantPermissions = true; + + /** The system property selecting the initial backend. */ + public static final String BACKEND_PROPERTY = "cn1.bluetooth.backend"; + + /** {@link #switchBackend(String)} name of the simulated stack. */ + public static final String BACKEND_SIMULATOR = SimulatorBleBackend.NAME; + + /** {@link #switchBackend(String)} name of the real-radio backend. */ + public static final String BACKEND_NATIVE = "native"; + + private volatile BleBackend backend; + private final JavaSEBluetoothLE le = new JavaSEBluetoothLE(this); + private final JavaSEBluetoothClassic classic = + new JavaSEBluetoothClassic(this); + + public JavaSEBluetooth() { + String name = System.getProperty(BACKEND_PROPERTY, + BACKEND_SIMULATOR); + try { + switchBackend(name); + } catch (RuntimeException ex) { + // unknown/unavailable backend requested -- fall back to the + // simulator so the API stays usable + switchBackend(BACKEND_SIMULATOR); + } + } + + /** The current backend; never {@code null}. */ + BleBackend backend() { + return backend; + } + + /** + * Switches the BLE engine. {@code "simulator"} activates the shared + * virtual stack; {@code "native"} activates the host machine's real + * radio through the bundled {@code cn1-ble-helper} process and throws + * {@code IllegalStateException} when no helper binary could be located + * (see the cn1.bluetooth.helperPath system property). + * Unknown names + * throw {@code IllegalArgumentException}. + */ + public synchronized void switchBackend(String name) { + if (BACKEND_SIMULATOR.equals(name)) { + installBackend(new SimulatorBleBackend( + BluetoothSimulator.getStack())); + return; + } + if (BACKEND_NATIVE.equals(name)) { + NativeBleBackend nativeBackend = new NativeBleBackend(); + if (!nativeBackend.isAvailable()) { + throw new IllegalStateException( + "The native Bluetooth backend is not available: no " + + "cn1-ble-helper binary was found. Tried: " + + nativeBackend.describeResolution()); + } + installBackend(nativeBackend); + return; + } + throw new IllegalArgumentException("Unknown Bluetooth backend: " + + name); + } + + private void installBackend(BleBackend newBackend) { + BleBackend old = backend; + if (old != null) { + old.setAdapterStateSink(null); + old.shutdown(); + } + backend = newBackend; + newBackend.setAdapterStateSink(new BleBackend.AdapterStateSink() { + public void adapterStateChanged(AdapterState newState) { + fireAdapterStateChanged(newState); + } + }); + } + + /** The name of the active backend, e.g. {@code "simulator"}. */ + public String activeBackendName() { + return backend.getName(); + } + + public boolean isSupported() { + return true; + } + + public boolean isLeSupported() { + return backend.isLeSupported(); + } + + public boolean isClassicSupported() { + // classic Bluetooth is simulator-only; the native seam is BLE + return BACKEND_SIMULATOR.equals(activeBackendName()) + && backend.isClassicSupported(); + } + + public boolean isPeripheralModeSupported() { + return backend.isPeripheralModeSupported(); + } + + public boolean isL2capSupported() { + return backend.isL2capSupported(); + } + + public AdapterState getAdapterState() { + return backend.getAdapterState(); + } + + public AsyncResource requestEnable() { + final AsyncResource out = new AsyncResource(); + BleBackend b = backend; + if (!(b instanceof SimulatorBleBackend)) { + // no programmatic enable flow on the native backend + out.complete(Boolean.FALSE); + return out; + } + SimulatedBluetoothStack stack = ((SimulatorBleBackend) b).getStack(); + stack.setAdapterEnabled(true); + // resolve once the enable request went through the stack scheduler + stack.logEvent("adapter", "requestEnable granted"); + stack.getScheduler().post(new Runnable() { + public void run() { + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + } + }); + return out; + } + + public boolean hasPermission(BluetoothPermission permission) { + return simGrantPermissions; + } + + public AsyncResource requestPermissions( + BluetoothPermission... permissions) { + AsyncResource out = new AsyncResource(); + out.complete(Boolean.valueOf(simGrantPermissions)); + return out; + } + + public com.codename1.bluetooth.le.BluetoothLE getLE() { + return le; + } + + public com.codename1.bluetooth.classic.BluetoothClassic getClassic() { + return classic; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetoothClassic.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetoothClassic.java new file mode 100644 index 00000000000..3e663fefe67 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetoothClassic.java @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothDevice; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.BondState; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.classic.BluetoothClassic; +import com.codename1.bluetooth.classic.ClassicDiscovery; +import com.codename1.bluetooth.classic.ClassicDiscoveryListener; +import com.codename1.bluetooth.classic.ClassicScanResult; +import com.codename1.bluetooth.classic.RfcommConnection; +import com.codename1.bluetooth.classic.RfcommServer; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/** + * Simulator classic-Bluetooth role: discovery emits the classic-flagged + * {@link VirtualPeripheral}s, RFCOMM connections and listeners ride the + * virtual stack's piped-stream endpoints, and the bonded-device listing + * mirrors the stack's bond registry. Classic Bluetooth is simulator-only: + * when the owner runs the native backend every operation reports + * unsupported. + */ +class JavaSEBluetoothClassic extends BluetoothClassic { + + private final JavaSEBluetooth owner; + + JavaSEBluetoothClassic(JavaSEBluetooth owner) { + this.owner = owner; + } + + private boolean isSimulatorActive() { + return SimulatorBleBackend.NAME.equals(owner.activeBackendName()); + } + + private SimulatedBluetoothStack stack() { + return BluetoothSimulator.getStack(); + } + + private static BluetoothException notSupported() { + return new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Classic Bluetooth is only available on the simulator " + + "backend"); + } + + /** + * Listener callbacks are documented as EDT-delivered; when the + * simulated stack is exercised headlessly (no initialized Display) + * they run inline on the stack's scheduler thread instead. + */ + private static void dispatch(Runnable r) { + if (Display.isInitialized()) { + Display.getInstance().callSerially(r); + } else { + r.run(); + } + } + + public ClassicDiscovery startDiscovery( + final ClassicDiscoveryListener listener) { + if (!isSimulatorActive()) { + ClassicDiscovery d = new SimClassicDiscovery(null); + d.error(notSupported()); + return d; + } + if (listener == null) { + ClassicDiscovery d = new SimClassicDiscovery(null); + d.error(new BluetoothException(BluetoothError.UNKNOWN, + "startDiscovery requires a listener")); + return d; + } + final SimulatedBluetoothStack stack = stack(); + final SimClassicDiscovery handle = new SimClassicDiscovery(stack); + Object token = stack.startClassicDiscovery( + new SimulatedBluetoothStack.ClassicDiscoverySink() { + public void onSighting(final VirtualPeripheral p) { + final ClassicScanResult result = + new ClassicScanResult( + new SimClassicDevice(stack, + p.getAddress()), + p.getRssi(), 0, 0); + dispatch(new Runnable() { + public void run() { + listener.deviceDiscovered(result); + } + }); + } + + public void onComplete() { + if (!handle.isDone()) { + handle.complete(Boolean.TRUE); + } + } + + public void onFailed(BluetoothError error, + String message) { + if (!handle.isDone()) { + handle.error(new BluetoothException(error, + message)); + } + } + }); + handle.setToken(token); + return handle; + } + + private static final class SimClassicDiscovery extends ClassicDiscovery { + private final SimulatedBluetoothStack stack; + private volatile Object token; + + SimClassicDiscovery(SimulatedBluetoothStack stack) { + this.stack = stack; + } + + void setToken(Object token) { + this.token = token; + } + + protected void onStop() { + Object t = token; + if (stack != null && t != null) { + stack.stopClassicDiscovery(t); + } + } + } + + public List getBondedDevices() { + ArrayList out = new ArrayList(); + if (!isSimulatorActive()) { + return out; + } + SimulatedBluetoothStack stack = stack(); + List addresses = stack.getBondedAddresses(); + int size = addresses.size(); + for (int i = 0; i < size; i++) { + VirtualPeripheral p = stack.getPeripheral(addresses.get(i)); + if (p != null && p.isClassic()) { + out.add(new SimClassicDevice(stack, p.getAddress())); + } + } + return out; + } + + public AsyncResource createBond(BluetoothDevice device) { + final AsyncResource out = new AsyncResource(); + if (!isSimulatorActive()) { + out.error(notSupported()); + return out; + } + if (device == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "createBond requires a device")); + return out; + } + stack().bond(device.getAddress(), booleanCallback(out)); + return out; + } + + public AsyncResource requestDiscoverable(int durationSeconds) { + AsyncResource out = new AsyncResource(); + if (!isSimulatorActive()) { + out.complete(Boolean.FALSE); + return out; + } + stack().logEvent("discoverable", + "requested for " + durationSeconds + "s (granted)"); + out.complete(Boolean.TRUE); + return out; + } + + public AsyncResource connect(BluetoothDevice device, + BluetoothUuid serviceUuid, boolean secure) { + AsyncResource out = + new AsyncResource(); + if (device == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "connect requires a device")); + return out; + } + connectImpl(device, serviceUuid, out); + return out; + } + + public AsyncResource connect(String address, + BluetoothUuid serviceUuid, boolean secure) { + AsyncResource out = + new AsyncResource(); + if (address == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "connect requires an address")); + return out; + } + connectImpl(new SimClassicDevice( + isSimulatorActive() ? stack() : null, address), + serviceUuid, out); + return out; + } + + private void connectImpl(final BluetoothDevice device, + BluetoothUuid serviceUuid, + final AsyncResource out) { + if (!isSimulatorActive()) { + out.error(notSupported()); + return; + } + BluetoothUuid uuid = serviceUuid == null + ? BluetoothUuid.SPP : serviceUuid; + stack().connectRfcomm(uuid, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(SimStreamChannel value) { + if (!out.isDone()) { + out.complete(new JavaSERfcommConnection(device, + value)); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + } + + public AsyncResource listen(String serviceName, + BluetoothUuid serviceUuid, boolean secure) { + final AsyncResource out = + new AsyncResource(); + if (!isSimulatorActive()) { + out.error(notSupported()); + return out; + } + final SimulatedBluetoothStack stack = stack(); + final BluetoothUuid uuid = serviceUuid == null + ? BluetoothUuid.SPP : serviceUuid; + stack.listenRfcomm(uuid, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + if (!out.isDone()) { + out.complete(new JavaSERfcommServer(uuid, stack)); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + return out; + } + + private SimulatedBluetoothStack.Callback booleanCallback( + final AsyncResource out) { + return new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + if (!out.isDone()) { + out.complete(value); + } + } + + public void onError(BluetoothError error, String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }; + } + + /** A classic device backed by the stack's peripheral registry. */ + private static final class SimClassicDevice extends BluetoothDevice { + private final SimulatedBluetoothStack stack; + private final String address; + + SimClassicDevice(SimulatedBluetoothStack stack, String address) { + this.stack = stack; + this.address = address; + } + + public String getAddress() { + return address; + } + + public String getName() { + VirtualPeripheral p = stack == null + ? null : stack.getPeripheral(address); + return p == null ? null : p.getName(); + } + + public DeviceType getType() { + VirtualPeripheral p = stack == null + ? null : stack.getPeripheral(address); + if (p == null) { + return DeviceType.UNKNOWN; + } + if (p.isClassic()) { + return p.isLe() ? DeviceType.DUAL : DeviceType.CLASSIC; + } + return DeviceType.LE; + } + + public BondState getBondState() { + return stack != null && stack.isBonded(address) + ? BondState.BONDED : BondState.NONE; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetoothLE.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetoothLE.java new file mode 100644 index 00000000000..5f4b0b8082c --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSEBluetoothLE.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.helper.BleBackend; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.util.AsyncResource; + +import java.util.List; + +/** + * The one stable {@link BluetoothLE} instance of the JavaSE port. Every + * hook routes to the owner's current {@link BleBackend}, so + * switching between the simulator and the future native backend never + * invalidates references application code holds. + */ +class JavaSEBluetoothLE extends BluetoothLE { + + private final JavaSEBluetooth owner; + + JavaSEBluetoothLE(JavaSEBluetooth owner) { + this.owner = owner; + } + + protected boolean isScanSupported() { + return owner.backend().isLeSupported(); + } + + protected void startPlatformScan() { + owner.backend().startScan(new BleBackend.ScanSink() { + public void onResult(ScanResult result) { + fireScanResult(result); + } + + public void onFailed(BluetoothException reason) { + fireScanFailed(reason); + } + }); + } + + protected void stopPlatformScan() { + owner.backend().stopScan(); + } + + public BlePeripheral getPeripheral(String address) { + return owner.backend().getPeripheral(address); + } + + public List getConnectedPeripherals( + BluetoothUuid serviceFilter) { + return owner.backend().getConnectedPeripherals(serviceFilter); + } + + public List getBondedPeripherals() { + return owner.backend().getBondedPeripherals(); + } + + public AsyncResource openGattServer( + GattServerListener listener) { + return owner.backend().openGattServer(listener); + } + + public AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse) { + return owner.backend().startAdvertising(settings, data, scanResponse); + } + + public AsyncResource openL2capServer(boolean secure) { + return owner.backend().openL2capServer(secure); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSERfcommConnection.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSERfcommConnection.java new file mode 100644 index 00000000000..fd8976e6b92 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSERfcommConnection.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothDevice; +import com.codename1.bluetooth.classic.RfcommConnection; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Simulator {@link RfcommConnection} backed by a {@link SimStreamChannel} + * piped pair from the virtual stack. + */ +class JavaSERfcommConnection extends RfcommConnection { + + private final SimStreamChannel channel; + + JavaSERfcommConnection(BluetoothDevice device, SimStreamChannel channel) { + super(device); + this.channel = channel; + } + + public InputStream getInputStream() throws IOException { + return channel.getInputStream(); + } + + public OutputStream getOutputStream() throws IOException { + return channel.getOutputStream(); + } + + public void close() throws IOException { + channel.close(); + } + + public boolean isOpen() { + return channel.isOpen(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSERfcommServer.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSERfcommServer.java new file mode 100644 index 00000000000..1dcd36b0226 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/JavaSERfcommServer.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothDevice; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.classic.RfcommConnection; +import com.codename1.bluetooth.classic.RfcommServer; +import com.codename1.util.AsyncResource; + +/** + * Simulator {@link RfcommServer} over the virtual stack's RFCOMM listener + * registry; virtual clients arrive via + * {@link SimulatedBluetoothStack#connectVirtualRfcommClient(BluetoothUuid)}. + */ +class JavaSERfcommServer extends RfcommServer { + + private final SimulatedBluetoothStack stack; + + JavaSERfcommServer(BluetoothUuid serviceUuid, + SimulatedBluetoothStack stack) { + super(serviceUuid); + this.stack = stack; + } + + public AsyncResource accept() { + final AsyncResource out = + new AsyncResource(); + stack.acceptRfcomm(getServiceUuid(), + new SimulatedBluetoothStack.Callback() { + public void onSuccess(SimStreamChannel value) { + if (!out.isDone()) { + out.complete(new JavaSERfcommConnection( + new VirtualClientDevice(), value)); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + return out; + } + + public void close() { + stack.closeRfcommServer(getServiceUuid()); + } + + /** The synthetic remote device of an accepted virtual client. */ + private static final class VirtualClientDevice extends BluetoothDevice { + public String getAddress() { + return "F0:00:00:00:00:FE"; + } + + public String getName() { + return "Virtual RFCOMM Client"; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ManualScheduler.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ManualScheduler.java new file mode 100644 index 00000000000..b71c2ee76c0 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ManualScheduler.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import java.util.PriorityQueue; + +/** + * A fully deterministic {@link SimScheduler} driven by an explicit virtual + * clock. Nothing runs until the owner pumps it: + * + *
    + *
  • {@link #pump()} runs the single next task that is due at the + * current virtual time.
  • + *
  • {@link #pumpAll()} runs due tasks until none remain due (delayed + * tasks stay queued).
  • + *
  • {@link #advance(long)} moves the virtual clock forward, releasing + * and running delayed tasks in order (due time first, submission + * order for ties) -- including tasks scheduled by the tasks it runs, + * as long as they fall inside the advanced window.
  • + *
+ * + *

The class never reads real time; the clock only moves via + * {@link #advance(long)}.

+ */ +public final class ManualScheduler implements SimScheduler { + + /** + * Safety valve against runaway task loops wedging a test forever: a + * single drain (pumpAll/advance) refuses to run more than this many + * tasks and fails loudly instead. + */ + private static final int MAX_TASKS_PER_DRAIN = 100000; + + private static final class ScheduledTask + implements Comparable { + final long due; + final long seq; + final Runnable task; + + ScheduledTask(long due, long seq, Runnable task) { + this.due = due; + this.seq = seq; + this.task = task; + } + + public int compareTo(ScheduledTask o) { + if (due != o.due) { + return due < o.due ? -1 : 1; + } + if (seq != o.seq) { + return seq < o.seq ? -1 : 1; + } + return 0; + } + } + + private final PriorityQueue queue = + new PriorityQueue(); + private long clock; + private long seq; + private boolean shutdown; + + public void post(Runnable task) { + postDelayed(task, 0); + } + + public synchronized void postDelayed(Runnable task, long millis) { + if (task == null || shutdown) { + return; + } + queue.add(new ScheduledTask(clock + Math.max(0, millis), seq++, task)); + } + + public synchronized void shutdown() { + shutdown = true; + queue.clear(); + } + + /** + * The current virtual time in milliseconds; starts at {@code 0}. + */ + public synchronized long getVirtualTimeMillis() { + return clock; + } + + /** + * Runs the single next task due at the current virtual time. + * + * @return {@code true} when a task ran, {@code false} when nothing is + * due + */ + public boolean pump() { + Runnable next; + synchronized (this) { + ScheduledTask t = queue.peek(); + if (t == null || t.due > clock) { + return false; + } + queue.poll(); + next = t.task; + } + next.run(); + return true; + } + + /** + * Runs due tasks (including tasks they post without delay) until the + * queue is quiet at the current virtual time. + * + * @return the number of tasks that ran + */ + public int pumpAll() { + int n = 0; + while (pump()) { + n++; + if (n > MAX_TASKS_PER_DRAIN) { + throw new IllegalStateException( + "ManualScheduler did not go quiet after " + + MAX_TASKS_PER_DRAIN + " tasks"); + } + } + return n; + } + + /** + * Moves the virtual clock forward by the given number of milliseconds, + * running every task that becomes due along the way in deterministic + * order (due time, then submission order). Tasks scheduled while + * advancing also run if they land inside the window. The clock ends + * exactly {@code millis} later. + */ + public void advance(long millis) { + long target; + synchronized (this) { + target = clock + Math.max(0, millis); + } + int n = 0; + while (true) { + Runnable next; + synchronized (this) { + ScheduledTask t = queue.peek(); + if (t == null || t.due > target) { + clock = target; + return; + } + queue.poll(); + if (t.due > clock) { + clock = t.due; + } + next = t.task; + } + next.run(); + n++; + if (n > MAX_TASKS_PER_DRAIN) { + throw new IllegalStateException( + "ManualScheduler did not go quiet after " + + MAX_TASKS_PER_DRAIN + " tasks"); + } + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/NativeBleBackend.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/NativeBleBackend.java new file mode 100644 index 00000000000..261f6be084d --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/NativeBleBackend.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.helper.HelperBleBackend; + +import java.util.List; + +/** + * The JavaSE simulator's binding of the shared, transport-agnostic + * {@link HelperBleBackend} onto a real {@code cn1-ble-helper} subprocess. + * All the protocol, GATT and lifecycle logic lives in the + * {@code codenameone-native-ble} module; this thin class only supplies the + * {@link ProcessTransportFactory} (the one place {@link ProcessBuilder} + * lives on JavaSE) and re-exposes the host-specific binary-resolution + * queries the port needs. {@code JavaSEBluetooth} constructs it unchanged + * via {@code new NativeBleBackend()} / {@code switchBackend("native")}. + */ +class NativeBleBackend extends HelperBleBackend { + + private final ProcessTransportFactory transportFactory; + + /** Resolves the helper binary for this host; check {@link #isAvailable()}. */ + NativeBleBackend() { + this(new ProcessTransportFactory()); + } + + /** + * Unit-test seam: runs the given command line (a fake helper speaking + * the wire protocol) instead of resolving a real binary. + */ + NativeBleBackend(List launchCommand) { + this(new ProcessTransportFactory(launchCommand)); + } + + private NativeBleBackend(ProcessTransportFactory transportFactory) { + super(transportFactory); + this.transportFactory = transportFactory; + } + + /** True when a helper binary was located for this host. */ + boolean isAvailable() { + return transportFactory.isAvailable(); + } + + /** Human-readable trace of the binary locations that were tried. */ + String describeResolution() { + return transportFactory.describeResolution(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ProcessTransport.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ProcessTransport.java new file mode 100644 index 00000000000..5ef89fd1f31 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ProcessTransport.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.helper.HelperTransport; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.List; + +/** + * The JavaSE simulator's {@link HelperTransport}: the one place the real + * {@code cn1-ble-helper} child process is launched, via + * {@link ProcessBuilder}. It owns the child process's stdin/stdout streams, + * pumps its stderr to the console for diagnostics and registers a JVM + * shutdown hook so the process is always torn down. The native + * Windows/Linux ports supply their own {@code HelperTransport} over a + * native subprocess bridge instead; the transport-agnostic + * {@link com.codename1.bluetooth.helper.HelperBleBackend} is shared. + */ +class ProcessTransport implements HelperTransport { + + private final List configuredCommand; + + private final Object lock = new Object(); + private Process process; + private Writer stdin; + private BufferedReader stdout; + private Thread shutdownHook; + + /** + * @param configuredCommand the launch command line this transport runs + * when {@link #start(List)} is passed + * {@code null} (the resolved helper binary, or + * an explicit test command) + */ + ProcessTransport(List configuredCommand) { + this.configuredCommand = configuredCommand; + } + + public void start(List command) throws IOException { + List launch = command != null ? command : configuredCommand; + if (launch == null || launch.isEmpty()) { + throw new IOException("no cn1-ble-helper launch command"); + } + synchronized (lock) { + Process p = new ProcessBuilder(launch).start(); + process = p; + stdin = new BufferedWriter(new OutputStreamWriter( + p.getOutputStream(), "UTF-8")); + stdout = new BufferedReader(new InputStreamReader( + p.getInputStream(), "UTF-8")); + startStderrPump(p); + final Process hooked = p; + shutdownHook = new Thread("cn1ble-helper-shutdown") { + public void run() { + destroyQuietly(hooked); + } + }; + Runtime.getRuntime().addShutdownHook(shutdownHook); + } + } + + public String readLine() throws IOException { + BufferedReader r; + synchronized (lock) { + r = stdout; + } + if (r == null) { + return null; + } + return r.readLine(); + } + + public void writeLine(String line) throws IOException { + synchronized (lock) { + if (stdin == null) { + throw new IOException("helper stdin closed"); + } + stdin.write(line); + stdin.write('\n'); + stdin.flush(); + } + } + + public boolean isAlive() { + synchronized (lock) { + return process != null && process.isAlive(); + } + } + + public void close() { + Process p; + Thread hook; + Writer in; + synchronized (lock) { + p = process; + hook = shutdownHook; + in = stdin; + stdin = null; + stdout = null; + process = null; + shutdownHook = null; + } + if (hook != null) { + try { + Runtime.getRuntime().removeShutdownHook(hook); + } catch (IllegalStateException ignored) { + // VM already exiting + } + } + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + } + } + if (p != null) { + try { + // grace period for the helper to honor the shutdown command + long deadline = System.currentTimeMillis() + 2000; + while (p.isAlive() + && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + if (p.isAlive()) { + p.destroy(); + } + } + } + + private static void destroyQuietly(Process p) { + if (p != null && p.isAlive()) { + p.destroy(); + } + } + + private void startStderrPump(final Process p) { + Thread t = new Thread("cn1ble-helper-stderr") { + public void run() { + try { + BufferedReader r = new BufferedReader( + new InputStreamReader(p.getErrorStream(), + "UTF-8")); + String line; + while ((line = r.readLine()) != null) { + System.err.println("[Cn1BleHelper] " + line); + } + } catch (IOException ignored) { + } + } + }; + t.setDaemon(true); + t.start(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ProcessTransportFactory.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ProcessTransportFactory.java new file mode 100644 index 00000000000..e1206a74822 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ProcessTransportFactory.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.helper.HelperTransport; +import com.codename1.bluetooth.helper.HelperTransportFactory; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Builds {@link ProcessTransport}s for the JavaSE simulator's native + * Bluetooth backend. Resolves the bundled {@code cn1-ble-helper} binary for + * this host through the shared {@link HelperBinaryResolver} (so the + * simulator and the native ports resolve identically), and exposes + * {@link #isAvailable()} / {@link #describeResolution()} so the port can + * refuse the switch with a helpful trace when no helper is found. + */ +class ProcessTransportFactory implements HelperTransportFactory { + + /** The resolved launch command, or {@code null} when unavailable. */ + private final List command; + private final String description; + + /** Resolves the helper binary for this host. */ + ProcessTransportFactory() { + List attempted = new ArrayList(); + File helperBinary = HelperBinaryResolver.resolveHelperBinary( + System.getProperty(HelperBinaryResolver.HELPER_PATH_PROPERTY), + System.getProperty("os.name", ""), + System.getProperty("os.arch", ""), + System.getenv("PATH"), attempted); + this.command = helperBinary != null + ? Collections.singletonList(helperBinary.getAbsolutePath()) + : null; + this.description = HelperBinaryResolver.join(attempted); + } + + /** + * Unit-test seam: runs the given command line (a fake helper speaking + * the wire protocol) instead of resolving a real binary. + */ + ProcessTransportFactory(List launchCommand) { + this.command = launchCommand; + this.description = "explicit launch command (test)"; + } + + public HelperTransport create() { + return new ProcessTransport(command); + } + + /** True when a helper binary (or explicit command) was located. */ + boolean isAvailable() { + return command != null; + } + + /** Human-readable trace of the binary locations that were tried. */ + String describeResolution() { + return description; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimBleAdvertisement.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimBleAdvertisement.java new file mode 100644 index 00000000000..5c067c4e41b --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimBleAdvertisement.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.le.server.BleAdvertisement; + +/** + * Simulator {@link BleAdvertisement} handle over the virtual stack's + * advertising registry. + */ +class SimBleAdvertisement extends BleAdvertisement { + + private final SimulatedBluetoothStack stack; + private final Object token; + + SimBleAdvertisement(SimulatedBluetoothStack stack, Object token) { + this.stack = stack; + this.token = token; + } + + public void stop() { + stack.stopAppAdvertising(token); + } + + public boolean isActive() { + return stack.isAppAdvertising(token); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimBlePeripheral.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimBlePeripheral.java new file mode 100644 index 00000000000..c5b18561daa --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimBlePeripheral.java @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.BondState; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionOptions; +import com.codename1.bluetooth.le.ConnectionPriority; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Simulator {@link BlePeripheral} bridging the core GATT client contract + * onto {@link SimulatedBluetoothStack} operations. One canonical instance + * exists per address (cached by {@link SimulatorBleBackend}), so + * discovered {@link GattCharacteristic} objects stay identical across the + * scan/connect/notify lifecycle. + */ +class SimBlePeripheral extends BlePeripheral { + + private final SimulatedBluetoothStack stack; + private final String address; + private final Object dbLock = new Object(); + /** service|characteristic uuid -> canonical discovered instance */ + private HashMap characteristicIndex = + new HashMap(); + + SimBlePeripheral(SimulatedBluetoothStack stack, String address) { + this.stack = stack; + this.address = address; + stack.setPeripheralSink(address, + new SimulatedBluetoothStack.PeripheralSink() { + public void onNotification(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, byte[] value) { + GattCharacteristic c = canonicalCharacteristic( + serviceUuid, characteristicUuid); + if (c != null) { + fireNotification(c, value); + } + } + + public void onConnectionLost(BluetoothError error, + String message) { + fireConnectionStateChanged( + ConnectionState.DISCONNECTED, + new BluetoothException(error, message)); + } + }); + } + + public String getAddress() { + return address; + } + + public String getName() { + VirtualPeripheral p = stack.getPeripheral(address); + return p == null ? null : p.getName(); + } + + public DeviceType getType() { + VirtualPeripheral p = stack.getPeripheral(address); + if (p == null) { + return DeviceType.UNKNOWN; + } + if (p.isClassic()) { + return p.isLe() ? DeviceType.DUAL : DeviceType.CLASSIC; + } + return DeviceType.LE; + } + + public BondState getBondState() { + return stack.isBonded(address) ? BondState.BONDED : BondState.NONE; + } + + private GattCharacteristic canonicalCharacteristic( + BluetoothUuid serviceUuid, BluetoothUuid characteristicUuid) { + synchronized (dbLock) { + return characteristicIndex.get( + serviceUuid + "|" + characteristicUuid); + } + } + + // ------------------------------------------------------------------ + // BlePeripheral SPI + // ------------------------------------------------------------------ + + protected void doConnect(ConnectionOptions options, + final AsyncResource out) { + stack.connect(address, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + if (!out.isDone()) { + out.complete(SimBlePeripheral.this); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + } + + protected void doDisconnect() { + stack.disconnect(address, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + fireConnectionStateChanged( + ConnectionState.DISCONNECTED, null); + } + + public void onError(BluetoothError error, + String message) { + // the link is gone either way + fireConnectionStateChanged( + ConnectionState.DISCONNECTED, null); + } + }); + } + + protected void doDiscoverServices( + final AsyncResource> out) { + stack.discoverServices(address, + new SimulatedBluetoothStack.Callback>() { + public void onSuccess(List value) { + List services = buildGattModel(value); + if (!out.isDone()) { + out.complete(services); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + } + + /** Maps the virtual database to canonical core model instances. */ + private List buildGattModel(List virtual) { + ArrayList services = new ArrayList(); + HashMap index = + new HashMap(); + int size = virtual == null ? 0 : virtual.size(); + for (int i = 0; i < size; i++) { + VirtualService vs = virtual.get(i); + GattService s = new GattService(this, vs.getUuid(), + vs.isPrimary(), i); + List chars = vs.getCharacteristics(); + int cs = chars.size(); + for (int j = 0; j < cs; j++) { + VirtualCharacteristic vc = chars.get(j); + GattCharacteristic c = new GattCharacteristic(s, + vc.getUuid(), vc.getProperties(), j); + List descs = vc.getDescriptors(); + int ds = descs.size(); + for (int k = 0; k < ds; k++) { + c.addDescriptor(new GattDescriptor(c, + descs.get(k).getUuid())); + } + s.addCharacteristic(c); + index.put(vs.getUuid() + "|" + vc.getUuid(), c); + } + services.add(s); + } + synchronized (dbLock) { + characteristicIndex = index; + } + return services; + } + + protected void doReadCharacteristic(GattCharacteristic c, + final AsyncResource out) { + stack.readCharacteristic(address, c.getService().getUuid(), + c.getUuid(), bytesCallback(out)); + } + + protected void doWriteCharacteristic(GattCharacteristic c, byte[] value, + boolean withResponse, final AsyncResource out) { + stack.writeCharacteristic(address, c.getService().getUuid(), + c.getUuid(), value, booleanCallback(out)); + } + + protected void doReadDescriptor(GattDescriptor d, + final AsyncResource out) { + GattCharacteristic c = d.getCharacteristic(); + stack.readDescriptor(address, c.getService().getUuid(), c.getUuid(), + d.getUuid(), bytesCallback(out)); + } + + protected void doWriteDescriptor(GattDescriptor d, byte[] value, + final AsyncResource out) { + GattCharacteristic c = d.getCharacteristic(); + stack.writeDescriptor(address, c.getService().getUuid(), c.getUuid(), + d.getUuid(), value, booleanCallback(out)); + } + + protected void doSetNotifications(GattCharacteristic c, boolean enable, + boolean indication, final AsyncResource out) { + stack.setNotifications(address, c.getService().getUuid(), c.getUuid(), + enable, booleanCallback(out)); + } + + protected void doReadRssi(final AsyncResource out) { + stack.readRssi(address, integerCallback(out)); + } + + protected void doRequestMtu(int mtu, final AsyncResource out) { + stack.requestMtu(address, mtu, integerCallback(out)); + } + + protected void doRequestConnectionPriority(ConnectionPriority priority, + AsyncResource out) { + // interval preferences are a no-op in the simulator + stack.logEvent("connectionPriority", address + " " + priority); + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + } + + protected void doCreateBond(final AsyncResource out) { + stack.bond(address, booleanCallback(out)); + } + + protected void doOpenL2cap(final int psm, boolean secure, + final AsyncResource out) { + stack.openL2capChannel(address, psm, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(SimStreamChannel value) { + if (!out.isDone()) { + out.complete(new SimL2capChannel(psm, value)); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + } + + // ------------------------------------------------------------------ + // callback bridges + // ------------------------------------------------------------------ + + private SimulatedBluetoothStack.Callback bytesCallback( + final AsyncResource out) { + return new SimulatedBluetoothStack.Callback() { + public void onSuccess(byte[] value) { + if (!out.isDone()) { + out.complete(value); + } + } + + public void onError(BluetoothError error, String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }; + } + + private SimulatedBluetoothStack.Callback booleanCallback( + final AsyncResource out) { + return new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + if (!out.isDone()) { + out.complete(value); + } + } + + public void onError(BluetoothError error, String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }; + } + + private SimulatedBluetoothStack.Callback integerCallback( + final AsyncResource out) { + return new SimulatedBluetoothStack.Callback() { + public void onSuccess(Integer value) { + if (!out.isDone()) { + out.complete(value); + } + } + + public void onError(BluetoothError error, String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimGattServer.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimGattServer.java new file mode 100644 index 00000000000..ec625d5d318 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimGattServer.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.gatt.GattStatus; +import com.codename1.bluetooth.le.server.BleCentral; +import com.codename1.bluetooth.le.server.GattLocalCharacteristic; +import com.codename1.bluetooth.le.server.GattLocalService; +import com.codename1.bluetooth.le.server.GattReadRequest; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.bluetooth.le.server.GattWriteRequest; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Simulator {@link GattServer} over the virtual stack's app-side GATT + * registry. Virtual centrals created via + * {@link SimulatedBluetoothStack#connectVirtualCentral()} appear here as + * {@link BleCentral}s and their requests flow through the core + * {@code fire*} helpers to the application listener. + */ +class SimGattServer extends GattServer { + + private final SimulatedBluetoothStack stack; + private final HashMap centrals = + new HashMap(); + + private static final class SimBleCentral extends BleCentral { + private final String address; + + SimBleCentral(String address) { + this.address = address; + } + + public String getAddress() { + return address; + } + } + + SimGattServer(GattServerListener listener, + SimulatedBluetoothStack stack) { + super(listener); + this.stack = stack; + } + + /** Opens the stack-side server and resolves {@code out} with this. */ + void open(final AsyncResource out) { + stack.openAppGattServer(new SimulatedBluetoothStack.AppServerSink() { + public void centralConnected(String centralAddress) { + fireCentralConnected(central(centralAddress)); + } + + public void centralDisconnected(String centralAddress) { + SimBleCentral central; + synchronized (centrals) { + central = centrals.remove(centralAddress); + } + if (central != null) { + fireCentralDisconnected(central); + } + } + + public void subscriptionChanged(String centralAddress, + GattLocalCharacteristic characteristic, + boolean subscribed) { + fireSubscriptionChanged(central(centralAddress), + characteristic, subscribed); + } + + public void characteristicReadRequest( + final SimulatedBluetoothStack.AppReadRequest request) { + fireCharacteristicReadRequest(new GattReadRequest( + central(request.getCentralAddress()), + request.getCharacteristic(), null, 0) { + public void respond(byte[] value) { + request.respond(value); + } + + public void reject(GattStatus status) { + request.reject(status); + } + }); + } + + public void characteristicWriteRequest( + final SimulatedBluetoothStack.AppWriteRequest request) { + fireCharacteristicWriteRequest(new GattWriteRequest( + central(request.getCentralAddress()), + request.getCharacteristic(), null, + request.getValue(), 0, true) { + public void respond() { + request.respond(); + } + + public void reject(GattStatus status) { + request.reject(status); + } + }); + } + }, new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + if (!out.isDone()) { + out.complete(SimGattServer.this); + } + } + + public void onError(BluetoothError error, String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + } + + private SimBleCentral central(String address) { + synchronized (centrals) { + SimBleCentral central = centrals.get(address); + if (central == null) { + central = new SimBleCentral(address); + centrals.put(address, central); + } + return central; + } + } + + public void removeService(GattLocalService service) { + stack.removeAppService(service); + } + + public void close() { + stack.closeAppGattServer(); + } + + public List getConnectedCentrals() { + ArrayList out = new ArrayList(); + List addresses = stack.getConnectedCentralAddresses(); + int size = addresses.size(); + for (int i = 0; i < size; i++) { + out.add(central(addresses.get(i))); + } + return out; + } + + protected void doAddService(GattLocalService service, + final AsyncResource out) { + stack.addAppService(service, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + if (!out.isDone()) { + out.complete(value); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + } + + protected void doNotify(BleCentral central, + GattLocalCharacteristic characteristic, byte[] value, + boolean confirm, final AsyncResource out) { + stack.notifyAppValue(characteristic, value, + central == null ? null : central.getAddress(), + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + if (!out.isDone()) { + out.complete(value); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimL2capChannel.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimL2capChannel.java new file mode 100644 index 00000000000..c764c0b18b3 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimL2capChannel.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.le.L2capChannel; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Simulator {@link L2capChannel} backed by a {@link SimStreamChannel} + * piped pair from the virtual stack. + */ +class SimL2capChannel extends L2capChannel { + + private final SimStreamChannel channel; + + SimL2capChannel(int psm, SimStreamChannel channel) { + super(psm); + this.channel = channel; + } + + public InputStream getInputStream() throws IOException { + return channel.getInputStream(); + } + + public OutputStream getOutputStream() throws IOException { + return channel.getOutputStream(); + } + + public void close() throws IOException { + channel.close(); + } + + public boolean isOpen() { + return channel.isOpen(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimL2capServer.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimL2capServer.java new file mode 100644 index 00000000000..cf5eb8c353d --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimL2capServer.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.util.AsyncResource; + +/** + * Simulator {@link L2capServer} over the virtual stack's published-PSM + * registry; virtual clients arrive via + * {@link SimulatedBluetoothStack#connectVirtualL2capClient(int)}. + */ +class SimL2capServer extends L2capServer { + + private final SimulatedBluetoothStack stack; + private final int psm; + + SimL2capServer(SimulatedBluetoothStack stack, int psm) { + this.stack = stack; + this.psm = psm; + } + + public int getPsm() { + return psm; + } + + public AsyncResource accept() { + final AsyncResource out = + new AsyncResource(); + stack.acceptAppL2cap(psm, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(SimStreamChannel value) { + if (!out.isDone()) { + out.complete(new SimL2capChannel(psm, value)); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + return out; + } + + public void close() { + stack.closeAppL2capServer(psm); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimScheduler.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimScheduler.java new file mode 100644 index 00000000000..4e461b4d6b9 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimScheduler.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +/** + * The single execution lane of the simulated Bluetooth stack. Every state + * mutation and every callback of {@link SimulatedBluetoothStack} runs + * through one scheduler, which makes the whole simulation single-threaded + * and deterministic. + * + *

Two implementations exist: {@link AutoScheduler} (a real daemon + * thread, used by the running simulator) and {@link ManualScheduler} + * (a virtual clock pumped explicitly, used by unit tests and anything + * else that needs full determinism).

+ */ +public interface SimScheduler { + + /** + * Enqueues a task to run as soon as possible, after every task already + * queued. Safe to call from any thread, including from within a task. + */ + void post(Runnable task); + + /** + * Enqueues a task to run after the given delay in (possibly virtual) + * milliseconds. Tasks with equal due times run in submission order. + */ + void postDelayed(Runnable task, long millis); + + /** + * Discards pending tasks and stops accepting new ones. Idempotent. + */ + void shutdown(); +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimStreamChannel.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimStreamChannel.java new file mode 100644 index 00000000000..d9459e4c522 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimStreamChannel.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; + +/** + * One endpoint of a bidirectional in-memory byte channel built from + * {@code java.io} piped streams -- the simulated stack's stand-in for an + * RFCOMM connection or an L2CAP channel. Channels come in cross-connected + * pairs from {@link #createPair()}: what one side writes the other side + * reads. Closing either side propagates EOF to the peer's input stream. + * + *

The streams block, exactly like the real transports -- consume them + * off the EDT.

+ */ +public final class SimStreamChannel { + + /** + * Generous pipe buffer so tests exchanging a few KiB never deadlock on + * a full pipe. + */ + private static final int PIPE_BUFFER = 1 << 16; + + private final PipedInputStream in; + private final PipedOutputStream out; + private volatile boolean open = true; + + private SimStreamChannel(PipedInputStream in, PipedOutputStream out) { + this.in = in; + this.out = out; + } + + /** + * Creates a cross-connected channel pair: bytes written to + * {@code pair[0]} are read from {@code pair[1]} and vice versa. + */ + public static SimStreamChannel[] createPair() { + try { + PipedInputStream aIn = new PipedInputStream(PIPE_BUFFER); + PipedOutputStream bOut = new PipedOutputStream(aIn); + PipedInputStream bIn = new PipedInputStream(PIPE_BUFFER); + PipedOutputStream aOut = new PipedOutputStream(bIn); + return new SimStreamChannel[] { + new SimStreamChannel(aIn, aOut), + new SimStreamChannel(bIn, bOut) + }; + } catch (IOException ex) { + // piped stream construction cannot fail in practice + throw new IllegalStateException("Failed to create pipe pair: " + + ex, ex); + } + } + + /** The blocking input stream of this side of the channel. */ + public InputStream getInputStream() { + return in; + } + + /** The blocking output stream of this side of the channel. */ + public OutputStream getOutputStream() { + return out; + } + + /** {@code true} until {@link #close()} was called on this side. */ + public boolean isOpen() { + return open; + } + + /** + * Closes both streams of this side; the peer's reads observe EOF. + * Never throws; idempotent. + */ + public void close() { + open = false; + try { + out.close(); + } catch (IOException ignored) { + } + try { + in.close(); + } catch (IOException ignored) { + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimStreamHandler.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimStreamHandler.java new file mode 100644 index 00000000000..f979af77e3b --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimStreamHandler.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +/** + * The "remote device side" of a virtual stream endpoint (RFCOMM service or + * L2CAP PSM) registered with the simulated stack. When the app connects to + * the endpoint, the handler receives the remote half of the freshly piped + * channel. + * + *

The callback is invoked on the stack's scheduler thread; the streams + * block, so a handler that does real I/O must hand the channel off to its + * own thread.

+ */ +public interface SimStreamHandler { + + /** A new incoming connection; {@code channel} is the remote side. */ + void onConnection(SimStreamChannel channel); +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatedBluetoothStack.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatedBluetoothStack.java new file mode 100644 index 00000000000..a439b5c6adf --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatedBluetoothStack.java @@ -0,0 +1,2287 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattStatus; +import com.codename1.bluetooth.le.server.GattLocalCharacteristic; +import com.codename1.bluetooth.le.server.GattLocalService; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * The engine of the JavaSE simulator's virtual Bluetooth world. Pure Java: + * no UI, no Swing, no Codename One {@code Display} -- remote devices are + * {@link VirtualPeripheral} models, transports are piped streams and time + * exists only through the single {@link SimScheduler} the stack was + * constructed with. Every state mutation and every callback runs on that + * scheduler, which makes the whole simulation deterministic under a + * {@link ManualScheduler} and thread-confined under an + * {@link AutoScheduler}. + * + *

Asynchronous completions are delayed by a configurable latency + * (default {@value #DEFAULT_LATENCY_MILLIS}ms) so races that would only + * show on a real radio also show in the simulator. Failures are scripted + * per operation key via + * {@link #failNext(String, BluetoothError, String)}.

+ * + *

The stack simulates both directions:

+ *
    + *
  • App as central/client -- scanning, connect/disconnect, + * GATT client operations, L2CAP channels and RFCOMM connections + * against registered {@link VirtualPeripheral}s.
  • + *
  • App as peripheral/server -- a GATT server registry with + * {@link VirtualCentral} counterparts, advertising state, published + * L2CAP PSMs and RFCOMM listeners with virtual clients.
  • + *
+ * + *

Application code normally goes through {@link BluetoothSimulator}, + * a thin static facade over one shared instance; tests construct their own + * stack with a {@link ManualScheduler}.

+ */ +public final class SimulatedBluetoothStack { + + /** The default artificial completion latency in milliseconds. */ + public static final long DEFAULT_LATENCY_MILLIS = 10; + + // ------------------------------------------------------------------ + // callback interfaces + // ------------------------------------------------------------------ + + /** Plain completion callback of an asynchronous stack operation. */ + public interface Callback { + void onSuccess(T value); + + void onError(BluetoothError error, String message); + } + + /** Receives the sightings of a scan feed. */ + public interface ScanFeedSink { + /** + * One advertisement sighting. + * + * @param timestamp a stack-provided monotonic counter value -- + * NOT wall-clock time + */ + void onSighting(VirtualPeripheral peripheral, long timestamp); + + void onScanFailed(BluetoothError error, String message); + } + + /** Receives the sightings of a classic discovery run. */ + public interface ClassicDiscoverySink { + void onSighting(VirtualPeripheral peripheral); + + /** The inquiry finished after reporting every classic device. */ + void onComplete(); + + void onFailed(BluetoothError error, String message); + } + + /** Remote-initiated events of one connected peripheral. */ + public interface PeripheralSink { + void onNotification(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, byte[] value); + + void onConnectionLost(BluetoothError error, String message); + } + + /** Observes simulated adapter on/off transitions. */ + public interface AdapterListener { + void adapterEnabledChanged(boolean enabled); + } + + /** + * The app-side GATT server's view of its virtual centrals -- wired by + * the port adapter to the core {@code GattServer} fire helpers. + */ + public interface AppServerSink { + void centralConnected(String centralAddress); + + void centralDisconnected(String centralAddress); + + void subscriptionChanged(String centralAddress, + GattLocalCharacteristic characteristic, boolean subscribed); + + void characteristicReadRequest(AppReadRequest request); + + void characteristicWriteRequest(AppWriteRequest request); + } + + /** + * A virtual central's read of an app-served characteristic without a + * static value. Answer exactly once via {@link #respond(byte[])} or + * {@link #reject(GattStatus)}; both are safe from any thread. + */ + public static final class AppReadRequest { + private final SimulatedBluetoothStack stack; + private final String centralAddress; + private final GattLocalCharacteristic characteristic; + private final Callback callback; + private boolean answered; + + AppReadRequest(SimulatedBluetoothStack stack, String centralAddress, + GattLocalCharacteristic characteristic, + Callback callback) { + this.stack = stack; + this.centralAddress = centralAddress; + this.characteristic = characteristic; + this.callback = callback; + } + + public String getCentralAddress() { + return centralAddress; + } + + public GattLocalCharacteristic getCharacteristic() { + return characteristic; + } + + public void respond(byte[] value) { + final byte[] v = ByteArrays.copy(value); + if (claim()) { + stack.postCompletion("centralRead", new Runnable() { + public void run() { + callback.onSuccess(v); + } + }); + } + } + + public void reject(GattStatus status) { + final GattStatus s = status == null + ? GattStatus.UNLIKELY_ERROR : status; + if (claim()) { + stack.postCompletion("centralRead", new Runnable() { + public void run() { + callback.onError(BluetoothError.GATT_ERROR, + "Read rejected with ATT status " + s); + } + }); + } + } + + private synchronized boolean claim() { + if (answered) { + return false; + } + answered = true; + return true; + } + } + + /** + * A virtual central's write of an app-served characteristic. Answer + * exactly once via {@link #respond()} or {@link #reject(GattStatus)}; + * both are safe from any thread. + */ + public static final class AppWriteRequest { + private final SimulatedBluetoothStack stack; + private final String centralAddress; + private final GattLocalCharacteristic characteristic; + private final byte[] value; + private final Callback callback; + private boolean answered; + + AppWriteRequest(SimulatedBluetoothStack stack, String centralAddress, + GattLocalCharacteristic characteristic, byte[] value, + Callback callback) { + this.stack = stack; + this.centralAddress = centralAddress; + this.characteristic = characteristic; + this.value = ByteArrays.copy(value); + this.callback = callback; + } + + public String getCentralAddress() { + return centralAddress; + } + + public GattLocalCharacteristic getCharacteristic() { + return characteristic; + } + + /** The written bytes (a copy). */ + public byte[] getValue() { + return ByteArrays.copy(value); + } + + public void respond() { + if (claim()) { + stack.postCompletion("centralWrite", new Runnable() { + public void run() { + callback.onSuccess(Boolean.TRUE); + } + }); + } + } + + public void reject(GattStatus status) { + final GattStatus s = status == null + ? GattStatus.UNLIKELY_ERROR : status; + if (claim()) { + stack.postCompletion("centralWrite", new Runnable() { + public void run() { + callback.onError(BluetoothError.GATT_ERROR, + "Write rejected with ATT status " + s); + } + }); + } + } + + private synchronized boolean claim() { + if (answered) { + return false; + } + answered = true; + return true; + } + } + + // ------------------------------------------------------------------ + // internal state + // ------------------------------------------------------------------ + + private static final class ScriptedFailure { + final BluetoothError error; + final String message; + + ScriptedFailure(BluetoothError error, String message) { + this.error = error == null ? BluetoothError.UNKNOWN : error; + this.message = message; + } + } + + private static final class ConnState { + boolean discovered; + int mtu = 23; + final HashSet subscriptions = new HashSet(); + } + + private static final class ScanFeed { + final ScanFeedSink sink; + boolean active = true; + final HashSet emitted = new HashSet(); + + ScanFeed(ScanFeedSink sink) { + this.sink = sink; + } + } + + private static final class ClassicFeed { + final ClassicDiscoverySink sink; + boolean active = true; + + ClassicFeed(ClassicDiscoverySink sink) { + this.sink = sink; + } + } + + private static final class ServerEndpoint { + final ArrayDeque pendingChannels = + new ArrayDeque(); + final ArrayDeque> pendingAccepts = + new ArrayDeque>(); + } + + static final class CentralState { + final String address; + /** subscription key -> notification listener */ + final HashMap listeners = + new HashMap(); + + CentralState(String address) { + this.address = address; + } + } + + private final SimScheduler scheduler; + private final Object lock = new Object(); + + private boolean adapterEnabled = true; + private long latencyMillis = DEFAULT_LATENCY_MILLIS; + private long monotonicTimestamp; + private AdapterListener adapterListener; + + private final LinkedHashMap peripherals = + new LinkedHashMap(); + private final HashMap connections = + new HashMap(); + private final HashMap peripheralSinks = + new HashMap(); + private final HashSet bonded = new HashSet(); + private final HashMap> failures = + new HashMap>(); + private final ArrayList eventListeners = + new ArrayList(); + private final ArrayList scanFeeds = new ArrayList(); + + // app-side (peripheral role) registries + private AppServerSink appServerSink; + private final ArrayList appServices = + new ArrayList(); + private final LinkedHashMap virtualCentrals = + new LinkedHashMap(); + private final LinkedHashMap advertisements = + new LinkedHashMap(); + private final HashMap l2capServers = + new HashMap(); + private final HashMap rfcommServers = + new HashMap(); + private final HashMap rfcommEndpoints = + new HashMap(); + private final ArrayList openChannels = + new ArrayList(); + private int nextPsm = 0x81; + private int nextCentralId = 1; + + public SimulatedBluetoothStack(SimScheduler scheduler) { + if (scheduler == null) { + throw new IllegalArgumentException("scheduler is required"); + } + this.scheduler = scheduler; + } + + /** The scheduler this stack runs on. */ + public SimScheduler getScheduler() { + return scheduler; + } + + // ------------------------------------------------------------------ + // plumbing helpers + // ------------------------------------------------------------------ + + private void run(Runnable r) { + scheduler.post(r); + } + + /** Schedules a completion callback after the configured latency. */ + private void completeLater(Runnable r) { + long latency; + synchronized (lock) { + latency = latencyMillis; + } + scheduler.postDelayed(r, latency); + } + + /** Latency-delayed completion used by the request objects. */ + void postCompletion(String op, Runnable r) { + fireEvent(op, "response"); + completeLater(r); + } + + private void fireEvent(String op, String detail) { + Object[] snapshot; + synchronized (lock) { + if (eventListeners.isEmpty()) { + return; + } + snapshot = eventListeners.toArray(); + } + for (int i = 0; i < snapshot.length; i++) { + ((StackEventListener) snapshot[i]).event(op, detail); + } + } + + private static void succeedNow(Callback cb, T value) { + if (cb != null) { + cb.onSuccess(value); + } + } + + private void succeed(final Callback cb, final T value) { + completeLater(new Runnable() { + public void run() { + succeedNow(cb, value); + } + }); + } + + private void fail(final Callback cb, final BluetoothError error, + final String message) { + completeLater(new Runnable() { + public void run() { + if (cb != null) { + cb.onError(error, message); + } + } + }); + } + + /** + * Consumes and applies a scripted failure for the op key when one is + * queued. Must run on the scheduler thread. + */ + private boolean failScripted(String op, Callback cb) { + ScriptedFailure f; + synchronized (lock) { + ArrayDeque q = failures.get(op); + f = q == null ? null : q.pollFirst(); + } + if (f == null) { + return false; + } + fireEvent(op, "scripted failure: " + f.error + + (f.message == null ? "" : " (" + f.message + ")")); + fail(cb, f.error, f.message); + return true; + } + + private long nextTimestamp() { + synchronized (lock) { + return ++monotonicTimestamp; + } + } + + private static String subKey(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid) { + return serviceUuid + "|" + characteristicUuid; + } + + // ------------------------------------------------------------------ + // configuration, scripting and the event log + // ------------------------------------------------------------------ + + /** Registers an event-log listener; effective immediately. */ + public void addEventListener(StackEventListener l) { + if (l == null) { + return; + } + synchronized (lock) { + if (!eventListeners.contains(l)) { + eventListeners.add(l); + } + } + } + + /** Removes an event-log listener. */ + public void removeEventListener(StackEventListener l) { + synchronized (lock) { + eventListeners.remove(l); + } + } + + /** Writes a free-form entry to the event log (via the scheduler). */ + public void logEvent(final String op, final String detail) { + run(new Runnable() { + public void run() { + fireEvent(op, detail); + } + }); + } + + /** + * Sets the artificial latency applied to every asynchronous + * completion. Ordered with subsequently issued operations. + */ + public void setLatencyMillis(final long millis) { + run(new Runnable() { + public void run() { + synchronized (lock) { + latencyMillis = Math.max(0, millis); + } + fireEvent("config", "latency=" + Math.max(0, millis) + "ms"); + } + }); + } + + /** The current artificial completion latency in milliseconds. */ + public long getLatencyMillis() { + synchronized (lock) { + return latencyMillis; + } + } + + /** + * Scripts the next occurrence of the given operation to fail. Multiple + * calls queue up (FIFO). Operation keys: {@code "connect"}, + * {@code "disconnect"}, {@code "read"}, {@code "write"}, + * {@code "discover"}, {@code "subscribe"}, {@code "scan"}, + * {@code "rssi"}, {@code "mtu"}, {@code "bond"}, + * {@code "rfcommConnect"}, {@code "l2cap"}. + */ + public void failNext(final String op, final BluetoothError error, + final String message) { + if (op == null) { + return; + } + run(new Runnable() { + public void run() { + synchronized (lock) { + ArrayDeque q = failures.get(op); + if (q == null) { + q = new ArrayDeque(); + failures.put(op, q); + } + q.addLast(new ScriptedFailure(error, message)); + } + fireEvent("script", "failNext " + op + " -> " + error); + } + }); + } + + /** + * Restores pristine state: peripherals, connections, scripted + * failures, app-side registries and virtual centrals are cleared; the + * adapter is re-enabled; latency returns to the default; open piped + * channels are closed and pending accepts fail. + */ + public void reset() { + run(new Runnable() { + public void run() { + fireEvent("reset", "stack reset"); + ArrayList channels; + ArrayList> accepts = + new ArrayList>(); + synchronized (lock) { + channels = new ArrayList(openChannels); + openChannels.clear(); + for (ServerEndpoint ep : l2capServers.values()) { + accepts.addAll(ep.pendingAccepts); + channels.addAll(ep.pendingChannels); + } + for (ServerEndpoint ep : rfcommServers.values()) { + accepts.addAll(ep.pendingAccepts); + channels.addAll(ep.pendingChannels); + } + l2capServers.clear(); + rfcommServers.clear(); + rfcommEndpoints.clear(); + peripherals.clear(); + connections.clear(); + peripheralSinks.clear(); + bonded.clear(); + failures.clear(); + scanFeeds.clear(); + appServerSink = null; + appServices.clear(); + virtualCentrals.clear(); + advertisements.clear(); + adapterEnabled = true; + latencyMillis = DEFAULT_LATENCY_MILLIS; + adapterListener = null; + nextPsm = 0x81; + nextCentralId = 1; + } + int size = channels.size(); + for (int i = 0; i < size; i++) { + channels.get(i).close(); + } + size = accepts.size(); + for (int i = 0; i < size; i++) { + final Callback cb = accepts.get(i); + cb.onError(BluetoothError.IO_ERROR, + "Server closed by stack reset"); + } + } + }); + } + + // ------------------------------------------------------------------ + // adapter + // ------------------------------------------------------------------ + + /** Turns the simulated adapter on or off. */ + public void setAdapterEnabled(final boolean enabled) { + run(new Runnable() { + public void run() { + AdapterListener l; + boolean changed; + synchronized (lock) { + changed = adapterEnabled != enabled; + adapterEnabled = enabled; + l = adapterListener; + } + fireEvent("adapter", enabled ? "enabled" : "disabled"); + if (changed && l != null) { + l.adapterEnabledChanged(enabled); + } + } + }); + } + + public boolean isAdapterEnabled() { + synchronized (lock) { + return adapterEnabled; + } + } + + /** Sets the single adapter-state listener (the port adapter). */ + public void setAdapterListener(final AdapterListener listener) { + run(new Runnable() { + public void run() { + synchronized (lock) { + adapterListener = listener; + } + } + }); + } + + // ------------------------------------------------------------------ + // peripheral registry + // ------------------------------------------------------------------ + + /** + * Registers a virtual peripheral. Active scan feeds pick it up + * immediately (after the configured latency). + */ + public void addPeripheral(final VirtualPeripheral peripheral) { + if (peripheral == null) { + return; + } + run(new Runnable() { + public void run() { + ArrayList feeds = new ArrayList(); + synchronized (lock) { + peripherals.put(peripheral.getAddress(), peripheral); + if (peripheral.isBonded()) { + bonded.add(peripheral.getAddress()); + } + if (peripheral.isLe()) { + for (int i = 0; i < scanFeeds.size(); i++) { + ScanFeed f = scanFeeds.get(i); + if (f.active) { + feeds.add(f); + } + } + } + } + fireEvent("registerPeripheral", peripheral.getAddress()); + int size = feeds.size(); + for (int i = 0; i < size; i++) { + scheduleSighting(feeds.get(i), peripheral, 1); + } + } + }); + } + + /** Removes a peripheral; an existing connection is dropped silently. */ + public void removePeripheral(final String address) { + run(new Runnable() { + public void run() { + synchronized (lock) { + peripherals.remove(address); + connections.remove(address); + bonded.remove(address); + } + fireEvent("removePeripheral", address); + } + }); + } + + /** Removes every registered peripheral. */ + public void clearPeripherals() { + run(new Runnable() { + public void run() { + synchronized (lock) { + peripherals.clear(); + connections.clear(); + bonded.clear(); + } + fireEvent("clearPeripherals", ""); + } + }); + } + + /** + * Replays a recorded trace: every fixture device is registered as a + * {@link VirtualPeripheral} at its first-sighting time and its RSSI + * timeline is replayed through the stack's scheduler + * ({@code postDelayed} with the fixture's relative timestamps -- on a + * {@link ManualScheduler} that is virtual-clock deterministic). + * Devices with a captured GATT database become fully connectable + * peripherals; the rest are advertisement-only. Safe to call again + * after {@link #reset()}. + */ + public void loadFixture(final BluetoothFixture fixture) { + if (fixture == null) { + return; + } + run(new Runnable() { + public void run() { + List devices = + fixture.getDevices(); + fireEvent("fixture", "loading " + devices.size() + + " device(s)"); + int size = devices.size(); + for (int i = 0; i < size; i++) { + BluetoothFixture.Device d = devices.get(i); + final VirtualPeripheral p = d.toVirtualPeripheral(); + List timeline = + d.rssiTimeline; + long firstSeen = timeline.isEmpty() ? 0 + : timeline.get(0).relTimeMs; + scheduler.postDelayed(new Runnable() { + public void run() { + addPeripheral(p); + } + }, firstSeen); + int ts = timeline.size(); + for (int j = 1; j < ts; j++) { + final BluetoothFixture.RssiSample s = + timeline.get(j); + scheduler.postDelayed(new Runnable() { + public void run() { + p.setRssi(s.rssi); + } + }, s.relTimeMs); + } + } + } + }); + } + + public boolean isPeripheralRegistered(String address) { + synchronized (lock) { + return peripherals.containsKey(address); + } + } + + /** The registered peripheral at the address, or {@code null}. */ + public VirtualPeripheral getPeripheral(String address) { + synchronized (lock) { + return peripherals.get(address); + } + } + + /** The registered addresses in registration order. */ + public List getPeripheralAddresses() { + synchronized (lock) { + return new ArrayList(peripherals.keySet()); + } + } + + // ------------------------------------------------------------------ + // scanning + // ------------------------------------------------------------------ + + /** + * Opens a scan feed: every registered LE peripheral is emitted once, + * staggered by the configured latency, and peripherals registered + * while the feed is active are emitted as they appear. Returns an + * opaque token for {@link #stopScanFeed(Object)}. + */ + public Object startScanFeed(final ScanFeedSink sink) { + final ScanFeed feed = new ScanFeed(sink); + run(new Runnable() { + public void run() { + fireEvent("scan", "start"); + if (failScriptedScan(sink)) { + return; + } + ArrayList toEmit = + new ArrayList(); + synchronized (lock) { + if (!adapterEnabled) { + feed.active = false; + } else { + scanFeeds.add(feed); + for (VirtualPeripheral p : peripherals.values()) { + if (p.isLe()) { + toEmit.add(p); + } + } + } + } + if (!feed.active) { + fireEvent("scan", "failed: adapter disabled"); + completeLater(new Runnable() { + public void run() { + sink.onScanFailed(BluetoothError.POWERED_OFF, + "The Bluetooth adapter is disabled"); + } + }); + return; + } + int size = toEmit.size(); + for (int i = 0; i < size; i++) { + scheduleSighting(feed, toEmit.get(i), i + 1); + } + } + }); + return feed; + } + + private boolean failScriptedScan(final ScanFeedSink sink) { + ScriptedFailure f; + synchronized (lock) { + ArrayDeque q = failures.get("scan"); + f = q == null ? null : q.pollFirst(); + } + if (f == null) { + return false; + } + final ScriptedFailure failure = f; + fireEvent("scan", "scripted failure: " + failure.error); + completeLater(new Runnable() { + public void run() { + sink.onScanFailed(failure.error, failure.message); + } + }); + return true; + } + + private void scheduleSighting(final ScanFeed feed, + final VirtualPeripheral p, int slot) { + long latency; + synchronized (lock) { + latency = latencyMillis; + } + scheduler.postDelayed(new Runnable() { + public void run() { + synchronized (lock) { + if (!feed.active + || !peripherals.containsKey(p.getAddress()) + || !feed.emitted.add(p.getAddress())) { + return; + } + } + fireEvent("scan", "sighting " + p.getAddress()); + feed.sink.onSighting(p, nextTimestamp()); + } + }, latency * slot); + } + + /** Stops a scan feed opened via {@link #startScanFeed(ScanFeedSink)}. */ + public void stopScanFeed(final Object token) { + run(new Runnable() { + public void run() { + if (!(token instanceof ScanFeed)) { + return; + } + ScanFeed feed = (ScanFeed) token; + synchronized (lock) { + feed.active = false; + scanFeeds.remove(feed); + } + fireEvent("scan", "stop"); + } + }); + } + + /** + * Runs a classic (BR/EDR) discovery: every classic-flagged peripheral + * is emitted once, staggered by the latency, then the sink completes. + * Returns an opaque token for {@link #stopClassicDiscovery(Object)}. + */ + public Object startClassicDiscovery(final ClassicDiscoverySink sink) { + final ClassicFeed feed = new ClassicFeed(sink); + run(new Runnable() { + public void run() { + fireEvent("scan", "classic discovery start"); + ScriptedFailure f; + synchronized (lock) { + ArrayDeque q = failures.get("scan"); + f = q == null ? null : q.pollFirst(); + } + if (f != null) { + final ScriptedFailure failure = f; + fireEvent("scan", "scripted failure: " + failure.error); + completeLater(new Runnable() { + public void run() { + sink.onFailed(failure.error, failure.message); + } + }); + return; + } + final ArrayList toEmit = + new ArrayList(); + boolean enabled; + synchronized (lock) { + enabled = adapterEnabled; + if (enabled) { + for (VirtualPeripheral p : peripherals.values()) { + if (p.isClassic()) { + toEmit.add(p); + } + } + } + } + if (!enabled) { + completeLater(new Runnable() { + public void run() { + sink.onFailed(BluetoothError.POWERED_OFF, + "The Bluetooth adapter is disabled"); + } + }); + return; + } + long latency; + synchronized (lock) { + latency = latencyMillis; + } + int size = toEmit.size(); + for (int i = 0; i < size; i++) { + final VirtualPeripheral p = toEmit.get(i); + scheduler.postDelayed(new Runnable() { + public void run() { + if (feed.active) { + fireEvent("scan", + "classic sighting " + p.getAddress()); + sink.onSighting(p); + } + } + }, latency * (i + 1)); + } + scheduler.postDelayed(new Runnable() { + public void run() { + if (feed.active) { + feed.active = false; + fireEvent("scan", "classic discovery complete"); + sink.onComplete(); + } + } + }, latency * (size + 1)); + } + }); + return feed; + } + + /** Aborts a running classic discovery; the sink stops firing. */ + public void stopClassicDiscovery(final Object token) { + run(new Runnable() { + public void run() { + if (token instanceof ClassicFeed) { + ((ClassicFeed) token).active = false; + fireEvent("scan", "classic discovery stop"); + } + } + }); + } + + // ------------------------------------------------------------------ + // central-role connection + GATT client + // ------------------------------------------------------------------ + + /** + * Registers the sink receiving remote-initiated events (notifications, + * connection loss) of the given peripheral. Survives connect cycles; + * cleared by {@link #reset()}. + */ + public void setPeripheralSink(final String address, + final PeripheralSink sink) { + run(new Runnable() { + public void run() { + synchronized (lock) { + if (sink == null) { + peripheralSinks.remove(address); + } else { + peripheralSinks.put(address, sink); + } + } + } + }); + } + + /** Connects to a registered, connectable peripheral. */ + public void connect(final String address, final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("connect", address); + if (failScripted("connect", cb)) { + return; + } + VirtualPeripheral p; + boolean enabled; + synchronized (lock) { + enabled = adapterEnabled; + p = peripherals.get(address); + } + if (!enabled) { + fail(cb, BluetoothError.POWERED_OFF, + "The Bluetooth adapter is disabled"); + return; + } + if (p == null) { + fail(cb, BluetoothError.CONNECTION_FAILED, + "No such peripheral: " + address); + return; + } + if (!p.isConnectable()) { + fail(cb, BluetoothError.CONNECTION_FAILED, + "Peripheral is not connectable: " + address); + return; + } + synchronized (lock) { + if (!connections.containsKey(address)) { + connections.put(address, new ConnState()); + } + } + succeed(cb, Boolean.TRUE); + } + }); + } + + /** + * Disconnects from a peripheral; succeeds silently when not + * connected (mirroring platform behavior on redundant disconnects). + */ + public void disconnect(final String address, final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("disconnect", address); + if (failScripted("disconnect", cb)) { + return; + } + synchronized (lock) { + connections.remove(address); + } + succeed(cb, Boolean.TRUE); + } + }); + } + + public boolean isConnected(String address) { + synchronized (lock) { + return connections.containsKey(address); + } + } + + /** The addresses currently connected, in connection order. */ + public List getConnectedAddresses() { + synchronized (lock) { + ArrayList out = new ArrayList(); + for (String address : peripherals.keySet()) { + if (connections.containsKey(address)) { + out.add(address); + } + } + return out; + } + } + + /** Discovers the GATT database of a connected peripheral. */ + public void discoverServices(final String address, + final Callback> cb) { + run(new Runnable() { + public void run() { + fireEvent("discover", address); + if (failScripted("discover", cb)) { + return; + } + ConnState conn = requireConnection(address, cb); + if (conn == null) { + return; + } + VirtualPeripheral p; + synchronized (lock) { + p = peripherals.get(address); + conn.discovered = true; + } + succeed(cb, p == null + ? new ArrayList() : p.getServices()); + } + }); + } + + /** Reads a characteristic value of a connected peripheral. */ + public void readCharacteristic(final String address, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, + final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("read", address + " " + serviceUuid + "/" + + characteristicUuid); + if (failScripted("read", cb)) { + return; + } + VirtualCharacteristic c = requireCharacteristic(address, + serviceUuid, characteristicUuid, cb); + if (c == null) { + return; + } + if (!c.canRead()) { + fail(cb, BluetoothError.GATT_ERROR, + "Characteristic is not readable: " + + characteristicUuid); + return; + } + succeed(cb, c.getValue()); + } + }); + } + + /** Writes a characteristic value of a connected peripheral. */ + public void writeCharacteristic(final String address, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, byte[] value, + final Callback cb) { + final byte[] v = ByteArrays.copy(value); + run(new Runnable() { + public void run() { + fireEvent("write", address + " " + serviceUuid + "/" + + characteristicUuid + " (" + v.length + " bytes)"); + if (failScripted("write", cb)) { + return; + } + VirtualCharacteristic c = requireCharacteristic(address, + serviceUuid, characteristicUuid, cb); + if (c == null) { + return; + } + if (!c.canWrite()) { + fail(cb, BluetoothError.GATT_ERROR, + "Characteristic is not writable: " + + characteristicUuid); + return; + } + c.setValue(v); + succeed(cb, Boolean.TRUE); + } + }); + } + + /** Reads a descriptor value of a connected peripheral. */ + public void readDescriptor(final String address, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, + final BluetoothUuid descriptorUuid, final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("read", address + " descriptor " + descriptorUuid); + if (failScripted("read", cb)) { + return; + } + VirtualDescriptor d = requireDescriptor(address, serviceUuid, + characteristicUuid, descriptorUuid, cb); + if (d == null) { + return; + } + succeed(cb, d.getValue()); + } + }); + } + + /** Writes a descriptor value of a connected peripheral. */ + public void writeDescriptor(final String address, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, + final BluetoothUuid descriptorUuid, byte[] value, + final Callback cb) { + final byte[] v = ByteArrays.copy(value); + run(new Runnable() { + public void run() { + fireEvent("write", address + " descriptor " + descriptorUuid + + " (" + v.length + " bytes)"); + if (failScripted("write", cb)) { + return; + } + VirtualDescriptor d = requireDescriptor(address, serviceUuid, + characteristicUuid, descriptorUuid, cb); + if (d == null) { + return; + } + d.setValue(v); + succeed(cb, Boolean.TRUE); + } + }); + } + + /** Arms or disarms notifications for a characteristic. */ + public void setNotifications(final String address, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, final boolean enable, + final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("subscribe", address + " " + characteristicUuid + + " enable=" + enable); + if (failScripted("subscribe", cb)) { + return; + } + VirtualCharacteristic c = requireCharacteristic(address, + serviceUuid, characteristicUuid, cb); + if (c == null) { + return; + } + if (enable && !c.canNotifyOrIndicate()) { + fail(cb, BluetoothError.GATT_ERROR, + "Characteristic supports neither notify nor " + + "indicate: " + characteristicUuid); + return; + } + synchronized (lock) { + ConnState conn = connections.get(address); + if (conn != null) { + String key = subKey(serviceUuid, characteristicUuid); + if (enable) { + conn.subscriptions.add(key); + } else { + conn.subscriptions.remove(key); + } + } + } + succeed(cb, Boolean.TRUE); + } + }); + } + + /** {@code true} while the app is subscribed to the characteristic. */ + public boolean isSubscribed(String address, BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid) { + synchronized (lock) { + ConnState conn = connections.get(address); + return conn != null && conn.subscriptions.contains( + subKey(serviceUuid, characteristicUuid)); + } + } + + /** Reads the RSSI of a connected peripheral. */ + public void readRssi(final String address, final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("rssi", address); + if (failScripted("rssi", cb)) { + return; + } + if (requireConnection(address, cb) == null) { + return; + } + VirtualPeripheral p; + synchronized (lock) { + p = peripherals.get(address); + } + succeed(cb, Integer.valueOf(p == null ? -127 : p.getRssi())); + } + }); + } + + /** Negotiates an MTU; grants the request clamped to 23..517. */ + public void requestMtu(final String address, final int requested, + final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("mtu", address + " request=" + requested); + if (failScripted("mtu", cb)) { + return; + } + ConnState conn = requireConnection(address, cb); + if (conn == null) { + return; + } + int granted = Math.max(23, Math.min(517, requested)); + synchronized (lock) { + conn.mtu = granted; + } + succeed(cb, Integer.valueOf(granted)); + } + }); + } + + /** The negotiated MTU of a connection; 23 when not connected. */ + public int getMtu(String address) { + synchronized (lock) { + ConnState conn = connections.get(address); + return conn == null ? 23 : conn.mtu; + } + } + + /** Bonds with a registered peripheral. */ + public void bond(final String address, final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("bond", address); + if (failScripted("bond", cb)) { + return; + } + boolean known; + synchronized (lock) { + known = peripherals.containsKey(address); + if (known) { + bonded.add(address); + } + } + if (!known) { + fail(cb, BluetoothError.BOND_FAILED, + "No such peripheral: " + address); + return; + } + succeed(cb, Boolean.TRUE); + } + }); + } + + public boolean isBonded(String address) { + synchronized (lock) { + return bonded.contains(address); + } + } + + /** The bonded addresses, in registration order. */ + public List getBondedAddresses() { + synchronized (lock) { + ArrayList out = new ArrayList(); + for (String address : peripherals.keySet()) { + if (bonded.contains(address)) { + out.add(address); + } + } + return out; + } + } + + /** Requires a live connection; fails the callback otherwise. */ + private ConnState requireConnection(String address, Callback cb) { + ConnState conn; + synchronized (lock) { + conn = connections.get(address); + } + if (conn == null) { + fail(cb, BluetoothError.NOT_CONNECTED, + "Peripheral is not connected: " + address); + } + return conn; + } + + private VirtualCharacteristic requireCharacteristic(String address, + BluetoothUuid serviceUuid, BluetoothUuid characteristicUuid, + Callback cb) { + ConnState conn = requireConnection(address, cb); + if (conn == null) { + return null; + } + if (!conn.discovered) { + fail(cb, BluetoothError.GATT_ERROR, + "Services were not discovered yet: " + address); + return null; + } + VirtualPeripheral p; + synchronized (lock) { + p = peripherals.get(address); + } + VirtualCharacteristic c = p == null + ? null : p.getCharacteristic(serviceUuid, characteristicUuid); + if (c == null) { + fail(cb, BluetoothError.GATT_ERROR, "No such characteristic: " + + serviceUuid + "/" + characteristicUuid); + } + return c; + } + + private VirtualDescriptor requireDescriptor(String address, + BluetoothUuid serviceUuid, BluetoothUuid characteristicUuid, + BluetoothUuid descriptorUuid, Callback cb) { + VirtualCharacteristic c = requireCharacteristic(address, serviceUuid, + characteristicUuid, cb); + if (c == null) { + return null; + } + VirtualDescriptor d = c.getDescriptor(descriptorUuid); + if (d == null) { + fail(cb, BluetoothError.GATT_ERROR, + "No such descriptor: " + descriptorUuid); + } + return d; + } + + // ------------------------------------------------------------------ + // remote-initiated events + // ------------------------------------------------------------------ + + /** + * Pushes a notification from a virtual peripheral to the app. Only + * delivered while the app is connected and subscribed. + */ + public void pushNotification(final String address, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, byte[] value) { + final byte[] v = ByteArrays.copy(value); + run(new Runnable() { + public void run() { + final PeripheralSink sink; + boolean subscribed; + synchronized (lock) { + ConnState conn = connections.get(address); + subscribed = conn != null && conn.subscriptions.contains( + subKey(serviceUuid, characteristicUuid)); + sink = peripheralSinks.get(address); + } + if (!subscribed || sink == null) { + fireEvent("notification", address + " " + + characteristicUuid + " dropped (no subscriber)"); + return; + } + fireEvent("notification", address + " " + characteristicUuid + + " (" + v.length + " bytes)"); + completeLater(new Runnable() { + public void run() { + sink.onNotification(serviceUuid, characteristicUuid, + v); + } + }); + } + }); + } + + /** + * Simulates the remote peripheral dropping the link: connection state + * is cleared and the peripheral's sink observes a connection loss. + */ + public void disconnectFromRemote(final String address) { + run(new Runnable() { + public void run() { + final PeripheralSink sink; + boolean wasConnected; + synchronized (lock) { + wasConnected = connections.remove(address) != null; + sink = peripheralSinks.get(address); + } + fireEvent("connectionLost", address + + (wasConnected ? "" : " (was not connected)")); + if (wasConnected && sink != null) { + completeLater(new Runnable() { + public void run() { + sink.onConnectionLost( + BluetoothError.CONNECTION_LOST, + "The remote device dropped the link"); + } + }); + } + } + }); + } + + // ------------------------------------------------------------------ + // L2CAP -- app as central + // ------------------------------------------------------------------ + + /** + * Opens an L2CAP channel to a virtual peripheral's endpoint at the + * given PSM (registered via + * {@link VirtualPeripheral#withL2capEndpoint(int, SimStreamHandler)}). + */ + public void openL2capChannel(final String address, final int psm, + final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("l2cap", "open " + address + " psm=" + psm); + if (failScripted("l2cap", cb)) { + return; + } + boolean enabled; + VirtualPeripheral p; + synchronized (lock) { + enabled = adapterEnabled; + p = peripherals.get(address); + } + if (!enabled) { + fail(cb, BluetoothError.POWERED_OFF, + "The Bluetooth adapter is disabled"); + return; + } + final SimStreamHandler handler = + p == null ? null : p.getL2capEndpoint(psm); + if (handler == null) { + fail(cb, BluetoothError.IO_ERROR, + "No L2CAP endpoint at PSM " + psm + " on " + + address); + return; + } + SimStreamChannel[] pair = SimStreamChannel.createPair(); + final SimStreamChannel local = pair[0]; + final SimStreamChannel remote = pair[1]; + trackChannels(pair); + completeLater(new Runnable() { + public void run() { + handler.onConnection(remote); + succeedNow(cb, local); + } + }); + } + }); + } + + private void trackChannels(SimStreamChannel[] pair) { + synchronized (lock) { + openChannels.add(pair[0]); + openChannels.add(pair[1]); + } + } + + // ------------------------------------------------------------------ + // app-side GATT server + virtual centrals + // ------------------------------------------------------------------ + + /** + * Opens the app's GATT server. The sink receives virtual-central + * events; any previously open server is replaced. + */ + public void openAppGattServer(final AppServerSink sink, + final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("gattServer", "open"); + synchronized (lock) { + appServerSink = sink; + appServices.clear(); + } + succeed(cb, Boolean.TRUE); + } + }); + } + + /** Publishes a service definition on the app's GATT server. */ + public void addAppService(final GattLocalService service, + final Callback cb) { + run(new Runnable() { + public void run() { + if (service == null) { + fail(cb, BluetoothError.UNKNOWN, "service is required"); + return; + } + fireEvent("gattServer", "addService " + service.getUuid()); + synchronized (lock) { + appServices.add(service); + } + succeed(cb, Boolean.TRUE); + } + }); + } + + /** Removes a previously added app service. */ + public void removeAppService(final GattLocalService service) { + run(new Runnable() { + public void run() { + boolean removed; + synchronized (lock) { + removed = appServices.remove(service); + } + fireEvent("gattServer", "removeService " + + (service == null ? "null" : service.getUuid()) + + (removed ? "" : " (not registered)")); + } + }); + } + + /** The app's published service definitions (a snapshot). */ + public List getAppServices() { + synchronized (lock) { + return new ArrayList(appServices); + } + } + + /** + * Closes the app's GATT server: virtual centrals are disconnected and + * the sink is released. + */ + public void closeAppGattServer() { + run(new Runnable() { + public void run() { + final AppServerSink sink; + final ArrayList centrals; + synchronized (lock) { + sink = appServerSink; + centrals = new ArrayList(virtualCentrals.keySet()); + appServerSink = null; + appServices.clear(); + virtualCentrals.clear(); + } + fireEvent("gattServer", "close"); + if (sink != null) { + int size = centrals.size(); + for (int i = 0; i < size; i++) { + final String address = centrals.get(i); + completeLater(new Runnable() { + public void run() { + sink.centralDisconnected(address); + } + }); + } + } + } + }); + } + + /** + * Pushes a value change of an app-served characteristic to subscribed + * virtual centrals ({@code centralAddress == null}) or to one central. + */ + public void notifyAppValue(final GattLocalCharacteristic characteristic, + byte[] value, final String centralAddress, + final Callback cb) { + final byte[] v = ByteArrays.copy(value); + run(new Runnable() { + public void run() { + if (characteristic == null) { + fail(cb, BluetoothError.UNKNOWN, + "characteristic is required"); + return; + } + BluetoothUuid serviceUuid = + findAppServiceUuid(characteristic); + if (serviceUuid == null) { + fail(cb, BluetoothError.GATT_ERROR, + "Characteristic is not part of a published " + + "service: " + characteristic.getUuid()); + return; + } + final BluetoothUuid svc = serviceUuid; + final BluetoothUuid chr = characteristic.getUuid(); + String key = subKey(svc, chr); + int delivered = 0; + synchronized (lock) { + for (CentralState central : virtualCentrals.values()) { + if (centralAddress != null + && !centralAddress.equals(central.address)) { + continue; + } + final VirtualCentral.NotificationListener l = + central.listeners.get(key); + if (l == null) { + continue; + } + delivered++; + completeLater(new Runnable() { + public void run() { + l.valueChanged(svc, chr, v); + } + }); + } + } + fireEvent("notifyValue", chr + " (" + v.length + + " bytes) to " + delivered + " central(s)"); + succeed(cb, Boolean.TRUE); + } + }); + } + + /** Must run on the scheduler thread or under external quiescence. */ + private BluetoothUuid findAppServiceUuid( + GattLocalCharacteristic characteristic) { + ArrayList snapshot; + synchronized (lock) { + snapshot = new ArrayList(appServices); + } + int size = snapshot.size(); + for (int i = 0; i < size; i++) { + GattLocalService s = snapshot.get(i); + List chars = s.getCharacteristics(); + int cs = chars.size(); + for (int j = 0; j < cs; j++) { + GattLocalCharacteristic c = chars.get(j); + if (c == characteristic || (c.getUuid() != null + && c.getUuid().equals(characteristic.getUuid()))) { + return s.getUuid(); + } + } + } + return null; + } + + /** Finds an app-served characteristic by service/characteristic UUID. */ + GattLocalCharacteristic findAppCharacteristic(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid) { + ArrayList snapshot; + synchronized (lock) { + snapshot = new ArrayList(appServices); + } + int size = snapshot.size(); + for (int i = 0; i < size; i++) { + GattLocalService s = snapshot.get(i); + if (!s.getUuid().equals(serviceUuid)) { + continue; + } + List chars = s.getCharacteristics(); + int cs = chars.size(); + for (int j = 0; j < cs; j++) { + if (chars.get(j).getUuid().equals(characteristicUuid)) { + return chars.get(j); + } + } + } + return null; + } + + /** + * Connects a scripted virtual central to the app's GATT server and + * returns its handle. The app-side sink observes the connection. + */ + public VirtualCentral connectVirtualCentral() { + final String address; + synchronized (lock) { + address = String.format("F0:00:00:00:00:%02X", + Integer.valueOf(nextCentralId++ & 0xFF)); + } + final VirtualCentral central = new VirtualCentral(this, address); + run(new Runnable() { + public void run() { + final AppServerSink sink; + synchronized (lock) { + virtualCentrals.put(address, new CentralState(address)); + sink = appServerSink; + } + fireEvent("central", "connected " + address); + if (sink != null) { + completeLater(new Runnable() { + public void run() { + sink.centralConnected(address); + } + }); + } + } + }); + return central; + } + + /** The addresses of the connected virtual centrals. */ + public List getConnectedCentralAddresses() { + synchronized (lock) { + return new ArrayList(virtualCentrals.keySet()); + } + } + + // called by VirtualCentral -------------------------------------------- + + void centralRead(final String centralAddress, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, + final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("centralRead", centralAddress + " " + serviceUuid + + "/" + characteristicUuid); + if (requireCentral(centralAddress, cb) == null) { + return; + } + final GattLocalCharacteristic c = findAppCharacteristic( + serviceUuid, characteristicUuid); + if (c == null) { + fail(cb, BluetoothError.GATT_ERROR, + "No such app characteristic: " + serviceUuid + "/" + + characteristicUuid); + return; + } + byte[] staticValue = c.getValue(); + if (staticValue != null) { + succeed(cb, ByteArrays.copy(staticValue)); + return; + } + final AppServerSink sink; + synchronized (lock) { + sink = appServerSink; + } + if (sink == null) { + fail(cb, BluetoothError.GATT_ERROR, + "The app GATT server is not open"); + return; + } + final AppReadRequest request = new AppReadRequest( + SimulatedBluetoothStack.this, centralAddress, c, cb); + completeLater(new Runnable() { + public void run() { + sink.characteristicReadRequest(request); + } + }); + } + }); + } + + void centralWrite(final String centralAddress, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, byte[] value, + final Callback cb) { + final byte[] v = ByteArrays.copy(value); + run(new Runnable() { + public void run() { + fireEvent("centralWrite", centralAddress + " " + serviceUuid + + "/" + characteristicUuid + " (" + v.length + + " bytes)"); + if (requireCentral(centralAddress, cb) == null) { + return; + } + final GattLocalCharacteristic c = findAppCharacteristic( + serviceUuid, characteristicUuid); + if (c == null) { + fail(cb, BluetoothError.GATT_ERROR, + "No such app characteristic: " + serviceUuid + "/" + + characteristicUuid); + return; + } + final AppServerSink sink; + synchronized (lock) { + sink = appServerSink; + } + if (sink == null) { + fail(cb, BluetoothError.GATT_ERROR, + "The app GATT server is not open"); + return; + } + final AppWriteRequest request = new AppWriteRequest( + SimulatedBluetoothStack.this, centralAddress, c, v, + cb); + completeLater(new Runnable() { + public void run() { + sink.characteristicWriteRequest(request); + } + }); + } + }); + } + + void centralSubscribe(final String centralAddress, + final BluetoothUuid serviceUuid, + final BluetoothUuid characteristicUuid, + final VirtualCentral.NotificationListener listener, + final boolean subscribe, final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("centralSubscribe", centralAddress + " " + + characteristicUuid + " subscribe=" + subscribe); + CentralState central = requireCentral(centralAddress, cb); + if (central == null) { + return; + } + final GattLocalCharacteristic c = findAppCharacteristic( + serviceUuid, characteristicUuid); + if (c == null) { + fail(cb, BluetoothError.GATT_ERROR, + "No such app characteristic: " + serviceUuid + "/" + + characteristicUuid); + return; + } + String key = subKey(serviceUuid, characteristicUuid); + final AppServerSink sink; + boolean changed; + synchronized (lock) { + if (subscribe) { + changed = central.listeners.put(key, listener) + == null; + } else { + changed = central.listeners.remove(key) != null; + } + sink = appServerSink; + } + if (changed && sink != null) { + completeLater(new Runnable() { + public void run() { + sink.subscriptionChanged(centralAddress, c, + subscribe); + } + }); + } + succeed(cb, Boolean.TRUE); + } + }); + } + + void centralDisconnect(final String centralAddress) { + run(new Runnable() { + public void run() { + final AppServerSink sink; + boolean removed; + synchronized (lock) { + removed = virtualCentrals.remove(centralAddress) != null; + sink = appServerSink; + } + fireEvent("central", "disconnected " + centralAddress + + (removed ? "" : " (was not connected)")); + if (removed && sink != null) { + completeLater(new Runnable() { + public void run() { + sink.centralDisconnected(centralAddress); + } + }); + } + } + }); + } + + private CentralState requireCentral(String centralAddress, + Callback cb) { + CentralState central; + synchronized (lock) { + central = virtualCentrals.get(centralAddress); + } + if (central == null) { + fail(cb, BluetoothError.NOT_CONNECTED, + "Virtual central is not connected: " + centralAddress); + } + return central; + } + + // ------------------------------------------------------------------ + // advertising (app side) + // ------------------------------------------------------------------ + + /** + * Registers an advertisement; the payload is an opaque description for + * the event log / debug UI. The callback receives the stop token. + */ + public void startAppAdvertising(final Object payload, + final Callback cb) { + final Object token = new Object(); + run(new Runnable() { + public void run() { + boolean enabled; + synchronized (lock) { + enabled = adapterEnabled; + } + if (!enabled) { + fail(cb, BluetoothError.POWERED_OFF, + "The Bluetooth adapter is disabled"); + return; + } + synchronized (lock) { + advertisements.put(token, + payload == null ? "advertisement" : payload); + } + fireEvent("advertise", "start " + + (payload == null ? "" : payload.toString())); + succeed(cb, token); + } + }); + } + + /** Stops an advertisement by its token. */ + public void stopAppAdvertising(final Object token) { + run(new Runnable() { + public void run() { + boolean removed; + synchronized (lock) { + removed = advertisements.remove(token) != null; + } + if (removed) { + fireEvent("advertise", "stop"); + } + } + }); + } + + /** Stops an advertisement after a delay (advertise timeouts). */ + public void stopAppAdvertisingAfter(final Object token, long millis) { + scheduler.postDelayed(new Runnable() { + public void run() { + stopAppAdvertising(token); + } + }, millis); + } + + /** {@code true} while the given advertisement token is live. */ + public boolean isAppAdvertising(Object token) { + synchronized (lock) { + return advertisements.containsKey(token); + } + } + + /** {@code true} while any advertisement is live. */ + public boolean isAdvertising() { + synchronized (lock) { + return !advertisements.isEmpty(); + } + } + + /** The live advertisement payloads (a snapshot), for the debug UI. */ + public List getAdvertisingPayloads() { + synchronized (lock) { + return new ArrayList(advertisements.values()); + } + } + + // ------------------------------------------------------------------ + // L2CAP -- app as server + // ------------------------------------------------------------------ + + /** Publishes an app L2CAP listener; the callback receives its PSM. */ + public void publishAppL2capServer(final Callback cb) { + run(new Runnable() { + public void run() { + int psm; + synchronized (lock) { + psm = nextPsm++; + l2capServers.put(Integer.valueOf(psm), + new ServerEndpoint()); + } + fireEvent("l2cap", "listen psm=" + psm); + succeed(cb, Integer.valueOf(psm)); + } + }); + } + + /** Accepts the next virtual client on a published PSM. */ + public void acceptAppL2cap(final int psm, + final Callback cb) { + run(new Runnable() { + public void run() { + acceptOnEndpoint(l2capEndpoint(psm), "l2cap", + "psm=" + psm, cb); + } + }); + } + + /** Unpublishes an app L2CAP listener; pending accepts fail. */ + public void closeAppL2capServer(final int psm) { + run(new Runnable() { + public void run() { + ServerEndpoint ep; + synchronized (lock) { + ep = l2capServers.remove(Integer.valueOf(psm)); + } + fireEvent("l2cap", "close psm=" + psm); + failPendingAccepts(ep); + } + }); + } + + /** + * Connects a virtual (remote) L2CAP client to the app's published PSM + * and returns the remote side of the channel immediately. When no + * listener is published at the PSM the returned channel observes EOF. + */ + public SimStreamChannel connectVirtualL2capClient(final int psm) { + SimStreamChannel[] pair = SimStreamChannel.createPair(); + final SimStreamChannel appSide = pair[0]; + final SimStreamChannel remoteSide = pair[1]; + trackChannels(pair); + run(new Runnable() { + public void run() { + fireEvent("l2cap", "virtual client connect psm=" + psm); + deliverToEndpoint(l2capEndpoint(psm), appSide); + } + }); + return remoteSide; + } + + private ServerEndpoint l2capEndpoint(int psm) { + synchronized (lock) { + return l2capServers.get(Integer.valueOf(psm)); + } + } + + // ------------------------------------------------------------------ + // RFCOMM + // ------------------------------------------------------------------ + + /** + * Registers a virtual remote RFCOMM endpoint under the given service + * UUID -- the counterpart the app connects to as a client. + */ + public void addRfcommEndpoint(final BluetoothUuid serviceUuid, + final SimStreamHandler handler) { + run(new Runnable() { + public void run() { + synchronized (lock) { + if (handler == null) { + rfcommEndpoints.remove(serviceUuid); + } else { + rfcommEndpoints.put(serviceUuid, handler); + } + } + fireEvent("rfcomm", "endpoint " + + (handler == null ? "removed" : "registered") + + " " + serviceUuid); + } + }); + } + + /** Connects the app (as a client) to a registered RFCOMM endpoint. */ + public void connectRfcomm(final BluetoothUuid serviceUuid, + final Callback cb) { + run(new Runnable() { + public void run() { + fireEvent("rfcommConnect", String.valueOf(serviceUuid)); + if (failScripted("rfcommConnect", cb)) { + return; + } + boolean enabled; + final SimStreamHandler handler; + synchronized (lock) { + enabled = adapterEnabled; + handler = rfcommEndpoints.get(serviceUuid); + } + if (!enabled) { + fail(cb, BluetoothError.POWERED_OFF, + "The Bluetooth adapter is disabled"); + return; + } + if (handler == null) { + fail(cb, BluetoothError.IO_ERROR, + "No RFCOMM endpoint registered for " + + serviceUuid); + return; + } + SimStreamChannel[] pair = SimStreamChannel.createPair(); + final SimStreamChannel local = pair[0]; + final SimStreamChannel remote = pair[1]; + trackChannels(pair); + completeLater(new Runnable() { + public void run() { + handler.onConnection(remote); + succeedNow(cb, local); + } + }); + } + }); + } + + /** Registers the app's RFCOMM listener under the service UUID. */ + public void listenRfcomm(final BluetoothUuid serviceUuid, + final Callback cb) { + run(new Runnable() { + public void run() { + boolean conflict; + synchronized (lock) { + conflict = rfcommServers.containsKey(serviceUuid); + if (!conflict) { + rfcommServers.put(serviceUuid, new ServerEndpoint()); + } + } + fireEvent("rfcomm", "listen " + serviceUuid + + (conflict ? " (already listening)" : "")); + if (conflict) { + fail(cb, BluetoothError.BUSY, + "An RFCOMM server is already listening on " + + serviceUuid); + return; + } + succeed(cb, Boolean.TRUE); + } + }); + } + + /** Accepts the next virtual client on the app's RFCOMM listener. */ + public void acceptRfcomm(final BluetoothUuid serviceUuid, + final Callback cb) { + run(new Runnable() { + public void run() { + acceptOnEndpoint(rfcommEndpointServer(serviceUuid), "rfcomm", + String.valueOf(serviceUuid), cb); + } + }); + } + + /** Stops the app's RFCOMM listener; pending accepts fail. */ + public void closeRfcommServer(final BluetoothUuid serviceUuid) { + run(new Runnable() { + public void run() { + ServerEndpoint ep; + synchronized (lock) { + ep = rfcommServers.remove(serviceUuid); + } + fireEvent("rfcomm", "close " + serviceUuid); + failPendingAccepts(ep); + } + }); + } + + /** + * Connects a virtual (remote) RFCOMM client to the app's listener and + * returns the remote side of the channel immediately. When no listener + * exists for the UUID the returned channel observes EOF. + */ + public SimStreamChannel connectVirtualRfcommClient( + final BluetoothUuid serviceUuid) { + SimStreamChannel[] pair = SimStreamChannel.createPair(); + final SimStreamChannel appSide = pair[0]; + final SimStreamChannel remoteSide = pair[1]; + trackChannels(pair); + run(new Runnable() { + public void run() { + fireEvent("rfcomm", "virtual client connect " + serviceUuid); + deliverToEndpoint(rfcommEndpointServer(serviceUuid), appSide); + } + }); + return remoteSide; + } + + private ServerEndpoint rfcommEndpointServer(BluetoothUuid serviceUuid) { + synchronized (lock) { + return rfcommServers.get(serviceUuid); + } + } + + // shared server-endpoint plumbing ----------------------------------- + + /** Must run on the scheduler thread. */ + private void acceptOnEndpoint(ServerEndpoint ep, String op, String what, + final Callback cb) { + if (ep == null) { + fail(cb, BluetoothError.IO_ERROR, + "The server is not listening (" + what + ")"); + return; + } + final SimStreamChannel queued; + synchronized (lock) { + queued = ep.pendingChannels.pollFirst(); + if (queued == null) { + ep.pendingAccepts.addLast(cb); + } + } + fireEvent(op, "accept " + what + + (queued == null ? " (waiting)" : "")); + if (queued != null) { + succeed(cb, queued); + } + } + + /** Must run on the scheduler thread. */ + private void deliverToEndpoint(ServerEndpoint ep, + SimStreamChannel appSide) { + if (ep == null) { + // nobody listening: the virtual client observes EOF at once + appSide.close(); + return; + } + final Callback waiting; + synchronized (lock) { + waiting = ep.pendingAccepts.pollFirst(); + if (waiting == null) { + ep.pendingChannels.addLast(appSide); + } + } + if (waiting != null) { + succeed(waiting, appSide); + } + } + + /** Must run on the scheduler thread. */ + private void failPendingAccepts(ServerEndpoint ep) { + if (ep == null) { + return; + } + ArrayList> accepts; + ArrayList channels; + synchronized (lock) { + accepts = new ArrayList>( + ep.pendingAccepts); + channels = new ArrayList(ep.pendingChannels); + ep.pendingAccepts.clear(); + ep.pendingChannels.clear(); + } + int size = accepts.size(); + for (int i = 0; i < size; i++) { + fail(accepts.get(i), BluetoothError.IO_ERROR, + "The server was closed"); + } + size = channels.size(); + for (int i = 0; i < size; i++) { + channels.get(i).close(); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatorBleBackend.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatorBleBackend.java new file mode 100644 index 00000000000..11cf22830e4 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatorBleBackend.java @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.helper.BleBackend; +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The {@link BleBackend} over {@link SimulatedBluetoothStack} -- the + * simulator's default BLE engine. Owns the canonical + * {@link SimBlePeripheral} per address and translates + * {@link VirtualPeripheral} sightings into core {@link ScanResult}s. + */ +class SimulatorBleBackend implements BleBackend { + + /** The backend name reported by {@link #getName()}. */ + static final String NAME = "simulator"; + + private final SimulatedBluetoothStack stack; + private final HashMap peripheralCache = + new HashMap(); + private Object scanToken; + + SimulatorBleBackend(SimulatedBluetoothStack stack) { + this.stack = stack; + } + + SimulatedBluetoothStack getStack() { + return stack; + } + + public String getName() { + return NAME; + } + + public boolean isLeSupported() { + return true; + } + + public boolean isPeripheralModeSupported() { + return true; + } + + public boolean isClassicSupported() { + return true; + } + + public boolean isL2capSupported() { + return true; + } + + public AdapterState getAdapterState() { + return stack.isAdapterEnabled() ? AdapterState.POWERED_ON + : AdapterState.POWERED_OFF; + } + + public void setAdapterStateSink(final AdapterStateSink sink) { + stack.setAdapterListener(sink == null ? null + : new SimulatedBluetoothStack.AdapterListener() { + public void adapterEnabledChanged(boolean enabled) { + sink.adapterStateChanged(enabled + ? AdapterState.POWERED_ON + : AdapterState.POWERED_OFF); + } + }); + } + + /** The canonical peripheral wrapper for the address. */ + SimBlePeripheral peripheral(String address) { + synchronized (peripheralCache) { + SimBlePeripheral p = peripheralCache.get(address); + if (p == null) { + p = new SimBlePeripheral(stack, address); + peripheralCache.put(address, p); + } + return p; + } + } + + public synchronized void startScan(final ScanSink sink) { + stopScan(); + scanToken = stack.startScanFeed( + new SimulatedBluetoothStack.ScanFeedSink() { + public void onSighting(VirtualPeripheral p, + long timestamp) { + sink.onResult(buildScanResult(p, timestamp)); + } + + public void onScanFailed(BluetoothError error, + String message) { + sink.onFailed(new BluetoothException(error, message)); + } + }); + } + + public synchronized void stopScan() { + if (scanToken != null) { + stack.stopScanFeed(scanToken); + scanToken = null; + } + } + + private ScanResult buildScanResult(VirtualPeripheral p, long timestamp) { + AdvertisementData ad = new AdvertisementData(); + ad.setLocalName(p.getName()); + List uuids = p.getAdvertisedServiceUuids(); + int size = uuids.size(); + for (int i = 0; i < size; i++) { + ad.addServiceUuid(uuids.get(i)); + } + for (Map.Entry e + : p.getManufacturerData().entrySet()) { + ad.addManufacturerData(e.getKey().intValue(), e.getValue()); + } + for (Map.Entry e + : p.getServiceData().entrySet()) { + ad.addServiceData(e.getKey(), e.getValue()); + } + ad.setTxPowerLevel(p.getTxPower()); + return new ScanResult(peripheral(p.getAddress()), p.getRssi(), ad, + p.isConnectable(), timestamp); + } + + public BlePeripheral getPeripheral(String address) { + return stack.isPeripheralRegistered(address) + ? peripheral(address) : null; + } + + public List getConnectedPeripherals( + BluetoothUuid serviceFilter) { + ArrayList out = new ArrayList(); + List addresses = stack.getConnectedAddresses(); + int size = addresses.size(); + for (int i = 0; i < size; i++) { + String address = addresses.get(i); + if (serviceFilter != null && !offersService(address, + serviceFilter)) { + continue; + } + out.add(peripheral(address)); + } + return out; + } + + private boolean offersService(String address, BluetoothUuid serviceUuid) { + VirtualPeripheral p = stack.getPeripheral(address); + if (p == null) { + return false; + } + return p.getService(serviceUuid) != null + || p.getAdvertisedServiceUuids().contains(serviceUuid); + } + + public List getBondedPeripherals() { + ArrayList out = new ArrayList(); + List addresses = stack.getBondedAddresses(); + int size = addresses.size(); + for (int i = 0; i < size; i++) { + String address = addresses.get(i); + VirtualPeripheral p = stack.getPeripheral(address); + if (p != null && p.isLe()) { + out.add(peripheral(address)); + } + } + return out; + } + + public AsyncResource openGattServer( + GattServerListener listener) { + AsyncResource out = new AsyncResource(); + new SimGattServer(listener, stack).open(out); + return out; + } + + public AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse) { + final AsyncResource out = + new AsyncResource(); + final AdvertiseSettings s = settings == null + ? new AdvertiseSettings() : settings; + stack.startAppAdvertising(describeAdvertisement(s, data), + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Object token) { + if (s.getTimeout() > 0) { + stack.stopAppAdvertisingAfter(token, + s.getTimeout()); + } + if (!out.isDone()) { + out.complete(new SimBleAdvertisement(stack, + token)); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + return out; + } + + private static String describeAdvertisement(AdvertiseSettings settings, + AdvertiseData data) { + StringBuilder sb = new StringBuilder("mode="); + sb.append(settings.getMode()); + sb.append(" connectable=").append(settings.isConnectable()); + if (data != null) { + List uuids = data.getServiceUuids(); + if (!uuids.isEmpty()) { + sb.append(" services=").append(uuids); + } + } + return sb.toString(); + } + + public AsyncResource openL2capServer(boolean secure) { + final AsyncResource out = + new AsyncResource(); + stack.publishAppL2capServer( + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Integer psm) { + if (!out.isDone()) { + out.complete(new SimL2capServer(stack, + psm.intValue())); + } + } + + public void onError(BluetoothError error, + String message) { + if (!out.isDone()) { + out.error(new BluetoothException(error, message)); + } + } + }); + return out; + } + + public void shutdown() { + stopScan(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/StackEventListener.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/StackEventListener.java new file mode 100644 index 00000000000..b4edfa3f6e8 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/StackEventListener.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +/** + * Observes the simulated Bluetooth stack's event log. Every operation the + * stack executes -- connects, reads, notifications, scan sightings, + * scripted failures -- fires one entry, so a debug UI (the future + * Simulate → Bluetooth menu) can show a live trace. + * + *

Listeners are invoked on the stack's scheduler thread.

+ */ +public interface StackEventListener { + + /** + * One stack operation. + * + * @param op a stable operation key, e.g. {@code "connect"}, + * {@code "read"}, {@code "scan"} + * @param detail a human-readable description of the specific event + */ + void event(String op, String detail); +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualCentral.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualCentral.java new file mode 100644 index 00000000000..f93844b8e9c --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualCentral.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; + +/** + * A scripted remote central connected to the app's simulated GATT server, + * obtained via {@link SimulatedBluetoothStack#connectVirtualCentral()} (or + * {@link BluetoothSimulator#connectVirtualCentral()}). Use it from tests + * or the future debug UI to exercise the app's peripheral role: read and + * write its characteristics, subscribe to its notifications and observe + * {@code notifyValue} pushes. + * + *

All operations complete asynchronously on the stack's scheduler, + * subject to the configured latency and scripting.

+ */ +public final class VirtualCentral { + + /** Receives app {@code notifyValue} pushes while subscribed. */ + public interface NotificationListener { + void valueChanged(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, byte[] value); + } + + private final SimulatedBluetoothStack stack; + private final String address; + + VirtualCentral(SimulatedBluetoothStack stack, String address) { + this.stack = stack; + this.address = address; + } + + /** The synthetic address of this central. */ + public String getAddress() { + return address; + } + + /** + * Reads an app-served characteristic. Static values resolve directly; + * otherwise the app's {@code GattServerListener} receives a read + * request and its response resolves the callback. + */ + public void readCharacteristic(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, + SimulatedBluetoothStack.Callback callback) { + stack.centralRead(address, serviceUuid, characteristicUuid, callback); + } + + /** + * Writes an app-served characteristic; the app's + * {@code GattServerListener} receives the write request and its + * acknowledgement resolves the callback. + */ + public void writeCharacteristic(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, byte[] value, + SimulatedBluetoothStack.Callback callback) { + stack.centralWrite(address, serviceUuid, characteristicUuid, value, + callback); + } + + /** + * Subscribes to an app-served characteristic; the app observes a + * subscription change and subsequent {@code notifyValue} pushes reach + * the listener. + */ + public void subscribe(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, NotificationListener listener, + SimulatedBluetoothStack.Callback callback) { + stack.centralSubscribe(address, serviceUuid, characteristicUuid, + listener, true, callback); + } + + /** Removes this central's subscription to the characteristic. */ + public void unsubscribe(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, + SimulatedBluetoothStack.Callback callback) { + stack.centralSubscribe(address, serviceUuid, characteristicUuid, + null, false, callback); + } + + /** Disconnects this central; the app observes the disconnection. */ + public void disconnect() { + stack.centralDisconnect(address); + } + + public String toString() { + return "VirtualCentral(" + address + ")"; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualCharacteristic.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualCharacteristic.java new file mode 100644 index 00000000000..1f06d7b216b --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualCharacteristic.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; + +import java.util.ArrayList; +import java.util.List; + +/** + * A characteristic of a {@link VirtualService} on a simulated remote + * peripheral. Properties use the core + * {@link GattCharacteristic}{@code .PROPERTY_*} bits. The value is mutable + * so it can be edited live (from tests or the future Simulate menu UI) + * while an app is connected. + */ +public final class VirtualCharacteristic { + + private final BluetoothUuid uuid; + private final int properties; + private byte[] value; + private final ArrayList descriptors = + new ArrayList(); + + public VirtualCharacteristic(BluetoothUuid uuid, int properties) { + if (uuid == null) { + throw new IllegalArgumentException("uuid is required"); + } + this.uuid = uuid; + this.properties = properties; + } + + public VirtualCharacteristic(BluetoothUuid uuid, int properties, + byte[] value) { + this(uuid, properties); + setValue(value); + } + + public BluetoothUuid getUuid() { + return uuid; + } + + /** The {@link GattCharacteristic}{@code .PROPERTY_*} bitmask. */ + public int getProperties() { + return properties; + } + + public boolean canRead() { + return (properties & GattCharacteristic.PROPERTY_READ) != 0; + } + + public boolean canWrite() { + return (properties & (GattCharacteristic.PROPERTY_WRITE + | GattCharacteristic.PROPERTY_WRITE_WITHOUT_RESPONSE)) != 0; + } + + public boolean canNotifyOrIndicate() { + return (properties & (GattCharacteristic.PROPERTY_NOTIFY + | GattCharacteristic.PROPERTY_INDICATE)) != 0; + } + + /** Sets the characteristic value (copied); fluent. */ + public synchronized VirtualCharacteristic setValue(byte[] value) { + this.value = ByteArrays.copy(value); + return this; + } + + /** The current characteristic value (a copy); never {@code null}. */ + public synchronized byte[] getValue() { + return ByteArrays.copy(value); + } + + /** Adds a descriptor; fluent. */ + public synchronized VirtualCharacteristic withDescriptor( + VirtualDescriptor descriptor) { + if (descriptor != null) { + descriptors.add(descriptor); + } + return this; + } + + /** The descriptors in registration order (a snapshot). */ + public synchronized List getDescriptors() { + return new ArrayList(descriptors); + } + + /** The first descriptor with the given UUID, or {@code null}. */ + public synchronized VirtualDescriptor getDescriptor(BluetoothUuid uuid) { + int size = descriptors.size(); + for (int i = 0; i < size; i++) { + VirtualDescriptor d = descriptors.get(i); + if (d.getUuid().equals(uuid)) { + return d; + } + } + return null; + } + + public String toString() { + return "VirtualCharacteristic(" + uuid + ")"; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualDescriptor.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualDescriptor.java new file mode 100644 index 00000000000..97a63d7801a --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualDescriptor.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; + +/** + * A descriptor of a {@link VirtualCharacteristic} on a simulated remote + * peripheral. The value is mutable so it can be edited live (from tests or + * the future Simulate menu UI) while an app is connected. + */ +public final class VirtualDescriptor { + + private final BluetoothUuid uuid; + private byte[] value; + + public VirtualDescriptor(BluetoothUuid uuid) { + if (uuid == null) { + throw new IllegalArgumentException("uuid is required"); + } + this.uuid = uuid; + } + + public VirtualDescriptor(BluetoothUuid uuid, byte[] value) { + this(uuid); + setValue(value); + } + + public BluetoothUuid getUuid() { + return uuid; + } + + /** Sets the descriptor value (copied); fluent. */ + public synchronized VirtualDescriptor setValue(byte[] value) { + this.value = ByteArrays.copy(value); + return this; + } + + /** The current descriptor value (a copy); never {@code null}. */ + public synchronized byte[] getValue() { + return ByteArrays.copy(value); + } + + public String toString() { + return "VirtualDescriptor(" + uuid + ")"; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualPeripheral.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualPeripheral.java new file mode 100644 index 00000000000..4276b47ffba --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualPeripheral.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A scriptable remote Bluetooth device in the simulated stack: identity, + * advertisement payload, GATT database and stream endpoints. Build one + * fluently, register it via + * {@link SimulatedBluetoothStack#addPeripheral(VirtualPeripheral)} (or + * {@link BluetoothSimulator#addPeripheral(VirtualPeripheral)}) and the app + * under test can scan for it, connect and talk to it. + * + *

Everything except the address is mutable so a debug UI can edit the + * device live.

+ */ +public final class VirtualPeripheral { + + private final String address; + private String name; + private int rssi = -60; + private boolean connectable = true; + private boolean le = true; + private boolean classic; + private boolean bonded; + private final ArrayList services = + new ArrayList(); + private final ArrayList advertisedServiceUuids = + new ArrayList(); + private final LinkedHashMap manufacturerData = + new LinkedHashMap(); + private final LinkedHashMap serviceData = + new LinkedHashMap(); + private Integer txPower; + private final LinkedHashMap l2capEndpoints = + new LinkedHashMap(); + + public VirtualPeripheral(String address) { + if (address == null || address.length() == 0) { + throw new IllegalArgumentException("address is required"); + } + this.address = address; + } + + public String getAddress() { + return address; + } + + /** Sets the advertised/cached device name; fluent. */ + public synchronized VirtualPeripheral setName(String name) { + this.name = name; + return this; + } + + public synchronized String getName() { + return name; + } + + /** Sets the signal strength in dBm reported by scans and RSSI reads. */ + public synchronized VirtualPeripheral setRssi(int rssi) { + this.rssi = rssi; + return this; + } + + public synchronized int getRssi() { + return rssi; + } + + /** Whether the device accepts connections; defaults to {@code true}. */ + public synchronized VirtualPeripheral setConnectable(boolean connectable) { + this.connectable = connectable; + return this; + } + + public synchronized boolean isConnectable() { + return connectable; + } + + /** Whether the device speaks BLE; defaults to {@code true}. */ + public synchronized VirtualPeripheral setLe(boolean le) { + this.le = le; + return this; + } + + public synchronized boolean isLe() { + return le; + } + + /** + * Whether the device speaks classic (BR/EDR) Bluetooth -- classic + * discovery only reports flagged devices. Defaults to {@code false}. + */ + public synchronized VirtualPeripheral setClassic(boolean classic) { + this.classic = classic; + return this; + } + + public synchronized boolean isClassic() { + return classic; + } + + /** + * Pre-seeds the bond state: a peripheral registered with + * {@code bonded == true} shows up in the bonded-device listings without + * an explicit bond operation. + */ + public synchronized VirtualPeripheral setBonded(boolean bonded) { + this.bonded = bonded; + return this; + } + + public synchronized boolean isBonded() { + return bonded; + } + + /** Adds a GATT service to the device's database; fluent. */ + public synchronized VirtualPeripheral withService(VirtualService service) { + if (service != null) { + services.add(service); + } + return this; + } + + /** The GATT services in registration order (a snapshot). */ + public synchronized List getServices() { + return new ArrayList(services); + } + + /** The first service with the given UUID, or {@code null}. */ + public synchronized VirtualService getService(BluetoothUuid uuid) { + int size = services.size(); + for (int i = 0; i < size; i++) { + VirtualService s = services.get(i); + if (s.getUuid().equals(uuid)) { + return s; + } + } + return null; + } + + /** The characteristic at the given service/characteristic UUID pair. */ + public synchronized VirtualCharacteristic getCharacteristic( + BluetoothUuid serviceUuid, BluetoothUuid characteristicUuid) { + VirtualService s = getService(serviceUuid); + return s == null ? null : s.getCharacteristic(characteristicUuid); + } + + /** Adds a service UUID to the advertisement payload; fluent. */ + public synchronized VirtualPeripheral addAdvertisedServiceUuid( + BluetoothUuid uuid) { + if (uuid != null && !advertisedServiceUuids.contains(uuid)) { + advertisedServiceUuids.add(uuid); + } + return this; + } + + /** The advertised service UUIDs (a snapshot). */ + public synchronized List getAdvertisedServiceUuids() { + return new ArrayList(advertisedServiceUuids); + } + + /** Adds manufacturer data to the advertisement payload; fluent. */ + public synchronized VirtualPeripheral addManufacturerData(int companyId, + byte[] data) { + manufacturerData.put(Integer.valueOf(companyId), ByteArrays.copy(data)); + return this; + } + + /** The advertised manufacturer data, keyed by company id (a snapshot). */ + public synchronized Map getManufacturerData() { + return new LinkedHashMap(manufacturerData); + } + + /** Adds service data to the advertisement payload; fluent. */ + public synchronized VirtualPeripheral addServiceData(BluetoothUuid uuid, + byte[] data) { + if (uuid != null) { + serviceData.put(uuid, ByteArrays.copy(data)); + } + return this; + } + + /** The advertised service data, keyed by service UUID (a snapshot). */ + public synchronized Map getServiceData() { + return new LinkedHashMap(serviceData); + } + + /** + * Sets the advertised TX power in dBm; {@code null} (the default) + * omits it from the advertisement. Fluent. + */ + public synchronized VirtualPeripheral setTxPower(Integer txPower) { + this.txPower = txPower; + return this; + } + + /** The advertised TX power in dBm, or {@code null} when absent. */ + public synchronized Integer getTxPower() { + return txPower; + } + + /** + * Registers a virtual L2CAP endpoint on this device: when the app + * opens an L2CAP channel to the given PSM, the handler receives the + * remote side of the piped channel. Fluent. + */ + public synchronized VirtualPeripheral withL2capEndpoint(int psm, + SimStreamHandler handler) { + if (handler == null) { + l2capEndpoints.remove(Integer.valueOf(psm)); + } else { + l2capEndpoints.put(Integer.valueOf(psm), handler); + } + return this; + } + + /** The L2CAP endpoint handler at the given PSM, or {@code null}. */ + public synchronized SimStreamHandler getL2capEndpoint(int psm) { + return l2capEndpoints.get(Integer.valueOf(psm)); + } + + public String toString() { + return "VirtualPeripheral(" + address + ", " + getName() + ")"; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualService.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualService.java new file mode 100644 index 00000000000..6b5ea25cded --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/VirtualService.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; + +import java.util.ArrayList; +import java.util.List; + +/** + * A GATT service of a {@link VirtualPeripheral} in the simulated Bluetooth + * stack. + */ +public final class VirtualService { + + private final BluetoothUuid uuid; + private boolean primary = true; + private final ArrayList characteristics = + new ArrayList(); + + public VirtualService(BluetoothUuid uuid) { + if (uuid == null) { + throw new IllegalArgumentException("uuid is required"); + } + this.uuid = uuid; + } + + public BluetoothUuid getUuid() { + return uuid; + } + + /** Marks this service secondary/primary; fluent. Defaults to primary. */ + public synchronized VirtualService setPrimary(boolean primary) { + this.primary = primary; + return this; + } + + public synchronized boolean isPrimary() { + return primary; + } + + /** Adds a characteristic; fluent. */ + public synchronized VirtualService withCharacteristic( + VirtualCharacteristic characteristic) { + if (characteristic != null) { + characteristics.add(characteristic); + } + return this; + } + + /** The characteristics in registration order (a snapshot). */ + public synchronized List getCharacteristics() { + return new ArrayList(characteristics); + } + + /** The first characteristic with the given UUID, or {@code null}. */ + public synchronized VirtualCharacteristic getCharacteristic( + BluetoothUuid uuid) { + int size = characteristics.size(); + for (int i = 0; i < size; i++) { + VirtualCharacteristic c = characteristics.get(i); + if (c.getUuid().equals(uuid)) { + return c; + } + } + return null; + } + + public String toString() { + return "VirtualService(" + uuid + ")"; + } +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 789b7a3679e..2d17b76384b 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -5741,8 +5741,20 @@ public void readMutableSurface() { public LocationManager getLocationManager() { return new HTML5LocationManager(); } - - + + // Web Bluetooth backend. Constructed lazily on first use because the + // JSBluetooth constructor performs host-bridge round-trips (capability + // probe + event-callback registration) that must not run during + // implementation init. + private JSBluetooth bluetooth; + + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + if (bluetooth == null) { + bluetooth = new JSBluetooth(); + } + return bluetooth; + } private com.codename1.media.VideoIO videoIO; private boolean videoIOResolved; diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBlePeripheral.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBlePeripheral.java new file mode 100644 index 00000000000..2f15855d7d2 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBlePeripheral.java @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2012, 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.impl.html5; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionOptions; +import com.codename1.bluetooth.le.ConnectionPriority; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.io.JSONParser; +import com.codename1.util.AsyncResource; +import com.codename1.util.Base64; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A remote BLE peripheral backed by a main-thread Web Bluetooth + * {@code BluetoothDevice}/{@code BluetoothRemoteGATTServer} pair. The + * worker only holds the opaque device id ({@link #getAddress()}, the + * per-origin Web Bluetooth {@code device.id}) plus per-attribute integer + * handle ids (iids) assigned by the host during service discovery; every + * {@code do*} operation is one {@code __cn1_bt_*__} host round-trip run on + * a background thread so the EDT never blocks on the bridge. + * + *

Platform divergences (Web Bluetooth):

+ *
    + *
  • MTU is hidden -- {@code requestMtu} resolves immediately + * with 512 (the ATT maximum attribute length, which is also the Web + * Bluetooth per-write limit) and {@code getMtu()} reports 512 once + * connected.
  • + *
  • No RSSI on established connections -- {@code readRssi} + * fails with {@link BluetoothError#NOT_SUPPORTED}.
  • + *
  • Bonding is browser/OS managed -- {@code createBond} fails + * with {@link BluetoothError#NOT_SUPPORTED}.
  • + *
  • No L2CAP channels -- {@code openL2capChannel} fails with + * {@link BluetoothError#NOT_SUPPORTED}.
  • + *
  • Connection priority requests are accepted as successful no-ops + * (the browser manages connection parameters), mirroring iOS.
  • + *
+ */ +public class JSBlePeripheral extends BlePeripheral { + + private final String deviceId; + private String name; + private final Object gattLock = new Object(); + private final HashMap charsByIid = + new HashMap(); + private final HashMap descriptorIds = + new HashMap(); + + JSBlePeripheral(String deviceId, String name) { + this.deviceId = deviceId; + this.name = name; + } + + void setDeviceName(String name) { + if (name != null) { + this.name = name; + } + } + + public String getAddress() { + return deviceId; + } + + public String getName() { + return name; + } + + public DeviceType getType() { + return DeviceType.LE; + } + + // ------------------------------------------------------------------ + // events routed from JSBluetooth.dispatchNativeEvent + // ------------------------------------------------------------------ + + void onNativeDisconnected() { + if (getConnectionState() == ConnectionState.DISCONNECTING) { + // app-requested teardown completing + fireConnectionStateChanged(ConnectionState.DISCONNECTED, null); + } else { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, + new BluetoothException(BluetoothError.CONNECTION_LOST, + "The GATT server disconnected")); + } + } + + void onNativeNotification(int iid, byte[] value) { + GattCharacteristic c; + synchronized (gattLock) { + c = charsByIid.get(Integer.valueOf(iid)); + } + if (c != null) { + fireNotification(c, value == null ? new byte[0] : value); + } + } + + // ------------------------------------------------------------------ + // port SPI + // ------------------------------------------------------------------ + + protected void doConnect(ConnectionOptions options, + final AsyncResource out) { + JSBluetooth.async(new Runnable() { + public void run() { + Map res = JSBluetooth.parseResult( + JSBluetooth.nativeBtConnect(deviceId)); + if (out.isDone()) { + // timed out / cancelled while the round-trip ran + return; + } + if (JSBluetooth.isOk(res)) { + // Web Bluetooth hides the negotiated ATT MTU; 512 is + // its per-write maximum, report that. + setMtu(512); + out.complete(JSBlePeripheral.this); + } else { + out.error(JSBluetooth.toException(res, + BluetoothError.CONNECTION_FAILED, "connect")); + } + } + }); + } + + protected void doDisconnect() { + JSBluetooth.async(new Runnable() { + public void run() { + JSBluetooth.nativeBtDisconnect(deviceId); + // gattserverdisconnected also reports this transition; the + // state machine dedupes whichever arrives second. + fireConnectionStateChanged(ConnectionState.DISCONNECTED, null); + } + }); + } + + protected void doDiscoverServices( + final AsyncResource> out) { + JSBluetooth.async(new Runnable() { + public void run() { + Map res = JSBluetooth.parseResult( + JSBluetooth.nativeBtDiscoverServices(deviceId)); + if (out.isDone()) { + return; + } + if (!JSBluetooth.isOk(res)) { + out.error(JSBluetooth.toException(res, + BluetoothError.GATT_ERROR, "discoverServices")); + return; + } + try { + out.complete(buildGattDatabase( + JSONParser.asList(res.get("services")))); + } catch (RuntimeException ex) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "Malformed GATT database from the host bridge: " + + ex, ex)); + } + } + }); + } + + /** + * Maps the host's one-shot JSON GATT dump onto the core model, + * rebuilding the iid handle tables used by reads/writes/notifications. + */ + private List buildGattDatabase(List services) { + ArrayList list = new ArrayList(); + HashMap newChars = + new HashMap(); + HashMap newDescs = + new HashMap(); + if (services != null) { + int sn = services.size(); + for (int i = 0; i < sn; i++) { + Map sm = JSONParser.asMap(services.get(i)); + String sUuid = JSONParser.getString(sm, "uuid"); + if (sm == null || sUuid == null) { + continue; + } + GattService service = new GattService(this, + BluetoothUuid.fromString(sUuid), + JSONParser.getInt(sm, "primary", 1) == 1, + JSONParser.getInt(sm, "iid", 0)); + List chars = + JSONParser.asList(sm.get("characteristics")); + int cn = chars == null ? 0 : chars.size(); + for (int j = 0; j < cn; j++) { + Map cm = JSONParser.asMap(chars.get(j)); + String cUuid = JSONParser.getString(cm, "uuid"); + if (cm == null || cUuid == null) { + continue; + } + int cIid = JSONParser.getInt(cm, "iid", 0); + GattCharacteristic characteristic = new GattCharacteristic( + service, BluetoothUuid.fromString(cUuid), + JSONParser.getInt(cm, "properties", 0), cIid); + service.addCharacteristic(characteristic); + newChars.put(Integer.valueOf(cIid), characteristic); + List descs = + JSONParser.asList(cm.get("descriptors")); + int dn = descs == null ? 0 : descs.size(); + for (int k = 0; k < dn; k++) { + Map dm = + JSONParser.asMap(descs.get(k)); + String dUuid = JSONParser.getString(dm, "uuid"); + if (dm == null || dUuid == null) { + continue; + } + GattDescriptor descriptor = new GattDescriptor( + characteristic, + BluetoothUuid.fromString(dUuid)); + characteristic.addDescriptor(descriptor); + newDescs.put(descriptor, Integer.valueOf( + JSONParser.getInt(dm, "iid", 0))); + } + } + list.add(service); + } + } + synchronized (gattLock) { + charsByIid.clear(); + charsByIid.putAll(newChars); + descriptorIds.clear(); + descriptorIds.putAll(newDescs); + } + return list; + } + + protected void doReadCharacteristic(final GattCharacteristic c, + final AsyncResource out) { + JSBluetooth.async(new Runnable() { + public void run() { + completeValueOp(out, JSBluetooth.parseResult( + JSBluetooth.nativeBtReadCharacteristic(deviceId, + c.getInstanceId())), "readCharacteristic"); + } + }); + } + + protected void doWriteCharacteristic(final GattCharacteristic c, + final byte[] value, final boolean withResponse, + final AsyncResource out) { + JSBluetooth.async(new Runnable() { + public void run() { + completeBooleanOp(out, JSBluetooth.parseResult( + JSBluetooth.nativeBtWriteCharacteristic(deviceId, + c.getInstanceId(), value, withResponse)), + "writeCharacteristic"); + } + }); + } + + protected void doReadDescriptor(final GattDescriptor d, + final AsyncResource out) { + final Integer iid = descriptorId(d); + if (iid == null) { + out.error(unknownDescriptor()); + return; + } + JSBluetooth.async(new Runnable() { + public void run() { + completeValueOp(out, JSBluetooth.parseResult( + JSBluetooth.nativeBtReadDescriptor(deviceId, + iid.intValue())), "readDescriptor"); + } + }); + } + + protected void doWriteDescriptor(final GattDescriptor d, + final byte[] value, final AsyncResource out) { + final Integer iid = descriptorId(d); + if (iid == null) { + out.error(unknownDescriptor()); + return; + } + JSBluetooth.async(new Runnable() { + public void run() { + completeBooleanOp(out, JSBluetooth.parseResult( + JSBluetooth.nativeBtWriteDescriptor(deviceId, + iid.intValue(), value)), "writeDescriptor"); + } + }); + } + + protected void doSetNotifications(final GattCharacteristic c, + final boolean enable, boolean indication, + final AsyncResource out) { + // Web Bluetooth's startNotifications() arms whichever of + // notify/indicate the characteristic supports; the indication flag + // needs no separate handling. + JSBluetooth.async(new Runnable() { + public void run() { + completeBooleanOp(out, JSBluetooth.parseResult( + JSBluetooth.nativeBtSetNotifications(deviceId, + c.getInstanceId(), enable)), + enable ? "subscribe" : "unsubscribe"); + } + }); + } + + protected void doReadRssi(AsyncResource out) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Web Bluetooth does not expose the RSSI of a connection")); + } + + protected void doRequestMtu(int mtu, AsyncResource out) { + // Web Bluetooth hides MTU negotiation entirely; 512 bytes is the + // ATT maximum attribute length and the API's per-write cap, so + // resolve immediately with that (mirrors the iOS behavior of + // resolving with the OS-negotiated value). + out.complete(Integer.valueOf(512)); + } + + protected void doRequestConnectionPriority(ConnectionPriority priority, + AsyncResource out) { + // the browser owns the connection parameters -- successful no-op, + // mirroring iOS + out.complete(Boolean.TRUE); + } + + protected void doCreateBond(AsyncResource out) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "Bonding is managed by the browser/OS on Web Bluetooth")); + } + + protected void doOpenL2cap(int psm, boolean secure, + AsyncResource out) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "L2CAP channels are not available on Web Bluetooth")); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private Integer descriptorId(GattDescriptor d) { + synchronized (gattLock) { + return descriptorIds.get(d); + } + } + + private static BluetoothException unknownDescriptor() { + return new BluetoothException(BluetoothError.UNKNOWN, + "Unknown descriptor -- re-run discoverServices()"); + } + + private static void completeValueOp(AsyncResource out, + Map res, String operation) { + if (out.isDone()) { + return; + } + if (!JSBluetooth.isOk(res)) { + out.error(JSBluetooth.toException(res, BluetoothError.GATT_ERROR, + operation)); + return; + } + String b64 = JSONParser.getString(res, "value"); + out.complete(b64 == null || b64.length() == 0 + ? new byte[0] : Base64.decode(b64.getBytes())); + } + + private static void completeBooleanOp(AsyncResource out, + Map res, String operation) { + if (out.isDone()) { + return; + } + if (JSBluetooth.isOk(res)) { + out.complete(Boolean.TRUE); + } else { + out.error(JSBluetooth.toException(res, BluetoothError.GATT_ERROR, + operation)); + } + } +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBluetooth.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBluetooth.java new file mode 100644 index 00000000000..3c5d4a60750 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBluetooth.java @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2012, 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.impl.html5; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothPermission; +import com.codename1.io.JSONParser; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Web Bluetooth backend of the Codename One Bluetooth API for the + * JavaScript port. + * + *

Architecture: the translated Java runs in a Web Worker where + * {@code navigator.bluetooth} does not exist. Every {@code nativeBt*} + * method below is bound in {@code port.js} to a generator that performs an + * {@code invokeHostNative("__cn1_bt_*__", ...)} round-trip to the main + * thread, where {@code browser_bridge.js} owns the actual + * {@code BluetoothDevice}/{@code BluetoothRemoteGATTServer} handles keyed + * by opaque string/int ids. The worker side only ever holds those ids.

+ * + *

Every native returns a JSON string of the shape + * {@code {"ok":1, ...}} on success or + * {@code {"ok":0,"code":"","message":"..."}} on + * failure, so failures always surface as typed + * {@link BluetoothException}s -- including the case of a stale + * {@code browser_bridge.js} that predates the {@code __cn1_bt_*__} + * handlers (the port.js bindings catch the unhandled-host-call rejection + * and synthesize a {@code NOT_SUPPORTED} result instead of throwing).

+ * + *

Host-initiated events (adapter availability changes, GATT server + * disconnects, characteristic notifications) flow back through a single + * worker-callback registered at {@link #nativeBtInit()} time; port.js + * converts each payload to Java types and invokes + * {@link #dispatchNativeEvent(String, String, String, byte[])}.

+ * + *

Capabilities: only the BLE central role is available. Web Bluetooth + * exposes no peripheral mode, no classic Bluetooth and no L2CAP channels; + * those report {@code false} here and their operations fail fast with + * {@link BluetoothError#NOT_SUPPORTED} through the core fallbacks.

+ */ +public class JSBluetooth extends Bluetooth { + + private static JSBluetooth instance; + + private final JSBluetoothLE le; + private final HashMap peripherals = + new HashMap(); + private final boolean supported; + private AdapterState adapterState; + + public JSBluetooth() { + instance = this; + le = new JSBluetoothLE(this); + boolean sup = false; + AdapterState state = AdapterState.UNSUPPORTED; + try { + sup = nativeBtSupported() == 1; + if (sup) { + nativeBtInit(); + Map res = parseResult(nativeBtAdapterState()); + state = mapAdapterState(JSONParser.getString(res, "state")); + } + } catch (Throwable t) { + sup = false; + state = AdapterState.UNSUPPORTED; + } + supported = sup; + adapterState = state; + // Keep dispatchNativeEvent reachable for the translator's dead-code + // elimination: port.js invokes it by its mangled name at event time, + // which the reachability analysis cannot see. This call is a no-op + // (the "__" prefix is filtered out) but forms a real call site. + dispatchNativeEvent("__keepalive__", null, null, null); + } + + // ------------------------------------------------------------------ + // capability surface + // ------------------------------------------------------------------ + + public boolean isSupported() { + return supported; + } + + public boolean isLeSupported() { + return supported; + } + + // classic / peripheral / L2CAP deliberately stay at the base-class + // false: Web Bluetooth has no API for any of them. + + public AdapterState getAdapterState() { + if (!supported) { + return AdapterState.UNSUPPORTED; + } + return adapterState; + } + + public com.codename1.bluetooth.le.BluetoothLE getLE() { + return le; + } + + /** + * Web Bluetooth has no standalone permission grant -- authorization is + * per-device and happens inside the {@code requestDevice} chooser. As + * long as the API is available the SCAN/CONNECT permissions are + * considered granted; the chooser itself is the real gate. + */ + public boolean hasPermission(BluetoothPermission permission) { + return supported; + } + + public AsyncResource requestPermissions( + BluetoothPermission... permissions) { + AsyncResource r = new AsyncResource(); + r.complete(supported ? Boolean.TRUE : Boolean.FALSE); + return r; + } + + // ------------------------------------------------------------------ + // peripheral registry + // ------------------------------------------------------------------ + + JSBlePeripheral obtainPeripheral(String deviceId, String name) { + synchronized (peripherals) { + JSBlePeripheral p = peripherals.get(deviceId); + if (p == null) { + p = new JSBlePeripheral(deviceId, name); + peripherals.put(deviceId, p); + } else { + p.setDeviceName(name); + } + return p; + } + } + + JSBlePeripheral peripheralOrNull(String deviceId) { + if (deviceId == null) { + return null; + } + synchronized (peripherals) { + return peripherals.get(deviceId); + } + } + + // ------------------------------------------------------------------ + // event entry point from port.js + // ------------------------------------------------------------------ + + /** + * Invoked (via a spawned green thread) by the port.js worker-callback + * that browser_bridge.js posts host events into. Also called once from + * the constructor with a {@code "__keepalive__"} kind so the + * translator keeps this method alive. + * + *

Kinds: {@code adapter} (detail = AdapterState name), + * {@code disconnect} (deviceId), {@code notify} (deviceId, detail = + * characteristic instance id, value = notification bytes).

+ */ + public static void dispatchNativeEvent(String kind, String deviceId, + String detail, byte[] value) { + JSBluetooth bt = instance; + if (bt == null || kind == null || kind.startsWith("__")) { + return; + } + if ("adapter".equals(kind)) { + AdapterState s = mapAdapterState(detail); + bt.adapterState = s; + bt.fireAdapterStateChanged(s); + return; + } + JSBlePeripheral p = bt.peripheralOrNull(deviceId); + if (p == null) { + return; + } + if ("disconnect".equals(kind)) { + p.onNativeDisconnected(); + } else if ("notify".equals(kind)) { + int iid = parseIntSafe(detail); + if (iid >= 0) { + p.onNativeNotification(iid, value); + } + } + } + + // ------------------------------------------------------------------ + // shared helpers for the JS Bluetooth backend + // ------------------------------------------------------------------ + + /** Runs r off the caller's thread so blocking native round-trips never stall the EDT. */ + static void async(Runnable r) { + new Thread(r, "WebBluetooth").start(); + } + + static Map parseResult(String json) { + if (json == null || json.length() == 0) { + return null; + } + try { + return JSONParser.parseJSON(json); + } catch (IOException e) { + return null; + } + } + + static boolean isOk(Map result) { + return result != null && JSONParser.getInt(result, "ok", 0) == 1; + } + + static BluetoothException toException(Map result, + BluetoothError fallback, String operation) { + if (result == null) { + return new BluetoothException(fallback, operation + + " failed: no response from the Bluetooth host bridge"); + } + BluetoothError error = fallback; + String code = JSONParser.getString(result, "code"); + if (code != null) { + try { + error = BluetoothError.valueOf(code); + } catch (IllegalArgumentException ignored) { + } + } + String message = JSONParser.getString(result, "message"); + return new BluetoothException(error, operation + " failed" + + (message == null || message.length() == 0 + ? "" : ": " + message)); + } + + static AdapterState mapAdapterState(String name) { + if (name != null) { + try { + return AdapterState.valueOf(name); + } catch (IllegalArgumentException ignored) { + } + } + return AdapterState.UNKNOWN; + } + + static int parseIntSafe(String s) { + if (s == null) { + return -1; + } + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return -1; + } + } + + /** Minimal JSON string escaper for values embedded in request options. */ + static void appendJsonString(StringBuilder sb, String value) { + sb.append('"'); + int len = value.length(); + for (int i = 0; i < len; i++) { + char c = value.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 hex = Integer.toHexString(c); + sb.append("\\u"); + for (int p = hex.length(); p < 4; p++) { + sb.append('0'); + } + sb.append(hex); + } else { + sb.append(c); + } + break; + } + } + sb.append('"'); + } + + // ------------------------------------------------------------------ + // natives -- bound in port.js, backed by __cn1_bt_*__ host handlers + // in browser_bridge.js. All return the JSON result contract described + // in the class javadoc (except the two int-returning probes). + // ------------------------------------------------------------------ + + /** 1 when navigator.bluetooth.requestDevice exists on the main thread. */ + static native int nativeBtSupported(); + + /** Registers the host-to-worker event callback; 1 on success. */ + static native int nativeBtInit(); + + /** {"ok":1,"state":"POWERED_ON"|"POWERED_OFF"|"UNKNOWN"|"UNSUPPORTED"} */ + static native String nativeBtAdapterState(); + + /** + * Reads the (setter-only) criteria of a core ScanFilter directly off + * its translated field representation in the worker -- the core API + * deliberately exposes no getters to ports, but the JS port lives + * inside the translated object model where the fields are visible. + * Returns a JSON object with optional keys: service (uuid string), + * name, namePrefix, address, manufacturerId (int, -1 when unset), + * manufacturerData / manufacturerDataMask (int arrays). + */ + static native String nativeBtExtractScanFilter(Object filter); + + /** + * Runs the Web Bluetooth chooser. optionsJson mirrors the + * requestDevice options dictionary ({@code filters} / + * {@code acceptAllDevices} / {@code optionalServices}). Success: + * {"ok":1,"id":"...","name":"..."}. + */ + static native String nativeBtRequestDevice(String optionsJson); + + static native String nativeBtConnect(String deviceId); + + static native String nativeBtDisconnect(String deviceId); + + /** + * One-shot full GATT database discovery -- a single host round-trip + * returning {"ok":1,"services":[{uuid,primary,iid,characteristics: + * [{uuid,iid,properties,descriptors:[{uuid,iid}]}]}]}. The iid values + * are host-side handle ids used by the read/write/subscribe natives. + */ + static native String nativeBtDiscoverServices(String deviceId); + + /** {"ok":1,"value":""} */ + static native String nativeBtReadCharacteristic(String deviceId, int iid); + + static native String nativeBtWriteCharacteristic(String deviceId, int iid, + byte[] value, boolean withResponse); + + /** {"ok":1,"value":""} */ + static native String nativeBtReadDescriptor(String deviceId, int iid); + + static native String nativeBtWriteDescriptor(String deviceId, int iid, + byte[] value); + + static native String nativeBtSetNotifications(String deviceId, int iid, + boolean enable); +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBluetoothLE.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBluetoothLE.java new file mode 100644 index 00000000000..1bedca1ed6c --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSBluetoothLE.java @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2012, 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.impl.html5; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.bluetooth.le.ScanFilter; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.ScanSettings; +import com.codename1.io.JSONParser; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * BLE central role over Web Bluetooth. + * + *

Scanning divergence (important): Web Bluetooth has no + * free-running scan -- {@code navigator.bluetooth.requestLEScan} is + * flag-gated/experimental, so it is not used. The only production entry + * point is the chooser-based {@code requestDevice()}. This port therefore + * maps {@code startScan} onto the chooser:

+ * + *
    + *
  • Starting the first scan handle opens the browser's device chooser, + * built from the active {@link ScanSettings}' filters (serviceUuid + * filters become {@code services} entries, name/namePrefix map + * directly, manufacturerData maps to the {@code manufacturerData} + * filter; an address-only filter cannot be expressed and is + * dropped). With no usable filters the chooser runs with + * {@code acceptAllDevices}.
  • + *
  • The chooser returns at most one user-picked device. It is + * delivered as a single {@link ScanResult} with RSSI {@code 0} + * (unknown) and an {@link AdvertisementData} synthesized from the + * device name plus the filter criteria (Web Bluetooth does not + * expose the raw advertisement). After that the scan handle stays + * active but silent -- no further devices will ever arrive; call + * {@code stop()} when done.
  • + *
  • Cancelling the chooser fails the active handles with + * {@link BluetoothError#USER_CANCELED}.
  • + *
  • {@code requestDevice} requires a user gesture. Calling + * {@code startScan} from a button handler works out of the box (the + * forwarded click's transient activation usually still holds); + * otherwise the host defers the chooser to the next real user + * gesture and fails with {@code USER_CANCELED} when none arrives + * within 30 seconds -- it never hangs.
  • + *
+ * + *

GATT access requires declared services: Web Bluetooth only + * allows GATT access to services listed in the chooser's filters or + * {@code optionalServices}. This port collects the service UUIDs of ALL + * active filters into {@code optionalServices} automatically -- JS apps + * should always put a {@code serviceUuid} filter (or several) in their + * {@link ScanSettings} or later {@code discoverServices()} will come back + * empty.

+ */ +public class JSBluetoothLE extends BluetoothLE { + + private final JSBluetooth bt; + private final Object genLock = new Object(); + private int scanGeneration; + + JSBluetoothLE(JSBluetooth bt) { + this.bt = bt; + } + + /** + * Re-obtains a peripheral seen earlier in this session (Web Bluetooth + * device ids are per-origin and only resolvable after a chooser + * grant); returns {@code null} for addresses this session never saw. + */ + public BlePeripheral getPeripheral(String address) { + return bt.peripheralOrNull(address); + } + + // ------------------------------------------------------------------ + // scan SPI -- chooser-based, see class javadoc + // ------------------------------------------------------------------ + + protected boolean isScanSupported() { + return bt.isLeSupported(); + } + + protected void startPlatformScan() { + final int gen; + synchronized (genLock) { + gen = ++scanGeneration; + } + // The chooser round-trip blocks its green thread (potentially for + // as long as the user stares at the dialog); never on the EDT. + JSBluetooth.async(new Runnable() { + public void run() { + runChooser(gen); + } + }); + } + + protected void stopPlatformScan() { + // A visible chooser cannot be programmatically dismissed; bump the + // generation so a late result is dropped instead of delivered to a + // scan session that already ended. + synchronized (genLock) { + scanGeneration++; + } + } + + private boolean isCurrentGeneration(int gen) { + synchronized (genLock) { + return gen == scanGeneration; + } + } + + private void runChooser(int gen) { + List> filters = extractActiveFilters(); + String optionsJson = buildRequestOptionsJson(filters); + Map res = JSBluetooth.parseResult( + JSBluetooth.nativeBtRequestDevice(optionsJson)); + if (!isCurrentGeneration(gen)) { + return; + } + if (!JSBluetooth.isOk(res)) { + fireScanFailed(JSBluetooth.toException(res, + BluetoothError.SCAN_FAILED, "requestDevice")); + return; + } + String id = JSONParser.getString(res, "id"); + if (id == null) { + fireScanFailed(new BluetoothException(BluetoothError.UNKNOWN, + "requestDevice returned no device id")); + return; + } + String name = JSONParser.getString(res, "name"); + JSBlePeripheral p = bt.obtainPeripheral(id, name); + fireScanResult(new ScanResult(p, 0, synthesizeAdvertisement(name, + filters), true, System.currentTimeMillis())); + } + + /** + * Reads the criteria of every filter of every active scan handle via + * the port-private field extractor (the core ScanFilter has no + * getters). + */ + private List> extractActiveFilters() { + ArrayList> out = + new ArrayList>(); + List active = getActiveScanSettings(); + int n = active.size(); + for (int i = 0; i < n; i++) { + List filters = active.get(i).getFilters(); + int fn = filters.size(); + for (int j = 0; j < fn; j++) { + Map extracted = JSBluetooth.parseResult( + JSBluetooth.nativeBtExtractScanFilter(filters.get(j))); + if (extracted != null) { + out.add(extracted); + } + } + } + return out; + } + + /** + * Builds the requestDevice options dictionary as a JSON string: + * per-filter {@code services}/{@code name}/{@code namePrefix}/ + * {@code manufacturerData} entries, plus every filter service UUID in + * {@code optionalServices} (required by Web Bluetooth for later GATT + * access). No usable filters → {@code acceptAllDevices:true}. + */ + private String buildRequestOptionsJson(List> filters) { + StringBuilder entries = new StringBuilder(); + ArrayList optionalServices = new ArrayList(); + int count = 0; + int n = filters.size(); + for (int i = 0; i < n; i++) { + Map f = filters.get(i); + StringBuilder entry = new StringBuilder(); + String service = JSONParser.getString(f, "service"); + if (service != null) { + entry.append("\"services\":["); + JSBluetooth.appendJsonString(entry, service); + entry.append(']'); + if (!optionalServices.contains(service)) { + optionalServices.add(service); + } + } + String name = JSONParser.getString(f, "name"); + if (name != null) { + if (entry.length() > 0) { + entry.append(','); + } + entry.append("\"name\":"); + JSBluetooth.appendJsonString(entry, name); + } + String namePrefix = JSONParser.getString(f, "namePrefix"); + if (namePrefix != null) { + if (entry.length() > 0) { + entry.append(','); + } + entry.append("\"namePrefix\":"); + JSBluetooth.appendJsonString(entry, namePrefix); + } + int manufacturerId = JSONParser.getInt(f, "manufacturerId", -1); + if (manufacturerId >= 0) { + if (entry.length() > 0) { + entry.append(','); + } + entry.append("\"manufacturerData\":[{\"companyIdentifier\":") + .append(manufacturerId); + appendIntArray(entry, "dataPrefix", + JSONParser.asList(f.get("manufacturerData"))); + appendIntArray(entry, "mask", + JSONParser.asList(f.get("manufacturerDataMask"))); + entry.append("}]"); + } + // an address-only filter cannot be expressed in Web Bluetooth + // and is dropped (the entry stays empty) + if (entry.length() > 0) { + if (count > 0) { + entries.append(','); + } + entries.append('{').append(entry).append('}'); + count++; + } + } + StringBuilder out = new StringBuilder(); + out.append('{'); + if (count > 0) { + out.append("\"filters\":[").append(entries).append(']'); + } else { + out.append("\"acceptAllDevices\":true"); + } + int os = optionalServices.size(); + if (os > 0) { + out.append(",\"optionalServices\":["); + for (int i = 0; i < os; i++) { + if (i > 0) { + out.append(','); + } + JSBluetooth.appendJsonString(out, optionalServices.get(i)); + } + out.append(']'); + } + out.append('}'); + return out.toString(); + } + + private static void appendIntArray(StringBuilder sb, String key, + List values) { + if (values == null || values.isEmpty()) { + return; + } + sb.append(",\"").append(key).append("\":["); + int n = values.size(); + for (int i = 0; i < n; i++) { + if (i > 0) { + sb.append(','); + } + Object v = values.get(i); + sb.append(v instanceof Number ? ((Number) v).intValue() & 0xFF : 0); + } + sb.append(']'); + } + + /** + * Web Bluetooth exposes no advertisement payload from the chooser, so + * the delivered ScanResult carries a synthesized AdvertisementData: + * the device name plus the criteria of the active filters (service + * UUIDs and manufacturer prefixes) -- exactly enough for the core + * demultiplexer's filter matching to accept the result the app just + * filtered for. + */ + private AdvertisementData synthesizeAdvertisement(String name, + List> filters) { + AdvertisementData ad = new AdvertisementData(); + if (name != null) { + ad.setLocalName(name); + } + int n = filters.size(); + for (int i = 0; i < n; i++) { + Map f = filters.get(i); + String service = JSONParser.getString(f, "service"); + if (service != null) { + try { + ad.addServiceUuid(BluetoothUuid.fromString(service)); + } catch (IllegalArgumentException ignored) { + } + } + int manufacturerId = JSONParser.getInt(f, "manufacturerId", -1); + if (manufacturerId >= 0) { + ad.addManufacturerData(manufacturerId, + toBytes(JSONParser.asList(f.get("manufacturerData")))); + } + } + return ad; + } + + private static byte[] toBytes(List values) { + if (values == null) { + return new byte[0]; + } + int n = values.size(); + byte[] out = new byte[n]; + for (int i = 0; i < n; i++) { + Object v = values.get(i); + out[i] = v instanceof Number ? (byte) ((Number) v).intValue() : 0; + } + return out; + } +} diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index 00effd2e4ad..751b0dfe3a7 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -5775,3 +5775,346 @@ bindCiFallback("BrowserComponent.access102InternalAssignFix", [ } return peerComponent; }); + +// ============================ Web Bluetooth ============================ +// Worker-side natives for com.codename1.impl.html5.JSBluetooth. The Java +// app runs in the worker where navigator.bluetooth does not exist, so every +// operation is an invokeHostNative round-trip to the __cn1_bt_*__ handlers +// in browser_bridge.js (which owns the BluetoothDevice / GATT handle +// tables). browser_bridge.js ships from the translator artifact while +// port.js ships fresh from source, so every binding here is NULL-SAFE +// against an older host bundle that lacks the __cn1_bt_*__ handlers: an +// unhandled-host-call rejection is caught and converted into the typed +// {ok:0, code:"NOT_SUPPORTED"} result contract instead of throwing raw. + +function cn1BtJavaString(value) { + return jvm.createStringLiteral(String(value)); +} + +// One host round-trip; always resolves to a plain result object (never +// throws) so the Java side can rely on the {ok, code, message} contract. +function* cn1BtHostCall(symbol, args) { + if (!jvm || typeof jvm.invokeHostNative !== "function") { + return { ok: 0, code: "NOT_SUPPORTED", message: "Bluetooth host bridge unavailable" }; + } + let res; + try { + res = yield jvm.invokeHostNative(symbol, args); + } catch (err) { + // Older browser_bridge.js without the Bluetooth handlers rejects with + // "Unhandled host call ..." -- surface it as a typed unsupported error. + return { + ok: 0, code: "NOT_SUPPORTED", + message: "Bluetooth host call " + symbol + " failed: " + + (err && err.message ? err.message : String(err)) + }; + } + if (res == null || typeof res !== "object") { + return { ok: 0, code: "UNKNOWN", message: "Empty response from " + symbol }; + } + return res; +} + +// Same, but marshals the result object to a Java JSON string -- the shape +// all the String-returning JSBluetooth natives use. +function* cn1BtHostCallJson(symbol, args) { + const res = yield* cn1BtHostCall(symbol, args); + let json; + try { + json = JSON.stringify(res); + } catch (_err) { + json = "{\"ok\":0,\"code\":\"UNKNOWN\",\"message\":\"unserializable host response\"}"; + } + return cn1BtJavaString(json); +} + +// Java byte[] (plain JS array with byte metadata, possibly signed values) +// -> structured-cloneable Uint8Array for the host. +function cn1BtToUint8Array(bytes) { + if (!bytes || typeof bytes.length !== "number") { + return new Uint8Array(0); + } + const n = bytes.length | 0; + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) { + out[i] = bytes[i] & 0xff; + } + return out; +} + +// -------------------------------------------------------------------- +// host -> worker event pump +// -------------------------------------------------------------------- +// browser_bridge.js posts {kind, deviceId, detail, bytes} payloads through +// the standard worker-callback channel; this dispatches them into the +// translated static JSBluetooth.dispatchNativeEvent(String,String,String, +// byte[]). The callback token is minted directly against jvm's +// worker-callback table (rather than passing a bare function through +// toHostTransferArg) so it works even when DOM event forwarding is +// disabled (cn1DisableEventForwarding=1 in the screenshot harness). + +const cn1BtDispatchEventCandidates = [ + "cn1_com_codename1_impl_html5_JSBluetooth_dispatchNativeEvent_java_lang_String_java_lang_String_java_lang_String_byte_1ARRAY", + "cn1_com_codename1_impl_html5_JSBluetooth_dispatchNativeEvent_java_lang_String_java_lang_String_java_lang_String_byte_1ARRAY_R_void", + "cn1_com_codename1_impl_html5_JSBluetooth_dispatchNativeEvent___java_lang_String_java_lang_String_java_lang_String_byte_1ARRAY", + "cn1_com_codename1_impl_html5_JSBluetooth_dispatchNativeEvent___java_lang_String_java_lang_String_java_lang_String_byte_1ARRAY_R_void" +]; +let cn1BtDispatchMissingLogged = false; + +function cn1BtResolveDispatch() { + for (let i = 0; i < cn1BtDispatchEventCandidates.length; i++) { + const name = cn1BtDispatchEventCandidates[i]; + if (typeof global[name] === "function") { + return global[name]; + } + if (jvm && jvm.translatedMethods && typeof jvm.translatedMethods[name] === "function") { + return jvm.translatedMethods[name]; + } + } + return null; +} + +function cn1BtDeliverEvent(payload) { + const fn = cn1BtResolveDispatch(); + if (!fn) { + if (!cn1BtDispatchMissingLogged) { + cn1BtDispatchMissingLogged = true; + if (global.console && typeof global.console.warn === "function") { + global.console.warn("PARPAR:bt-event-dropped:JSBluetooth.dispatchNativeEvent not found"); + } + } + return; + } + const kind = payload && payload.kind != null ? cn1BtJavaString(payload.kind) : null; + const deviceId = payload && payload.deviceId != null ? cn1BtJavaString(payload.deviceId) : null; + const detail = payload && payload.detail != null ? cn1BtJavaString(payload.detail) : null; + let bytes = null; + const raw = payload && payload.bytes; + if (raw && typeof raw.length === "number") { + const n = raw.length | 0; + bytes = jvm.newArray(n, "JAVA_BYTE", 1); + for (let i = 0; i < n; i++) { + const v = raw[i] & 0xff; + bytes[i] = v > 127 ? v - 256 : v; + } + } + try { + jvm.spawn(null, (function*() { + yield* cn1_ivAdapt(fn(kind, deviceId, detail, bytes)); + })()); + } catch (err) { + if (global.console && typeof global.console.error === "function") { + global.console.error("PARPAR:bt-event-error:" + (err && err.message ? err.message : String(err))); + } + } +} + +function cn1BtMintEventCallbackToken() { + try { + if (jvm && jvm.workerCallbacks && typeof jvm.nextWorkerCallbackId === "number") { + const id = jvm.nextWorkerCallbackId++; + jvm.workerCallbacks[id] = cn1BtDeliverEvent; + return { __cn1WorkerCallback: id }; + } + } catch (_err) {} + return null; +} + +// -------------------------------------------------------------------- +// native bindings +// -------------------------------------------------------------------- + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtSupported__R_int", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtSupported___R_int" +], function*() { + const res = yield* cn1BtHostCall("__cn1_bt_support__", []); + return res.ok === 1 && res.supported ? 1 : 0; +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtInit__R_int", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtInit___R_int" +], function*() { + const token = cn1BtMintEventCallbackToken(); + if (!token) { + // no event channel (very old runtime) -- request/read/write still work, + // notifications and disconnect events just won't stream + return 0; + } + const res = yield* cn1BtHostCall("__cn1_bt_set_event_callback__", [token]); + return res.ok === 1 ? 1 : 0; +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtAdapterState__R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtAdapterState___R_java_lang_String" +], function*() { + return yield* cn1BtHostCallJson("__cn1_bt_adapter_state__", []); +}); + +// Reads the (setter-only) ScanFilter criteria straight off the translated +// object's fields -- worker-local, no host call. Long fields (the +// BluetoothUuid halves) may be BigInt (exact-longs runtime), the legacy +// {__l:1,l,h} record, or a plain number; all three are handled. +function cn1BtLongToUnsignedHex16(v) { + try { + if (typeof v === "bigint") { + return BigInt.asUintN(64, v).toString(16).padStart(16, "0"); + } + if (v && typeof v === "object" && v.__l === 1) { + return (v.h >>> 0).toString(16).padStart(8, "0") + + (v.l >>> 0).toString(16).padStart(8, "0"); + } + if (typeof v === "number" && isFinite(v)) { + return BigInt.asUintN(64, BigInt(Math.trunc(v))).toString(16).padStart(16, "0"); + } + } catch (_err) {} + return null; +} + +function cn1BtUuidFieldToString(uuidObj) { + if (!uuidObj) { + return null; + } + const msb = cn1BtLongToUnsignedHex16(uuidObj.cn1_com_codename1_bluetooth_BluetoothUuid_msb); + const lsb = cn1BtLongToUnsignedHex16(uuidObj.cn1_com_codename1_bluetooth_BluetoothUuid_lsb); + if (msb == null || lsb == null) { + return null; + } + const hex = msb + lsb; + return hex.substring(0, 8) + "-" + hex.substring(8, 12) + "-" + + hex.substring(12, 16) + "-" + hex.substring(16, 20) + "-" + + hex.substring(20); +} + +function cn1BtJavaBytesToPlainArray(bytes) { + if (!bytes || typeof bytes.length !== "number") { + return null; + } + const n = bytes.length | 0; + const out = new Array(n); + for (let i = 0; i < n; i++) { + out[i] = bytes[i] & 0xff; + } + return out; +} + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtExtractScanFilter_java_lang_Object_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtExtractScanFilter___java_lang_Object_R_java_lang_String" +], function(filter) { + const out = {}; + try { + if (filter) { + const service = cn1BtUuidFieldToString(filter.cn1_com_codename1_bluetooth_le_ScanFilter_serviceUuid); + if (service) { + out.service = service; + } + const name = filter.cn1_com_codename1_bluetooth_le_ScanFilter_name; + if (name != null) { + out.name = jvm.toNativeString(name); + } + const namePrefix = filter.cn1_com_codename1_bluetooth_le_ScanFilter_namePrefix; + if (namePrefix != null) { + out.namePrefix = jvm.toNativeString(namePrefix); + } + const address = filter.cn1_com_codename1_bluetooth_le_ScanFilter_address; + if (address != null) { + out.address = jvm.toNativeString(address); + } + const manufacturerId = filter.cn1_com_codename1_bluetooth_le_ScanFilter_manufacturerId; + out.manufacturerId = typeof manufacturerId === "number" ? manufacturerId | 0 : -1; + const data = cn1BtJavaBytesToPlainArray(filter.cn1_com_codename1_bluetooth_le_ScanFilter_manufacturerData); + if (data) { + out.manufacturerData = data; + } + const mask = cn1BtJavaBytesToPlainArray(filter.cn1_com_codename1_bluetooth_le_ScanFilter_manufacturerDataMask); + if (mask) { + out.manufacturerDataMask = mask; + } + } + } catch (_err) { + // best effort -- an unreadable filter degrades to acceptAllDevices + } + return cn1BtJavaString(JSON.stringify(out)); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtRequestDevice_java_lang_String_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtRequestDevice___java_lang_String_R_java_lang_String" +], function*(optionsJson) { + let options = {}; + try { + options = JSON.parse(jvm.toNativeString(optionsJson)); + } catch (_err) { + options = { acceptAllDevices: true }; + } + return yield* cn1BtHostCallJson("__cn1_bt_request_device__", [options]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtConnect_java_lang_String_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtConnect___java_lang_String_R_java_lang_String" +], function*(deviceId) { + return yield* cn1BtHostCallJson("__cn1_bt_connect__", [{ id: jvm.toNativeString(deviceId) }]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtDisconnect_java_lang_String_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtDisconnect___java_lang_String_R_java_lang_String" +], function*(deviceId) { + return yield* cn1BtHostCallJson("__cn1_bt_disconnect__", [{ id: jvm.toNativeString(deviceId) }]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtDiscoverServices_java_lang_String_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtDiscoverServices___java_lang_String_R_java_lang_String" +], function*(deviceId) { + return yield* cn1BtHostCallJson("__cn1_bt_discover__", [{ id: jvm.toNativeString(deviceId) }]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtReadCharacteristic_java_lang_String_int_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtReadCharacteristic___java_lang_String_int_R_java_lang_String" +], function*(deviceId, iid) { + return yield* cn1BtHostCallJson("__cn1_bt_read_char__", + [{ id: jvm.toNativeString(deviceId), iid: iid | 0 }]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtWriteCharacteristic_java_lang_String_int_byte_1ARRAY_boolean_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtWriteCharacteristic___java_lang_String_int_byte_1ARRAY_boolean_R_java_lang_String" +], function*(deviceId, iid, value, withResponse) { + return yield* cn1BtHostCallJson("__cn1_bt_write_char__", [{ + id: jvm.toNativeString(deviceId), iid: iid | 0, + value: cn1BtToUint8Array(value), withResponse: !!withResponse + }]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtReadDescriptor_java_lang_String_int_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtReadDescriptor___java_lang_String_int_R_java_lang_String" +], function*(deviceId, iid) { + return yield* cn1BtHostCallJson("__cn1_bt_read_desc__", + [{ id: jvm.toNativeString(deviceId), iid: iid | 0 }]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtWriteDescriptor_java_lang_String_int_byte_1ARRAY_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtWriteDescriptor___java_lang_String_int_byte_1ARRAY_R_java_lang_String" +], function*(deviceId, iid, value) { + return yield* cn1BtHostCallJson("__cn1_bt_write_desc__", [{ + id: jvm.toNativeString(deviceId), iid: iid | 0, + value: cn1BtToUint8Array(value) + }]); +}); + +bindNative([ + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtSetNotifications_java_lang_String_int_boolean_R_java_lang_String", + "cn1_com_codename1_impl_html5_JSBluetooth_nativeBtSetNotifications___java_lang_String_int_boolean_R_java_lang_String" +], function*(deviceId, iid, enable) { + return yield* cn1BtHostCallJson("__cn1_bt_set_notify__", [{ + id: jvm.toNativeString(deviceId), iid: iid | 0, enable: !!enable + }]); +}); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_subprocess.c b/Ports/LinuxPort/nativeSources/cn1_linux_subprocess.c new file mode 100644 index 00000000000..3557da88b04 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_subprocess.c @@ -0,0 +1,303 @@ +/* + * 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. + */ + +/* + * Native child-process (subprocess) transport for the Codename One Linux port. + * The native ports have full java.lang.Thread but no java.lang.Process / + * ProcessBuilder (absent from vm/JavaAPI), so a Java NativeSubprocessTransport + * that needs to spawn a helper binary (e.g. the Rust Bluetooth helper) and talk + * to it over stdio drives these primitives instead. + * + * A subprocess peer is a small heap struct holding the child pid and the + * parent-side ends of two pipes wired to the child's stdin/stdout (stderr is + * inherited). It mirrors the socket bridge (cn1_linux_socket.c) exactly: + * blocking read/write are bracketed with CN1_YIELD_THREAD / CN1_RESUME_THREAD so + * the concurrent GC is not stalled by a thread parked in a syscall, and the + * byte[] passed to a blocking read/write is anchored with CN1_PROC_KEEP_ALIVE so + * the conservative GC cannot sweep it mid-I/O. + */ + +#define _GNU_SOURCE +#include "cn1_linux.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char** environ; +extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); + +/* Forces a read/write buffer object to stay live across a parked blocking call so + * the conservative GC can't sweep it mid-I/O -- identical idiom and rationale to + * CN1_SOCKET_KEEP_ALIVE in cn1_linux_socket.c (an address-escape asm that cannot + * be optimized away and shares no cross-thread state). */ +#define CN1_PROC_KEEP_ALIVE(obj) __asm__ __volatile__("" : : "r"(obj)) + +typedef struct { + pid_t pid; + int stdinFd; /* parent write end -> child stdin (-1 once closed) */ + int stdoutFd; /* parent read end <- child stdout (-1 once closed) */ + int exited; /* 1 once the child has been reaped */ + int exitStatus; +} CN1Subprocess; + +/* Non-blocking reap: if the child has exited, records it and returns 1. */ +static int cn1ProcReap(CN1Subprocess* p) { + int status; + pid_t r; + if (p->exited) { + return 1; + } + r = waitpid(p->pid, &status, WNOHANG); + if (r == p->pid) { + p->exited = 1; + p->exitStatus = status; + return 1; + } + /* r == 0 -> still running; r < 0 (ECHILD etc.) -> treat as gone. */ + if (r < 0) { + p->exited = 1; + return 1; + } + return 0; +} + +JAVA_LONG com_codename1_impl_linux_LinuxNative_procSpawn___java_lang_String_1ARRAY_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT argv) { + int argc; + int i; + char** cargv = 0; + JAVA_OBJECT* elements; + int inPipe[2]; + int outPipe[2]; + posix_spawn_file_actions_t actions; + int actionsInited = 0; + pid_t pid = 0; + CN1Subprocess* p; + + if (argv == JAVA_NULL) { + return 0; + } + argc = (int) (*(JAVA_ARRAY) argv).length; + if (argc <= 0) { + return 0; + } + elements = (JAVA_OBJECT*) (*(JAVA_ARRAY) argv).data; + + /* Build a NULL-terminated char** from the String[]. stringToUTF8 returns a + * per-thread reusable buffer that the next call overwrites, so each element + * MUST be strdup'd immediately after conversion. */ + cargv = (char**) calloc((size_t) argc + 1, sizeof(char*)); + if (!cargv) { + return 0; + } + for (i = 0; i < argc; i++) { + const char* s = elements[i] == JAVA_NULL ? "" : stringToUTF8(threadStateData, elements[i]); + cargv[i] = strdup(s ? s : ""); + if (!cargv[i]) { + goto fail; + } + } + cargv[argc] = 0; + + if (pipe(inPipe) != 0) { + goto fail; + } + if (pipe(outPipe) != 0) { + close(inPipe[0]); + close(inPipe[1]); + goto fail; + } + + if (posix_spawn_file_actions_init(&actions) != 0) { + close(inPipe[0]); + close(inPipe[1]); + close(outPipe[0]); + close(outPipe[1]); + goto fail; + } + actionsInited = 1; + /* Child: stdin <- inPipe read end, stdout -> outPipe write end. The parent + * ends (inPipe[1], outPipe[0]) are closed in the child so it sees EOF when + * the parent closes its side. stderr is inherited. */ + posix_spawn_file_actions_adddup2(&actions, inPipe[0], STDIN_FILENO); + posix_spawn_file_actions_adddup2(&actions, outPipe[1], STDOUT_FILENO); + posix_spawn_file_actions_addclose(&actions, inPipe[0]); + posix_spawn_file_actions_addclose(&actions, inPipe[1]); + posix_spawn_file_actions_addclose(&actions, outPipe[0]); + posix_spawn_file_actions_addclose(&actions, outPipe[1]); + + /* posix_spawnp so argv[0] is resolved against PATH, like execvp. */ + if (posix_spawnp(&pid, cargv[0], &actions, 0, cargv, environ) != 0) { + posix_spawn_file_actions_destroy(&actions); + close(inPipe[0]); + close(inPipe[1]); + close(outPipe[0]); + close(outPipe[1]); + goto fail; + } + posix_spawn_file_actions_destroy(&actions); + actionsInited = 0; + + /* Parent keeps the write end of stdin and the read end of stdout; the + * child-side ends are ours to close now. */ + close(inPipe[0]); + close(outPipe[1]); + + for (i = 0; i < argc; i++) { + free(cargv[i]); + } + free(cargv); + + p = (CN1Subprocess*) calloc(1, sizeof(CN1Subprocess)); + if (!p) { + /* Spawn succeeded but we can't track it: terminate to avoid an orphan. */ + kill(pid, SIGTERM); + close(inPipe[1]); + close(outPipe[0]); + return 0; + } + p->pid = pid; + p->stdinFd = inPipe[1]; + p->stdoutFd = outPipe[0]; + p->exited = 0; + return (JAVA_LONG) (intptr_t) p; + +fail: + if (actionsInited) { + posix_spawn_file_actions_destroy(&actions); + } + if (cargv) { + for (i = 0; i < argc; i++) { + free(cargv[i]); + } + free(cargv); + } + return 0; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_procRead___long_byte_1ARRAY_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) handle; + char* data; + ssize_t n; + if (!p || p->stdoutFd < 0 || buffer == JAVA_NULL || length <= 0) { + return -1; + } + data = (char*) (*(JAVA_ARRAY) buffer).data; + /* read() blocks until the helper writes (or its stdout closes). Park the + * thread across the syscall so the concurrent GC -- which waits for every + * lightweight thread to pause before it can traverse stacks -- is not + * deadlocked (see cn1_linux_socket.c socketRead). */ + CN1_YIELD_THREAD; + n = read(p->stdoutFd, data + offset, (size_t) length); + CN1_RESUME_THREAD; + /* Keep the buffer array reachable across the parked read: only `data` (an + * interior pointer) is used, so the optimizer may drop `buffer` and the GC, + * scanning this parked thread, can sweep it mid-read (use-after-free). */ + CN1_PROC_KEEP_ALIVE(buffer); + if (n < 0) { + return -1; + } + return (JAVA_INT) n; /* 0 == EOF (child closed stdout / exited) */ +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_procWrite___long_byte_1ARRAY_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) handle; + char* data; + int written = 0; + if (!p || p->stdinFd < 0 || buffer == JAVA_NULL || length <= 0) { + return -1; + } + data = (char*) (*(JAVA_ARRAY) buffer).data; + /* Ignore SIGPIPE for this write so a dead helper reports EPIPE instead of + * killing the process; the return value already surfaces the error. */ + signal(SIGPIPE, SIG_IGN); + /* Yield across the (potentially blocking) write loop -- a thread parked in + * write() must not stall a GC mark (see socketWrite). */ + CN1_YIELD_THREAD; + while (written < length) { + ssize_t n = write(p->stdinFd, data + offset + written, (size_t) (length - written)); + if (n <= 0) { + CN1_RESUME_THREAD; + /* Keep the array reachable across the parked write, exactly like + * socketWrite: only `data` (an interior pointer) is used after the + * yield, so the GC could otherwise sweep the array WHILE write() is + * still reading from it. */ + CN1_PROC_KEEP_ALIVE(buffer); + return written > 0 ? written : -1; + } + written += (int) n; + } + CN1_RESUME_THREAD; + CN1_PROC_KEEP_ALIVE(buffer); + return written; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_procCloseStdin___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) handle; + if (!p) { + return; + } + if (p->stdinFd >= 0) { + close(p->stdinFd); + p->stdinFd = -1; + } +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_procClose___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) handle; + if (!p) { + return; + } + if (p->stdinFd >= 0) { + close(p->stdinFd); + p->stdinFd = -1; + } + if (p->stdoutFd >= 0) { + close(p->stdoutFd); + p->stdoutFd = -1; + } + if (!cn1ProcReap(p)) { + /* Still running: ask it to stop, then reap so it does not become a + * zombie. A brief blocking waitpid is acceptable here (close is not on a + * hot path); park across it for the GC. */ + kill(p->pid, SIGTERM); + CN1_YIELD_THREAD; + waitpid(p->pid, &p->exitStatus, 0); + CN1_RESUME_THREAD; + p->exited = 1; + } + free(p); +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_procIsAlive___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) handle; + if (!p) { + return 0; + } + return cn1ProcReap(p) ? 0 : 1; +} diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBluetoothTransport.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBluetoothTransport.java new file mode 100644 index 00000000000..4c17913344e --- /dev/null +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBluetoothTransport.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, 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.impl.linux; + +import com.codename1.bluetooth.helper.NativeSubprocessTransport; +import com.codename1.bluetooth.helper.NativeSubprocessTransportFactory; + +import java.util.List; + +/// The Linux native port's {@code cn1-ble-helper} transport: it bridges the +/// shared {@link NativeSubprocessTransport} line framing onto +/// {@link LinuxNative}'s {@code posix_spawn}-based process primitives. +final class LinuxBluetoothTransport extends NativeSubprocessTransport { + + LinuxBluetoothTransport(List command) { + super(command); + } + + protected long rawSpawn(String[] argv) { + return LinuxNative.procSpawn(argv); + } + + protected int rawRead(long handle, byte[] buf, int off, int len) { + return LinuxNative.procRead(handle, buf, off, len); + } + + protected int rawWrite(long handle, byte[] buf, int off, int len) { + return LinuxNative.procWrite(handle, buf, off, len); + } + + protected void rawCloseStdin(long handle) { + LinuxNative.procCloseStdin(handle); + } + + protected void rawClose(long handle) { + LinuxNative.procClose(handle); + } + + protected int rawIsAlive(long handle) { + return LinuxNative.procIsAlive(handle); + } + + /// The factory the port wires into {@code HelperBluetooth}. + static final class Factory extends NativeSubprocessTransportFactory { + protected NativeSubprocessTransport createTransport( + List command) { + return new LinuxBluetoothTransport(command); + } + + protected String executableName(String basename) { + return basename; + } + } +} diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 8320a41c183..3168f4d4a90 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -415,6 +415,20 @@ public com.codename1.security.Biometrics getBiometrics() { return biometrics; } + // Real BLE central via the shared cn1-ble-helper subprocess (btleplug -> + // BlueZ), spawned through the native posix_spawn bridge. Peripheral / + // L2CAP / classic report unsupported (btleplug is central-only). + private com.codename1.bluetooth.Bluetooth bluetooth; + + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + if (bluetooth == null) { + bluetooth = new com.codename1.bluetooth.helper.HelperBluetooth( + new LinuxBluetoothTransport.Factory()); + } + return bluetooth; + } + // WinRT Geolocator-backed location. getCurrentLocation reports OUT_OF_SERVICE // honestly when Linux location is disabled / denied. private com.codename1.location.LocationManager locationManager; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 4920bbd7437..d93df993d3f 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -473,6 +473,36 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** The local host name (used by Socket.getHostOrIP). */ public static native String getHostOrIP(); + /* ----------------------------------------------------- subprocess */ + + /** + * Spawns {@code argv[0]} with {@code argv} as its argument vector, wiring the + * child's stdin and stdout to pipes owned by this process (stderr is + * inherited). Returns an opaque native handle, or 0 on failure. + */ + public static native long procSpawn(String[] argv); + + /** + * Blocking read of up to {@code len} bytes from the child's stdout into + * {@code buf} at {@code off}; returns bytes read, 0 on EOF, -1 on error. + */ + public static native int procRead(long handle, byte[] buf, int off, int len); + + /** + * Writes {@code len} bytes from {@code buf} at {@code off} to the child's + * stdin; returns bytes written, or -1 on error. + */ + public static native int procWrite(long handle, byte[] buf, int off, int len); + + /** Closes the child's stdin (signals EOF to the helper) without killing it. */ + public static native void procCloseStdin(long handle); + + /** Terminates the child (if running), closes the pipes, and frees the handle. */ + public static native void procClose(long handle); + + /** Returns 1 if the child is still running, 0 if it has exited. */ + public static native int procIsAlive(long handle); + /* ------------------------------------------------------- clipboard */ public static native void clipboardSetText(String text); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_subprocess.c b/Ports/WindowsPort/nativeSources/cn1_windows_subprocess.c new file mode 100644 index 00000000000..8acdfb70b06 --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_subprocess.c @@ -0,0 +1,396 @@ +/* + * 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. + */ + +/* + * Native child-process (subprocess) transport for the Codename One Windows port. + * The native ports have full java.lang.Thread but no java.lang.Process / + * ProcessBuilder (absent from vm/JavaAPI), so a Java NativeSubprocessTransport + * that needs to spawn a helper binary (e.g. the Rust Bluetooth helper) and talk + * to it over stdio drives these primitives instead. + * + * A subprocess peer is a CN1Subprocess* holding the PROCESS_INFORMATION and the + * parent-side ends of two anonymous pipes wired to the child's stdin/stdout + * (stderr is inherited). It mirrors the socket bridge (cn1_windows_socket.c): + * blocking ReadFile/WriteFile are bracketed with CN1_YIELD_THREAD / + * CN1_RESUME_THREAD so the concurrent GC is not stalled by a thread parked in a + * blocking call, and the byte[] passed to a blocking read/write is anchored with + * CN1_PROC_KEEP_ALIVE so the conservative GC cannot sweep it mid-I/O. + */ + +#ifdef _WIN32 + +#include "cn1_windows.h" +#include +#include + +extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); + +/* Forces a read/write buffer object to stay live across a parked blocking call so + * the conservative GC can't sweep it mid-I/O -- identical idiom and rationale to + * CN1_SOCKET_KEEP_ALIVE in cn1_windows_socket.c (an address-escape asm; clang-cl, + * the compiler for this port, supports GNU inline asm). */ +#define CN1_PROC_KEEP_ALIVE(obj) __asm__ __volatile__("" : : "r"(obj)) + +typedef struct CN1Subprocess { + PROCESS_INFORMATION pi; + HANDLE stdinWrite; /* parent write end -> child stdin (NULL once closed) */ + HANDLE stdoutRead; /* parent read end <- child stdout (NULL once closed) */ +} CN1Subprocess; + +/* --------------------------------------------------------- command line build */ + +/* + * Growable UTF-8 byte buffer for assembling the command line. On allocation + * failure the buffer marks itself failed and further appends are no-ops so the + * caller only has to check once at the end. + */ +typedef struct { + char* data; + size_t len; + size_t cap; + int failed; +} Cn1CmdBuf; + +static void cn1CmdEnsure(Cn1CmdBuf* b, size_t extra) { + if (b->failed) { + return; + } + if (b->len + extra + 1 > b->cap) { + size_t ncap = b->cap ? b->cap * 2 : 256; + char* nd; + while (ncap < b->len + extra + 1) { + ncap *= 2; + } + nd = (char*) realloc(b->data, ncap); + if (!nd) { + b->failed = 1; + return; + } + b->data = nd; + b->cap = ncap; + } +} + +static void cn1CmdPutc(Cn1CmdBuf* b, char c) { + cn1CmdEnsure(b, 1); + if (b->failed) { + return; + } + b->data[b->len++] = c; +} + +static void cn1CmdPutn(Cn1CmdBuf* b, char c, size_t n) { + size_t i; + for (i = 0; i < n; i++) { + cn1CmdPutc(b, c); + } +} + +/* + * Appends one argument, quoted per the CreateProcess / CommandLineToArgvW rules + * (the "Everybody quotes command line arguments the wrong way" algorithm): + * quote the argument when it is empty or contains whitespace/quotes; inside the + * quotes, backslashes that precede a quote (or the closing quote) are doubled and + * embedded quotes are backslash-escaped. + */ +static void cn1CmdAppendArg(Cn1CmdBuf* b, const char* arg) { + size_t i; + size_t n = strlen(arg); + int needQuote = (n == 0); + for (i = 0; i < n && !needQuote; i++) { + char c = arg[i]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '"') { + needQuote = 1; + } + } + if (!needQuote) { + for (i = 0; i < n; i++) { + cn1CmdPutc(b, arg[i]); + } + return; + } + cn1CmdPutc(b, '"'); + for (i = 0; i < n; i++) { + size_t slashes = 0; + while (i < n && arg[i] == '\\') { + slashes++; + i++; + } + if (i == n) { + /* Trailing backslashes: double them so they don't escape the + * closing quote. */ + cn1CmdPutn(b, '\\', slashes * 2); + break; + } + if (arg[i] == '"') { + /* Backslashes before a quote are doubled, then the quote escaped. */ + cn1CmdPutn(b, '\\', slashes * 2 + 1); + cn1CmdPutc(b, '"'); + } else { + cn1CmdPutn(b, '\\', slashes); + cn1CmdPutc(b, arg[i]); + } + } + cn1CmdPutc(b, '"'); +} + +/* Converts a UTF-8 string to a freshly malloc'd WCHAR*, or NULL on failure. */ +static WCHAR* cn1Utf8ToWide(const char* utf8) { + int needed; + WCHAR* wide; + needed = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, NULL, 0); + if (needed <= 0) { + return NULL; + } + wide = (WCHAR*) malloc((size_t) needed * sizeof(WCHAR)); + if (!wide) { + return NULL; + } + if (MultiByteToWideChar(CP_UTF8, 0, utf8, -1, wide, needed) <= 0) { + free(wide); + return NULL; + } + return wide; +} + +/* ------------------------------------------------------------------ bridge */ + +JAVA_LONG com_codename1_impl_windows_WindowsNative_procSpawn___java_lang_String_1ARRAY_R_long( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1Arg1) { + int argc; + int i; + JAVA_OBJECT* elements; + Cn1CmdBuf cmd; + WCHAR* wideCmd = NULL; + HANDLE childStdinRead = NULL; + HANDLE childStdinWrite = NULL; + HANDLE childStdoutRead = NULL; + HANDLE childStdoutWrite = NULL; + SECURITY_ATTRIBUTES sa; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + CN1Subprocess* p; + + if (__cn1Arg1 == JAVA_NULL) { + return 0; + } + argc = (int) (*(JAVA_ARRAY) __cn1Arg1).length; + if (argc <= 0) { + return 0; + } + elements = (JAVA_OBJECT*) (*(JAVA_ARRAY) __cn1Arg1).data; + + /* Build the command line. stringToUTF8 returns a per-thread reusable buffer + * that the next call overwrites, so each element must be consumed (appended) + * before the next conversion -- which cn1CmdAppendArg does. */ + memset(&cmd, 0, sizeof(cmd)); + for (i = 0; i < argc; i++) { + const char* s = elements[i] == JAVA_NULL ? "" : stringToUTF8(threadStateData, elements[i]); + if (i > 0) { + cn1CmdPutc(&cmd, ' '); + } + cn1CmdAppendArg(&cmd, s ? s : ""); + } + if (cmd.failed || cmd.data == NULL) { + free(cmd.data); + return 0; + } + cmd.data[cmd.len] = '\0'; + wideCmd = cn1Utf8ToWide(cmd.data); + free(cmd.data); + if (!wideCmd) { + return 0; + } + + ZeroMemory(&sa, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; /* pipe ends must be inheritable to reach the child */ + sa.lpSecurityDescriptor = NULL; + + if (!CreatePipe(&childStdinRead, &childStdinWrite, &sa, 0)) { + cn1WindowsLog("procSpawn: CreatePipe(stdin) failed"); + free(wideCmd); + return 0; + } + if (!CreatePipe(&childStdoutRead, &childStdoutWrite, &sa, 0)) { + cn1WindowsLog("procSpawn: CreatePipe(stdout) failed"); + CloseHandle(childStdinRead); + CloseHandle(childStdinWrite); + free(wideCmd); + return 0; + } + /* The parent-owned ends must NOT be inherited by the child, otherwise the + * child holds a copy and the pipe never signals EOF/broken. */ + SetHandleInformation(childStdinWrite, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(childStdoutRead, HANDLE_FLAG_INHERIT, 0); + + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = childStdinRead; + si.hStdOutput = childStdoutWrite; + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); /* inherit stderr */ + + ZeroMemory(&pi, sizeof(pi)); + if (!CreateProcessW(NULL, wideCmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { + cn1WindowsLog("procSpawn: CreateProcess failed"); + CloseHandle(childStdinRead); + CloseHandle(childStdinWrite); + CloseHandle(childStdoutRead); + CloseHandle(childStdoutWrite); + free(wideCmd); + return 0; + } + free(wideCmd); + + /* The child-side ends belong to the child now; the parent closes its copies + * so the pipes signal EOF/broken correctly. */ + CloseHandle(childStdinRead); + CloseHandle(childStdoutWrite); + + p = (CN1Subprocess*) calloc(1, sizeof(CN1Subprocess)); + if (!p) { + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + CloseHandle(childStdinWrite); + CloseHandle(childStdoutRead); + return 0; + } + p->pi = pi; + p->stdinWrite = childStdinWrite; + p->stdoutRead = childStdoutRead; + return (JAVA_LONG) (intptr_t) p; +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_procRead___long_byte_1ARRAY_int_int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_OBJECT __cn1Arg2, JAVA_INT __cn1Arg3, JAVA_INT __cn1Arg4) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) __cn1Arg1; + JAVA_ARRAY_BYTE* data; + DWORD read = 0; + BOOL ok; + if (p == NULL || p->stdoutRead == NULL || __cn1Arg2 == JAVA_NULL || __cn1Arg4 <= 0) { + return -1; + } + data = (JAVA_ARRAY_BYTE*) (*(JAVA_ARRAY) __cn1Arg2).data; + /* ReadFile blocks until the helper writes (or its stdout closes). Park the + * thread across the call so the concurrent GC is not held up (see + * cn1_windows_socket.c socketRead). */ + CN1_YIELD_THREAD; + ok = ReadFile(p->stdoutRead, (char*) (data + __cn1Arg3), (DWORD) __cn1Arg4, &read, NULL); + CN1_RESUME_THREAD; + /* Keep the buffer array reachable across the parked read: only `data` (an + * interior pointer) is used, so the optimizer may drop __cn1Arg2 and the GC, + * scanning this parked thread, can sweep it mid-read (use-after-free). */ + CN1_PROC_KEEP_ALIVE(__cn1Arg2); + if (!ok) { + /* A closed/broken pipe after the child exits is a normal EOF. */ + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_HANDLE_EOF) { + return 0; + } + return -1; + } + return (JAVA_INT) read; /* 0 == EOF */ +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_procWrite___long_byte_1ARRAY_int_int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_OBJECT __cn1Arg2, JAVA_INT __cn1Arg3, JAVA_INT __cn1Arg4) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) __cn1Arg1; + JAVA_ARRAY_BYTE* data; + int written = 0; + if (p == NULL || p->stdinWrite == NULL || __cn1Arg2 == JAVA_NULL || __cn1Arg4 <= 0) { + return -1; + } + data = (JAVA_ARRAY_BYTE*) (*(JAVA_ARRAY) __cn1Arg2).data; + /* Yield across the (potentially blocking) write loop -- a thread parked in + * WriteFile must not stall a GC mark (see socketWrite). */ + CN1_YIELD_THREAD; + while (written < __cn1Arg4) { + DWORD wrote = 0; + if (!WriteFile(p->stdinWrite, (char*) (data + __cn1Arg3 + written), (DWORD) (__cn1Arg4 - written), &wrote, NULL) || wrote == 0) { + CN1_RESUME_THREAD; + /* Keep the array reachable across the parked write, exactly like + * socketWrite: only `data` (an interior pointer) is used after the + * yield, so the GC could otherwise sweep it WHILE WriteFile is still + * reading from it. */ + CN1_PROC_KEEP_ALIVE(__cn1Arg2); + return written > 0 ? written : -1; + } + written += (int) wrote; + } + CN1_RESUME_THREAD; + CN1_PROC_KEEP_ALIVE(__cn1Arg2); + return written; +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_procCloseStdin___long( + CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) __cn1Arg1; + if (p == NULL) { + return; + } + if (p->stdinWrite != NULL) { + CloseHandle(p->stdinWrite); + p->stdinWrite = NULL; + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_procClose___long( + CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) __cn1Arg1; + if (p == NULL) { + return; + } + if (p->stdinWrite != NULL) { + CloseHandle(p->stdinWrite); + p->stdinWrite = NULL; + } + if (p->stdoutRead != NULL) { + CloseHandle(p->stdoutRead); + p->stdoutRead = NULL; + } + if (p->pi.hProcess != NULL) { + if (WaitForSingleObject(p->pi.hProcess, 0) != WAIT_OBJECT_0) { + TerminateProcess(p->pi.hProcess, 1); + } + CloseHandle(p->pi.hProcess); + p->pi.hProcess = NULL; + } + if (p->pi.hThread != NULL) { + CloseHandle(p->pi.hThread); + p->pi.hThread = NULL; + } + free(p); +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_procIsAlive___long_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1) { + CN1Subprocess* p = (CN1Subprocess*) (intptr_t) __cn1Arg1; + if (p == NULL || p->pi.hProcess == NULL) { + return 0; + } + return (WaitForSingleObject(p->pi.hProcess, 0) == WAIT_TIMEOUT) ? 1 : 0; +} + +#endif /* _WIN32 */ diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsBluetoothTransport.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsBluetoothTransport.java new file mode 100644 index 00000000000..7c29767453e --- /dev/null +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsBluetoothTransport.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, 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.impl.windows; + +import com.codename1.bluetooth.helper.NativeSubprocessTransport; +import com.codename1.bluetooth.helper.NativeSubprocessTransportFactory; + +import java.util.List; + +/// The Windows native port's {@code cn1-ble-helper} transport: it bridges the +/// shared {@link NativeSubprocessTransport} line framing onto +/// {@link WindowsNative}'s {@code posix_spawn}-based process primitives. +final class WindowsBluetoothTransport extends NativeSubprocessTransport { + + WindowsBluetoothTransport(List command) { + super(command); + } + + protected long rawSpawn(String[] argv) { + return WindowsNative.procSpawn(argv); + } + + protected int rawRead(long handle, byte[] buf, int off, int len) { + return WindowsNative.procRead(handle, buf, off, len); + } + + protected int rawWrite(long handle, byte[] buf, int off, int len) { + return WindowsNative.procWrite(handle, buf, off, len); + } + + protected void rawCloseStdin(long handle) { + WindowsNative.procCloseStdin(handle); + } + + protected void rawClose(long handle) { + WindowsNative.procClose(handle); + } + + protected int rawIsAlive(long handle) { + return WindowsNative.procIsAlive(handle); + } + + /// The factory the port wires into {@code HelperBluetooth}. + static final class Factory extends NativeSubprocessTransportFactory { + protected NativeSubprocessTransport createTransport( + List command) { + return new WindowsBluetoothTransport(command); + } + + protected String executableName(String basename) { + return basename + ".exe"; + } + } +} diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 937f9a122ed..c7c3a629c22 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -405,6 +405,20 @@ public com.codename1.security.Biometrics getBiometrics() { return biometrics; } + // Real BLE central via the shared cn1-ble-helper subprocess (btleplug -> + // WinRT), spawned through the native CreateProcess bridge. Peripheral / + // L2CAP / classic report unsupported (btleplug is central-only). + private com.codename1.bluetooth.Bluetooth bluetooth; + + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + if (bluetooth == null) { + bluetooth = new com.codename1.bluetooth.helper.HelperBluetooth( + new WindowsBluetoothTransport.Factory()); + } + return bluetooth; + } + // WinRT Geolocator-backed location. getCurrentLocation reports OUT_OF_SERVICE // honestly when Windows location is disabled / denied. private com.codename1.location.LocationManager locationManager; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 310ed308128..630a0caf156 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -481,6 +481,36 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** The local host name (used by Socket.getHostOrIP). */ public static native String getHostOrIP(); + /* ----------------------------------------------------- subprocess */ + + /** + * Spawns {@code argv[0]} with {@code argv} as its argument vector, wiring the + * child's stdin and stdout to pipes owned by this process (stderr is + * inherited). Returns an opaque native handle, or 0 on failure. + */ + public static native long procSpawn(String[] argv); + + /** + * Blocking read of up to {@code len} bytes from the child's stdout into + * {@code buf} at {@code off}; returns bytes read, 0 on EOF, -1 on error. + */ + public static native int procRead(long handle, byte[] buf, int off, int len); + + /** + * Writes {@code len} bytes from {@code buf} at {@code off} to the child's + * stdin; returns bytes written, or -1 on error. + */ + public static native int procWrite(long handle, byte[] buf, int off, int len); + + /** Closes the child's stdin (signals EOF to the helper) without killing it. */ + public static native void procCloseStdin(long handle); + + /** Terminates the child (if running), closes the pipes, and frees the handle. */ + public static native void procClose(long handle); + + /** Returns 1 if the child is still running, 0 if it has exited. */ + public static native int procIsAlive(long handle); + /* ------------------------------------------------------- clipboard */ public static native void clipboardSetText(String text); diff --git a/Ports/iOSPort/nativeSources/CN1Bluetooth.h b/Ports/iOSPort/nativeSources/CN1Bluetooth.h new file mode 100644 index 00000000000..2b3bb665c99 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Bluetooth.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012-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. + */ + +// CoreBluetooth bridge for the com.codename1.bluetooth API. The entire +// implementation lives in CN1Bluetooth.m gated on CN1_INCLUDE_BLUETOOTH +// (flipped by IPhoneBuilder when the classpath scanner sees +// com.codename1.bluetooth.*); when the define is off every +// com_codename1_impl_ios_IOSNative_bt* trampoline is a linkable stub and no +// CoreBluetooth symbol is referenced. +// +// Nothing is exported from this translation unit -- the singleton +// controller, its dispatch queue and the L2CAP handle table are all +// file-static in CN1Bluetooth.m. + +#ifndef CN1_BLUETOOTH_H +#define CN1_BLUETOOTH_H + +#import + +#endif // CN1_BLUETOOTH_H diff --git a/Ports/iOSPort/nativeSources/CN1Bluetooth.m b/Ports/iOSPort/nativeSources/CN1Bluetooth.m new file mode 100644 index 00000000000..6961ebf0cd0 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Bluetooth.m @@ -0,0 +1,2467 @@ +/* + * Copyright (c) 2012-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. + */ + +// CoreBluetooth implementation of the IOSNative bt* trampolines backing +// com.codename1.bluetooth (see IOSBluetooth.java for the Java half). +// +// Architecture: +// - A single CN1BluetoothController owns the CBCentralManager and (on +// slices that have it) the CBPeripheralManager. Both are created lazily: +// instantiating a CBCentralManager is what pops the OS Bluetooth +// permission dialog, so nothing is allocated until the app touches the +// Bluetooth API. +// - All CoreBluetooth work runs on one serial dispatch queue +// ("com.codename1.bluetooth"), never the main queue. Void natives hop +// onto it with dispatch_async; value-returning natives use a +// run-inline-if-already-on-queue sync so that Java callbacks re-entering +// the bridge from the queue thread cannot deadlock. +// - Discovered CBPeripheral objects are retained for the lifetime of the +// controller in an identifier-keyed dictionary. CoreBluetooth only keeps +// weak knowledge of unretained peripherals -- connections silently die +// without this. Entries are small and device discovery counts are +// bounded in practice, so no eviction pass is attempted (documented +// trade-off). +// - Java arrays/strings passed into a native are converted to NSData / +// NSString *before* any dispatch_async: once the Java frame returns the +// JAVA_OBJECT is no longer rooted and must not be touched. +// - Delegate callbacks call the generated +// com_codename1_impl_ios_IOSBluetooth_nativeBt* trampolines directly from +// the Bluetooth queue (getThreadLocalData() attaches the thread), same as +// the camera/NFC bridges. +// +// Memory management is manual -- the iOS port builds with +// CLANG_ENABLE_OBJC_ARC=NO. + +#include "xmlvm.h" +#import "CodenameOne_GLViewController.h" +#import "CN1Bluetooth.h" + +#ifdef CN1_INCLUDE_BLUETOOTH + +#import +#include +#include +#include "com_codename1_impl_ios_IOSBluetooth.h" + +// The BLE peripheral role (CBPeripheralManager) does not exist on tvOS or +// watchOS; the whole server/advertising/L2CAP-publish section compiles out +// there and isBlePeripheralSupported reports false. +#if !TARGET_OS_TV && !TARGET_OS_WATCH +#define CN1_BT_PERIPHERAL_ROLE 1 +#import +#endif + +extern JAVA_OBJECT fromNSString(CODENAME_ONE_THREAD_STATE, NSString* str); +extern NSString* toNSString(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); +extern JAVA_OBJECT nsDataToByteArr(NSData *data); + +// Error codes shared with IOSBluetooth.java -- keep the two lists in sync. +#define CN1_BT_ERR_UNKNOWN 0 +#define CN1_BT_ERR_NOT_SUPPORTED 1 +#define CN1_BT_ERR_POWERED_OFF 2 +#define CN1_BT_ERR_UNAUTHORIZED 3 +#define CN1_BT_ERR_SCAN_FAILED 4 +#define CN1_BT_ERR_ADVERTISE_FAILED 5 +#define CN1_BT_ERR_CONNECTION_FAILED 6 +#define CN1_BT_ERR_CONNECTION_LOST 7 +#define CN1_BT_ERR_NOT_CONNECTED 8 +#define CN1_BT_ERR_GATT 9 +#define CN1_BT_ERR_TIMEOUT 10 +#define CN1_BT_ERR_IO 11 + +// -------------------------------------------------------------------------- +// dispatch queue + +static dispatch_queue_t cn1btQueue = nil; +static void *kCN1BtQueueKey = &kCN1BtQueueKey; + +static dispatch_queue_t cn1btGetQueue(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + cn1btQueue = dispatch_queue_create("com.codename1.bluetooth", + DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(cn1btQueue, kCN1BtQueueKey, + (void *)1, NULL); + }); + return cn1btQueue; +} + +/** Runs the block on the Bluetooth queue and waits; runs inline when the + * caller is already on the queue (Java callbacks issued from delegate code + * re-enter the bridge synchronously -- dispatch_sync would deadlock). */ +static void cn1btSync(void (^block)(void)) { + if (dispatch_get_specific(kCN1BtQueueKey)) { + block(); + } else { + dispatch_sync(cn1btGetQueue(), block); + } +} + +/** Fire-and-forget onto the Bluetooth queue (inline when already there, + * preserving ordering on the serial queue in both cases). */ +static void cn1btAsync(void (^block)(void)) { + if (dispatch_get_specific(kCN1BtQueueKey)) { + block(); + } else { + dispatch_async(cn1btGetQueue(), block); + } +} + +// -------------------------------------------------------------------------- +// small helpers + +static JAVA_OBJECT cn1btJString(NSString *s) { + return s == nil ? JAVA_NULL : fromNSString(getThreadLocalData(), s); +} + +static JAVA_OBJECT cn1btJBytes(NSData *d) { + return d == nil ? JAVA_NULL : nsDataToByteArr(d); +} + +static NSData *cn1btDataFromJavaArray(JAVA_OBJECT arr) { + if (arr == JAVA_NULL) { + return [NSData data]; + } + JAVA_ARRAY a = (JAVA_ARRAY)arr; + if (a->length <= 0) { + return [NSData data]; + } + return [NSData dataWithBytes:a->data length:a->length]; +} + +static NSString *cn1btHexFromData(NSData *d) { + if (d == nil || [d length] == 0) { + return @""; + } + const unsigned char *bytes = (const unsigned char *)[d bytes]; + NSUInteger len = [d length]; + NSMutableString *out = [NSMutableString stringWithCapacity:len * 2]; + for (NSUInteger i = 0; i < len; i++) { + [out appendFormat:@"%02x", bytes[i]]; + } + return out; +} + +static int cn1btHexDigit(unichar c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; +} + +static NSData *cn1btDataFromHex(NSString *hex) { + NSUInteger len = [hex length] / 2; + if (len == 0) { + return nil; + } + NSMutableData *out = [NSMutableData dataWithLength:len]; + unsigned char *bytes = (unsigned char *)[out mutableBytes]; + for (NSUInteger i = 0; i < len; i++) { + int hi = cn1btHexDigit([hex characterAtIndex:i * 2]); + int lo = cn1btHexDigit([hex characterAtIndex:i * 2 + 1]); + if (hi < 0 || lo < 0) { + return nil; + } + bytes[i] = (unsigned char)((hi << 4) | lo); + } + return out; +} + +static CBUUID *cn1btUuid(NSString *uuidStr) { + if (uuidStr == nil || [uuidStr length] == 0) { + return nil; + } + @try { + return [CBUUID UUIDWithString:uuidStr]; + } @catch (NSException *e) { + return nil; + } +} + +/** Converts a Java String[] to CBUUIDs; must run on the calling Java + * thread (before any dispatch). */ +static NSArray *cn1btUuidArray(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT arr) { + if (arr == JAVA_NULL) { + return nil; + } + JAVA_ARRAY a = (JAVA_ARRAY)arr; + if (a->length <= 0) { + return nil; + } + JAVA_ARRAY_OBJECT *data = (JAVA_ARRAY_OBJECT *)a->data; + NSMutableArray *out = [NSMutableArray arrayWithCapacity:a->length]; + for (int i = 0; i < a->length; i++) { + if (data[i] == JAVA_NULL) { + continue; + } + CBUUID *u = cn1btUuid(toNSString(threadStateData, + (JAVA_OBJECT)data[i])); + if (u != nil) { + [out addObject:u]; + } + } + return [out count] > 0 ? out : nil; +} + +static int cn1btAuthorizationValue(void) { + if (@available(iOS 13.1, tvOS 13.1, watchOS 6.1, *)) { + switch (CBManager.authorization) { + case CBManagerAuthorizationNotDetermined: + return 0; + case CBManagerAuthorizationRestricted: + return 1; + case CBManagerAuthorizationDenied: + return 2; + case CBManagerAuthorizationAllowedAlways: + default: + return 3; + } + } + return 3; // pre-13.1: no per-app Bluetooth authorization +} + +static int cn1btMapNSError(NSError *err, int fallback) { + if (err == nil) { + return fallback; + } + if ([err.domain isEqualToString:CBATTErrorDomain]) { + return CN1_BT_ERR_GATT; + } + if ([err.domain isEqualToString:CBErrorDomain]) { + switch (err.code) { + case CBErrorNotConnected: + return CN1_BT_ERR_NOT_CONNECTED; + case CBErrorConnectionTimeout: + return CN1_BT_ERR_TIMEOUT; + case CBErrorPeripheralDisconnected: + return CN1_BT_ERR_CONNECTION_LOST; + case CBErrorOperationCancelled: + return CN1_BT_ERR_UNKNOWN; + default: + return fallback; + } + } + return fallback; +} + +// -------------------------------------------------------------------------- +// Java callback wrappers (all safe to call from the Bluetooth queue) + +static void cn1btSendRequestError(int rid, int code, NSString *msg) { + if (rid <= 0) { + return; + } + com_codename1_impl_ios_IOSBluetooth_nativeBtRequestError___int_int_java_lang_String( + getThreadLocalData(), rid, code, cn1btJString(msg)); +} + +static void cn1btSendNSError(int rid, NSError *err, int fallback) { + cn1btSendRequestError(rid, cn1btMapNSError(err, fallback), + err != nil ? err.localizedDescription : nil); +} + +static void cn1btSendOpComplete(int rid) { + if (rid <= 0) { + return; + } + com_codename1_impl_ios_IOSBluetooth_nativeBtOperationComplete___int( + getThreadLocalData(), rid); +} + +// -------------------------------------------------------------------------- +// L2CAP channel wrappers -- guarded by their own lock (not the queue) so +// the blocking read/write natives can poll off-queue without starving the +// delegates. + +@interface CN1BtL2capChannel : NSObject +@property (nonatomic, retain) NSObject *channel; // CBL2CAPChannel +@property (nonatomic, retain) NSInputStream *input; +@property (nonatomic, retain) NSOutputStream *output; +@property (nonatomic, assign) BOOL closed; +@end + +@implementation CN1BtL2capChannel +- (void)dealloc { + [_channel release]; + [_input release]; + [_output release]; + [super dealloc]; +} +@end + +static NSMutableDictionary *cn1btL2capChannels = nil; +static long long cn1btNextL2capHandle = 1; + +static NSMutableDictionary *cn1btChannels(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + cn1btL2capChannels = [[NSMutableDictionary alloc] init]; + }); + return cn1btL2capChannels; +} + +/** Wraps a freshly opened CBL2CAPChannel, opens its streams and returns + * the opaque handle handed to Java. The streams are consumed by polling + * (hasBytesAvailable / hasSpaceAvailable + read/write) from the Java + * caller's thread, so no runloop scheduling is required. */ +static long long cn1btRegisterL2capChannel(CBL2CAPChannel *channel) + API_AVAILABLE(ios(11.0), tvos(11.0), watchos(4.0)) { + CN1BtL2capChannel *w = [[CN1BtL2capChannel alloc] init]; + w.channel = (NSObject *)channel; + w.input = channel.inputStream; + w.output = channel.outputStream; + [w.input open]; + [w.output open]; + long long h; + NSMutableDictionary *dict = cn1btChannels(); + @synchronized (dict) { + h = cn1btNextL2capHandle++; + [dict setObject:w forKey:[NSNumber numberWithLongLong:h]]; + } + [w release]; + return h; +} + +static CN1BtL2capChannel *cn1btChannelForHandle(long long h) { + NSMutableDictionary *dict = cn1btChannels(); + @synchronized (dict) { + CN1BtL2capChannel *w = [dict objectForKey: + [NSNumber numberWithLongLong:h]]; + return [[w retain] autorelease]; + } +} + +// -------------------------------------------------------------------------- +// per-peripheral bookkeeping + +@interface CN1BtPeripheralEntry : NSObject +@property (nonatomic, retain) CBPeripheral *peripheral; +// GATT discovery aggregation +@property (nonatomic, assign) int discoverRid; +@property (nonatomic, assign) int pendingCharDiscoveries; +@property (nonatomic, assign) int pendingDescDiscoveries; +// pending request ids keyed by "serviceIndex/charIndex" (+ "/descUuid") +@property (nonatomic, retain) NSMutableDictionary *pendingReads; +@property (nonatomic, retain) NSMutableDictionary *pendingWrites; +@property (nonatomic, retain) NSMutableDictionary *pendingNotifyState; +@property (nonatomic, retain) NSMutableDictionary *pendingDescReads; +@property (nonatomic, retain) NSMutableDictionary *pendingDescWrites; +@property (nonatomic, assign) int pendingRssiRid; +// array of @{@"rid": ..., @"psm": ...} awaiting didOpenL2CAPChannel +@property (nonatomic, retain) NSMutableArray *pendingL2capOpens; +@end + +@implementation CN1BtPeripheralEntry +- (id)init { + if ((self = [super init])) { + self.pendingReads = [NSMutableDictionary dictionary]; + self.pendingWrites = [NSMutableDictionary dictionary]; + self.pendingNotifyState = [NSMutableDictionary dictionary]; + self.pendingDescReads = [NSMutableDictionary dictionary]; + self.pendingDescWrites = [NSMutableDictionary dictionary]; + self.pendingL2capOpens = [NSMutableArray array]; + } + return self; +} + +- (void)dealloc { + [_peripheral release]; + [_pendingReads release]; + [_pendingWrites release]; + [_pendingNotifyState release]; + [_pendingDescReads release]; + [_pendingDescWrites release]; + [_pendingL2capOpens release]; + [super dealloc]; +} +@end + +// -------------------------------------------------------------------------- +// controller + +typedef void (^CN1BtCharOp)(CN1BtPeripheralEntry *e, CBCharacteristic *ch, + NSString *key); + +@interface CN1BluetoothController : NSObject { + CBCentralManager *central; + NSMutableDictionary *entries; // identifier UUID string -> entry + BOOL scanActive; + BOOL scanDuplicates; + NSArray *scanServiceUuids; +#ifdef CN1_BT_PERIPHERAL_ROLE + CBPeripheralManager *peripheralMgr; + NSMutableArray *pendingServerOpenRids; // NSNumber(int) + int pendingAdvertiseRid; // 0 = none + NSDictionary *pendingAdvertiseData; // retained until started + BOOL advertiseSubmitted; // startAdvertising already sent + NSMutableDictionary *localServices; // @(sid) -> CBMutableService + NSMutableDictionary *localChars; // @(cid) -> CBMutableCharacteristic + NSMutableDictionary *staticValues; // @(cid) -> NSData + NSMutableDictionary *pendingAddServiceRids; // @(svc ptr) -> @(rid) + NSMutableDictionary *attRequests; // @(handle) -> entry dict + long long nextAttHandle; + NSMutableArray *pendingNotifies; // job dicts, FIFO + NSMutableDictionary *subscribedCentrals; // central id -> CBCentral + NSMutableArray *pendingPublishRequests; // pre-poweredOn @{rid, secure} + NSMutableArray *publishAwaitingCallback; // NSNumber(rid), FIFO +#endif +} +- (void)ensureCentral; +- (void)startScanUuids:(NSArray *)uuids duplicates:(BOOL)dup; +- (void)stopScan; +- (NSString *)retrievePeripheral:(NSString *)pid; +- (NSString *)connectedPeripheralsFor:(CBUUID *)serviceUuid; +- (void)connectPeripheral:(NSString *)pid; +- (void)disconnectPeripheral:(NSString *)pid; +- (void)discoverServices:(int)requestId peripheral:(NSString *)pid; +- (void)withChar:(int)requestId peripheral:(NSString *)pid + svcUuid:(NSString *)svcUuid svcInst:(int)svcInst + charUuid:(NSString *)charUuid charInst:(int)charInst + op:(CN1BtCharOp)op; +- (void)readRssi:(int)requestId peripheral:(NSString *)pid; +- (int)maxWriteLength:(NSString *)pid withResponse:(BOOL)withResponse; +- (void)openL2cap:(int)requestId peripheral:(NSString *)pid psm:(int)psm; +#ifdef CN1_BT_PERIPHERAL_ROLE +- (void)openGattServer:(int)requestId; +- (void)addService:(int)requestId definition:(NSString *)def; +- (void)removeServiceById:(int)serviceLocalId; +- (void)closeGattServer; +- (void)startAdvertising:(int)requestId name:(NSString *)name + uuids:(NSArray *)uuids; +- (void)stopAdvertising; +- (void)notifyValue:(int)requestId charId:(int)charLocalId + data:(NSData *)data central:(NSString *)centralId; +- (void)respondToRead:(long long)handle data:(NSData *)data + status:(int)attStatus; +- (void)respondToWrite:(long long)handle status:(int)attStatus; +- (void)publishL2cap:(int)requestId secure:(BOOL)secure; +- (void)unpublishL2cap:(int)psm; +#endif +@end + +static CN1BluetoothController *cn1btController = nil; + +static CN1BluetoothController *cn1btGetController(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + cn1btController = [[CN1BluetoothController alloc] init]; + }); + return cn1btController; +} + +@implementation CN1BluetoothController + +- (id)init { + if ((self = [super init])) { + entries = [[NSMutableDictionary alloc] init]; +#ifdef CN1_BT_PERIPHERAL_ROLE + pendingServerOpenRids = [[NSMutableArray alloc] init]; + localServices = [[NSMutableDictionary alloc] init]; + localChars = [[NSMutableDictionary alloc] init]; + staticValues = [[NSMutableDictionary alloc] init]; + pendingAddServiceRids = [[NSMutableDictionary alloc] init]; + attRequests = [[NSMutableDictionary alloc] init]; + pendingNotifies = [[NSMutableArray alloc] init]; + subscribedCentrals = [[NSMutableDictionary alloc] init]; + pendingPublishRequests = [[NSMutableArray alloc] init]; + publishAwaitingCallback = [[NSMutableArray alloc] init]; +#endif + } + return self; +} + +// The controller is a process-lifetime singleton -- no dealloc path. + +- (void)ensureCentral { + if (central == nil) { + // creating the manager triggers the OS permission prompt + central = [[CBCentralManager alloc] initWithDelegate:self + queue:cn1btGetQueue()]; + } +} + +- (CN1BtPeripheralEntry *)entryForPeripheral:(CBPeripheral *)p { + NSString *pid = p.identifier.UUIDString; + CN1BtPeripheralEntry *e = [entries objectForKey:pid]; + if (e == nil) { + e = [[CN1BtPeripheralEntry alloc] init]; + e.peripheral = p; + p.delegate = self; + [entries setObject:e forKey:pid]; + [e release]; + } + return e; +} + +- (CN1BtPeripheralEntry *)entryForId:(NSString *)pid { + return pid == nil ? nil : [entries objectForKey:pid]; +} + +// ---- attribute lookup ---------------------------------------------------- + +- (CBService *)serviceIn:(CBPeripheral *)p uuid:(NSString *)uuidStr + instance:(int)inst { + CBUUID *u = cn1btUuid(uuidStr); + if (u == nil) { + return nil; + } + NSArray *svcs = p.services; + if (inst >= 0 && inst < (int)[svcs count]) { + CBService *s = [svcs objectAtIndex:inst]; + if ([s.UUID isEqual:u]) { + return s; + } + } + for (CBService *s in svcs) { + if ([s.UUID isEqual:u]) { + return s; + } + } + return nil; +} + +- (CBCharacteristic *)charIn:(CBService *)s uuid:(NSString *)uuidStr + instance:(int)inst { + if (s == nil) { + return nil; + } + CBUUID *u = cn1btUuid(uuidStr); + if (u == nil) { + return nil; + } + NSArray *chars = s.characteristics; + if (inst >= 0 && inst < (int)[chars count]) { + CBCharacteristic *c = [chars objectAtIndex:inst]; + if ([c.UUID isEqual:u]) { + return c; + } + } + for (CBCharacteristic *c in chars) { + if ([c.UUID isEqual:u]) { + return c; + } + } + return nil; +} + +- (CBDescriptor *)descIn:(CBCharacteristic *)c uuid:(NSString *)uuidStr { + CBUUID *u = cn1btUuid(uuidStr); + if (c == nil || u == nil) { + return nil; + } + for (CBDescriptor *d in c.descriptors) { + if ([d.UUID isEqual:u]) { + return d; + } + } + return nil; +} + +/** "serviceIndex/charIndex" key for the pending-request dictionaries. */ +- (NSString *)keyForChar:(CBCharacteristic *)c in:(CBPeripheral *)p { + CBService *s = c.service; + if (s == nil) { + return nil; + } + NSUInteger si = [p.services indexOfObject:s]; + NSUInteger ci = [s.characteristics indexOfObject:c]; + if (si == NSNotFound || ci == NSNotFound) { + return nil; + } + return [NSString stringWithFormat:@"%d/%d", (int)si, (int)ci]; +} + +- (NSString *)keyForDesc:(CBDescriptor *)d in:(CBPeripheral *)p { + CBCharacteristic *c = d.characteristic; + if (c == nil) { + return nil; + } + NSString *ck = [self keyForChar:c in:p]; + if (ck == nil) { + return nil; + } + return [NSString stringWithFormat:@"%@/%@", ck, + [d.UUID.UUIDString uppercaseString]]; +} + +/** Fails every request pending on this peripheral (link went down). */ +- (void)flushPendingOps:(CN1BtPeripheralEntry *)e code:(int)code + msg:(NSString *)msg { + NSArray *dicts = [NSArray arrayWithObjects:e.pendingReads, + e.pendingWrites, e.pendingNotifyState, e.pendingDescReads, + e.pendingDescWrites, nil]; + for (NSMutableDictionary *d in dicts) { + for (NSNumber *rid in [d allValues]) { + cn1btSendRequestError([rid intValue], code, msg); + } + [d removeAllObjects]; + } + if (e.discoverRid > 0) { + cn1btSendRequestError(e.discoverRid, code, msg); + e.discoverRid = 0; + } + e.pendingCharDiscoveries = 0; + e.pendingDescDiscoveries = 0; + if (e.pendingRssiRid > 0) { + cn1btSendRequestError(e.pendingRssiRid, code, msg); + e.pendingRssiRid = 0; + } + for (NSDictionary *job in e.pendingL2capOpens) { + cn1btSendRequestError([[job objectForKey:@"rid"] intValue], code, + msg); + } + [e.pendingL2capOpens removeAllObjects]; +} + +// ---- central-role operations (run on the Bluetooth queue) ----------------- + +- (void)startScanUuids:(NSArray *)uuids duplicates:(BOOL)dup { + [self ensureCentral]; + scanActive = YES; + scanDuplicates = dup; + if (scanServiceUuids != uuids) { + [scanServiceUuids release]; + scanServiceUuids = [uuids retain]; + } + if (central.state == CBManagerStatePoweredOn) { + [self issueScan]; + } + // otherwise centralManagerDidUpdateState issues the scan on poweredOn +} + +- (void)issueScan { + NSDictionary *opts = [NSDictionary dictionaryWithObject: + [NSNumber numberWithBool:scanDuplicates] + forKey:CBCentralManagerScanOptionAllowDuplicatesKey]; + [central scanForPeripheralsWithServices:scanServiceUuids options:opts]; +} + +- (void)stopScan { + scanActive = NO; + if (central != nil && central.state == CBManagerStatePoweredOn) { + [central stopScan]; + } +} + +- (NSString *)retrievePeripheral:(NSString *)pid { + [self ensureCentral]; + NSUUID *uuid = [[[NSUUID alloc] initWithUUIDString:pid] autorelease]; + if (uuid == nil) { + return nil; + } + NSArray *found = [central retrievePeripheralsWithIdentifiers: + [NSArray arrayWithObject:uuid]]; + if ([found count] == 0) { + return nil; + } + CBPeripheral *p = [found objectAtIndex:0]; + [self entryForPeripheral:p]; + return p.name != nil ? p.name : @""; +} + +- (NSString *)connectedPeripheralsFor:(CBUUID *)serviceUuid { + [self ensureCentral]; + NSArray *found = [central retrieveConnectedPeripheralsWithServices: + [NSArray arrayWithObject:serviceUuid]]; + NSMutableString *out = [NSMutableString string]; + for (CBPeripheral *p in found) { + [self entryForPeripheral:p]; + if ([out length] > 0) { + [out appendString:@"\n"]; + } + [out appendFormat:@"%@\t%@", p.identifier.UUIDString, + p.name != nil ? p.name : @""]; + } + return out; +} + +- (void)connectPeripheral:(NSString *)pid { + [self ensureCentral]; + CN1BtPeripheralEntry *e = [self entryForId:pid]; + if (e == nil) { + // not seen in a scan; try resolving the persisted identifier + NSUUID *uuid = [[[NSUUID alloc] initWithUUIDString:pid] + autorelease]; + NSArray *found = uuid == nil ? nil + : [central retrievePeripheralsWithIdentifiers: + [NSArray arrayWithObject:uuid]]; + if ([found count] > 0) { + e = [self entryForPeripheral:[found objectAtIndex:0]]; + } + } + if (e == nil) { + com_codename1_impl_ios_IOSBluetooth_nativeBtConnectFailed___java_lang_String_int_java_lang_String( + getThreadLocalData(), cn1btJString(pid), + CN1_BT_ERR_CONNECTION_FAILED, + cn1btJString(@"Unknown peripheral identifier")); + return; + } + [central connectPeripheral:e.peripheral options:nil]; +} + +- (void)disconnectPeripheral:(NSString *)pid { + CN1BtPeripheralEntry *e = [self entryForId:pid]; + if (e != nil && central != nil) { + [central cancelPeripheralConnection:e.peripheral]; + } +} + +- (void)discoverServices:(int)requestId peripheral:(NSString *)pid { + CN1BtPeripheralEntry *e = [self entryForId:pid]; + if (e == nil || e.peripheral.state != CBPeripheralStateConnected) { + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_CONNECTED, + @"Peripheral is not connected"); + return; + } + if (e.discoverRid > 0) { + cn1btSendRequestError(requestId, CN1_BT_ERR_UNKNOWN, + @"Discovery already in progress"); + return; + } + e.discoverRid = requestId; + e.pendingCharDiscoveries = 0; + e.pendingDescDiscoveries = 0; + [e.peripheral discoverServices:nil]; +} + +/** Resolves the addressed characteristic and hands it to `op`, or fails + * the request. Shared by read/write/notify-arm/descriptor operations. */ +- (void)withChar:(int)requestId peripheral:(NSString *)pid + svcUuid:(NSString *)svcUuid svcInst:(int)svcInst + charUuid:(NSString *)charUuid charInst:(int)charInst + op:(CN1BtCharOp)op { + CN1BtPeripheralEntry *e = [self entryForId:pid]; + if (e == nil || e.peripheral.state != CBPeripheralStateConnected) { + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_CONNECTED, + @"Peripheral is not connected"); + return; + } + CBService *s = [self serviceIn:e.peripheral uuid:svcUuid + instance:svcInst]; + CBCharacteristic *ch = [self charIn:s uuid:charUuid instance:charInst]; + NSString *key = ch != nil ? [self keyForChar:ch in:e.peripheral] : nil; + if (ch == nil || key == nil) { + cn1btSendRequestError(requestId, CN1_BT_ERR_GATT, + @"Characteristic not found -- rerun discoverServices"); + return; + } + op(e, ch, key); +} + +- (void)readRssi:(int)requestId peripheral:(NSString *)pid { + CN1BtPeripheralEntry *e = [self entryForId:pid]; + if (e == nil || e.peripheral.state != CBPeripheralStateConnected) { + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_CONNECTED, + @"Peripheral is not connected"); + return; + } + e.pendingRssiRid = requestId; + [e.peripheral readRSSI]; +} + +- (int)maxWriteLength:(NSString *)pid withResponse:(BOOL)withResponse { + CN1BtPeripheralEntry *e = [self entryForId:pid]; + if (e == nil || e.peripheral.state != CBPeripheralStateConnected) { + return 0; + } + return (int)[e.peripheral maximumWriteValueLengthForType: + (withResponse ? CBCharacteristicWriteWithResponse + : CBCharacteristicWriteWithoutResponse)]; +} + +- (void)openL2cap:(int)requestId peripheral:(NSString *)pid psm:(int)psm { + CN1BtPeripheralEntry *e = [self entryForId:pid]; + if (e == nil || e.peripheral.state != CBPeripheralStateConnected) { + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_CONNECTED, + @"Peripheral is not connected"); + return; + } + if (@available(iOS 11.0, tvOS 11.0, watchOS 4.0, *)) { + NSMutableDictionary *job = [NSMutableDictionary dictionary]; + [job setObject:[NSNumber numberWithInt:requestId] forKey:@"rid"]; + [job setObject:[NSNumber numberWithInt:psm] forKey:@"psm"]; + [e.pendingL2capOpens addObject:job]; + [e.peripheral openL2CAPChannel:(CBL2CAPPSM)psm]; + } else { + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"L2CAP requires iOS 11+"); + } +} + +// ---- CBCentralManagerDelegate ---------------------------------------------- + +- (void)centralManagerDidUpdateState:(CBCentralManager *)c { + com_codename1_impl_ios_IOSBluetooth_nativeBtStateChanged___int_int( + getThreadLocalData(), (int)c.state, cn1btAuthorizationValue()); + if (c.state == CBManagerStatePoweredOn && scanActive) { + [self issueScan]; + } +} + +- (void)centralManager:(CBCentralManager *)c + didDiscoverPeripheral:(CBPeripheral *)peripheral + advertisementData:(NSDictionary *)advertisementData + RSSI:(NSNumber *)RSSI { + @autoreleasepool { + [self entryForPeripheral:peripheral]; + NSString *pid = peripheral.identifier.UUIDString; + NSString *localName = [advertisementData objectForKey: + CBAdvertisementDataLocalNameKey]; + NSMutableString *uuidsCsv = [NSMutableString string]; + NSArray *advUuids = [advertisementData objectForKey: + CBAdvertisementDataServiceUUIDsKey]; + NSArray *overflow = [advertisementData objectForKey: + CBAdvertisementDataOverflowServiceUUIDsKey]; + for (NSArray *list in [NSArray arrayWithObjects:advUuids, overflow, + nil]) { + for (CBUUID *u in list) { + if ([uuidsCsv length] > 0) { + [uuidsCsv appendString:@","]; + } + [uuidsCsv appendString:u.UUIDString]; + } + } + NSData *mfg = [advertisementData objectForKey: + CBAdvertisementDataManufacturerDataKey]; + NSMutableString *svcDataCsv = [NSMutableString string]; + NSDictionary *svcData = [advertisementData objectForKey: + CBAdvertisementDataServiceDataKey]; + for (CBUUID *u in svcData) { + if ([svcDataCsv length] > 0) { + [svcDataCsv appendString:@","]; + } + [svcDataCsv appendFormat:@"%@=%@", u.UUIDString, + cn1btHexFromData([svcData objectForKey:u])]; + } + NSNumber *tx = [advertisementData objectForKey: + CBAdvertisementDataTxPowerLevelKey]; + NSNumber *connectable = [advertisementData objectForKey: + CBAdvertisementDataIsConnectable]; + JAVA_OBJECT jPid = cn1btJString(pid); + JAVA_OBJECT jName = cn1btJString(peripheral.name); + JAVA_OBJECT jUuids = cn1btJString(uuidsCsv); + JAVA_OBJECT jLocalName = cn1btJString(localName); + JAVA_OBJECT jMfg = cn1btJBytes(mfg); + JAVA_OBJECT jSvcData = cn1btJString(svcDataCsv); + com_codename1_impl_ios_IOSBluetooth_nativeBtScanResult___java_lang_String_java_lang_String_int_boolean_java_lang_String_java_lang_String_byte_1ARRAY_java_lang_String_int( + getThreadLocalData(), jPid, jName, [RSSI intValue], + connectable == nil || [connectable boolValue] ? 1 : 0, + jUuids, jLocalName, jMfg, jSvcData, + tx != nil ? [tx intValue] : -999); + } +} + +- (void)centralManager:(CBCentralManager *)c + didConnectPeripheral:(CBPeripheral *)peripheral { + [self entryForPeripheral:peripheral]; + com_codename1_impl_ios_IOSBluetooth_nativeBtConnected___java_lang_String( + getThreadLocalData(), + cn1btJString(peripheral.identifier.UUIDString)); +} + +- (void)centralManager:(CBCentralManager *)c + didFailToConnectPeripheral:(CBPeripheral *)peripheral + error:(NSError *)error { + com_codename1_impl_ios_IOSBluetooth_nativeBtConnectFailed___java_lang_String_int_java_lang_String( + getThreadLocalData(), + cn1btJString(peripheral.identifier.UUIDString), + cn1btMapNSError(error, CN1_BT_ERR_CONNECTION_FAILED), + cn1btJString(error != nil ? error.localizedDescription : nil)); +} + +- (void)centralManager:(CBCentralManager *)c + didDisconnectPeripheral:(CBPeripheral *)peripheral + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId: + peripheral.identifier.UUIDString]; + if (e != nil) { + [self flushPendingOps:e code:CN1_BT_ERR_NOT_CONNECTED + msg:@"Peripheral disconnected"]; + } + com_codename1_impl_ios_IOSBluetooth_nativeBtDisconnected___java_lang_String_int_java_lang_String( + getThreadLocalData(), + cn1btJString(peripheral.identifier.UUIDString), + error != nil + ? cn1btMapNSError(error, CN1_BT_ERR_CONNECTION_LOST) : 0, + cn1btJString(error != nil ? error.localizedDescription : nil)); +} + +// ---- CBPeripheralDelegate: discovery aggregation ---------------------------- + +- (void)peripheral:(CBPeripheral *)p didDiscoverServices:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + if (e == nil || e.discoverRid <= 0) { + return; + } + if (error != nil) { + int rid = e.discoverRid; + e.discoverRid = 0; + cn1btSendNSError(rid, error, CN1_BT_ERR_GATT); + return; + } + NSArray *svcs = p.services; + e.pendingCharDiscoveries = (int)[svcs count]; + e.pendingDescDiscoveries = 0; + if ([svcs count] == 0) { + [self maybeEmitGattDb:e]; + return; + } + for (CBService *s in svcs) { + [p discoverCharacteristics:nil forService:s]; + } +} + +- (void)peripheral:(CBPeripheral *)p + didDiscoverCharacteristicsForService:(CBService *)service + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + if (e == nil || e.discoverRid <= 0) { + return; + } + e.pendingCharDiscoveries--; + if (error == nil) { + for (CBCharacteristic *c in service.characteristics) { + e.pendingDescDiscoveries++; + [p discoverDescriptorsForCharacteristic:c]; + } + } + [self maybeEmitGattDb:e]; +} + +- (void)peripheral:(CBPeripheral *)p + didDiscoverDescriptorsForCharacteristic:(CBCharacteristic *)c + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + if (e == nil || e.discoverRid <= 0) { + return; + } + e.pendingDescDiscoveries--; + [self maybeEmitGattDb:e]; +} + +/** Emits the aggregated GATT database once every characteristic and + * descriptor discovery round-trip finished. Instance ids are array + * indices, mirrored by the lookup helpers above. */ +- (void)maybeEmitGattDb:(CN1BtPeripheralEntry *)e { + if (e.discoverRid <= 0 || e.pendingCharDiscoveries > 0 + || e.pendingDescDiscoveries > 0) { + return; + } + @autoreleasepool { + CBPeripheral *p = e.peripheral; + NSMutableString *db = [NSMutableString string]; + NSArray *svcs = p.services; + for (NSUInteger si = 0; si < [svcs count]; si++) { + CBService *s = [svcs objectAtIndex:si]; + [db appendFormat:@"S|%@|%d|%d\n", s.UUID.UUIDString, + s.isPrimary ? 1 : 0, (int)si]; + NSArray *chars = s.characteristics; + for (NSUInteger ci = 0; ci < [chars count]; ci++) { + CBCharacteristic *c = [chars objectAtIndex:ci]; + [db appendFormat:@"C|%@|%d|%d\n", c.UUID.UUIDString, + (int)c.properties, (int)ci]; + for (CBDescriptor *d in c.descriptors) { + [db appendFormat:@"D|%@\n", d.UUID.UUIDString]; + } + } + } + int rid = e.discoverRid; + e.discoverRid = 0; + com_codename1_impl_ios_IOSBluetooth_nativeBtServicesDiscovered___int_java_lang_String_java_lang_String( + getThreadLocalData(), rid, + cn1btJString(p.identifier.UUIDString), cn1btJString(db)); + } +} + +// ---- CBPeripheralDelegate: GATT client callbacks ---------------------------- + +- (void)peripheral:(CBPeripheral *)p + didUpdateValueForCharacteristic:(CBCharacteristic *)c + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + if (e == nil) { + return; + } + NSString *key = [self keyForChar:c in:p]; + NSNumber *rid = key != nil ? [e.pendingReads objectForKey:key] : nil; + if (rid != nil) { + // response to an app-issued read + [e.pendingReads removeObjectForKey:key]; + if (error != nil) { + cn1btSendNSError([rid intValue], error, CN1_BT_ERR_GATT); + } else { + com_codename1_impl_ios_IOSBluetooth_nativeBtValue___int_byte_1ARRAY( + getThreadLocalData(), [rid intValue], + cn1btJBytes(c.value)); + } + return; + } + if (error != nil || key == nil) { + return; + } + // unsolicited -> notification / indication + CBService *s = c.service; + NSUInteger si = [p.services indexOfObject:s]; + NSUInteger ci = [s.characteristics indexOfObject:c]; + com_codename1_impl_ios_IOSBluetooth_nativeBtNotification___java_lang_String_java_lang_String_int_java_lang_String_int_byte_1ARRAY( + getThreadLocalData(), + cn1btJString(p.identifier.UUIDString), + cn1btJString(s.UUID.UUIDString), (int)si, + cn1btJString(c.UUID.UUIDString), (int)ci, + cn1btJBytes(c.value)); +} + +- (void)peripheral:(CBPeripheral *)p + didWriteValueForCharacteristic:(CBCharacteristic *)c + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + NSString *key = [self keyForChar:c in:p]; + NSNumber *rid = e != nil && key != nil + ? [e.pendingWrites objectForKey:key] : nil; + if (rid == nil) { + return; + } + [e.pendingWrites removeObjectForKey:key]; + if (error != nil) { + cn1btSendNSError([rid intValue], error, CN1_BT_ERR_GATT); + } else { + cn1btSendOpComplete([rid intValue]); + } +} + +- (void)peripheral:(CBPeripheral *)p + didUpdateNotificationStateForCharacteristic:(CBCharacteristic *)c + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + NSString *key = [self keyForChar:c in:p]; + NSNumber *rid = e != nil && key != nil + ? [e.pendingNotifyState objectForKey:key] : nil; + if (rid == nil) { + return; + } + [e.pendingNotifyState removeObjectForKey:key]; + if (error != nil) { + cn1btSendNSError([rid intValue], error, CN1_BT_ERR_GATT); + } else { + cn1btSendOpComplete([rid intValue]); + } +} + +- (void)peripheral:(CBPeripheral *)p + didUpdateValueForDescriptor:(CBDescriptor *)d + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + NSString *key = [self keyForDesc:d in:p]; + NSNumber *rid = e != nil && key != nil + ? [e.pendingDescReads objectForKey:key] : nil; + if (rid == nil) { + return; + } + [e.pendingDescReads removeObjectForKey:key]; + if (error != nil) { + cn1btSendNSError([rid intValue], error, CN1_BT_ERR_GATT); + return; + } + // descriptor values surface as NSData / NSString / NSNumber depending + // on the UUID; normalize to bytes (numbers little-endian, 2 bytes) + NSData *data = nil; + id v = d.value; + if ([v isKindOfClass:[NSData class]]) { + data = v; + } else if ([v isKindOfClass:[NSString class]]) { + data = [(NSString *)v dataUsingEncoding:NSUTF8StringEncoding]; + } else if ([v isKindOfClass:[NSNumber class]]) { + unsigned short n = [(NSNumber *)v unsignedShortValue]; + unsigned char b[2] = {(unsigned char)(n & 0xFF), + (unsigned char)((n >> 8) & 0xFF)}; + data = [NSData dataWithBytes:b length:2]; + } + com_codename1_impl_ios_IOSBluetooth_nativeBtValue___int_byte_1ARRAY( + getThreadLocalData(), [rid intValue], cn1btJBytes(data)); +} + +- (void)peripheral:(CBPeripheral *)p + didWriteValueForDescriptor:(CBDescriptor *)d + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + NSString *key = [self keyForDesc:d in:p]; + NSNumber *rid = e != nil && key != nil + ? [e.pendingDescWrites objectForKey:key] : nil; + if (rid == nil) { + return; + } + [e.pendingDescWrites removeObjectForKey:key]; + if (error != nil) { + cn1btSendNSError([rid intValue], error, CN1_BT_ERR_GATT); + } else { + cn1btSendOpComplete([rid intValue]); + } +} + +- (void)peripheral:(CBPeripheral *)p didReadRSSI:(NSNumber *)RSSI + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + if (e == nil || e.pendingRssiRid <= 0) { + return; + } + int rid = e.pendingRssiRid; + e.pendingRssiRid = 0; + if (error != nil) { + cn1btSendNSError(rid, error, CN1_BT_ERR_GATT); + } else { + com_codename1_impl_ios_IOSBluetooth_nativeBtRssi___int_int( + getThreadLocalData(), rid, [RSSI intValue]); + } +} + +- (void)peripheral:(CBPeripheral *)p + didModifyServices:(NSArray *)invalidatedServices { + com_codename1_impl_ios_IOSBluetooth_nativeBtServicesInvalidated___java_lang_String( + getThreadLocalData(), + cn1btJString(p.identifier.UUIDString)); +} + +- (void)peripheral:(CBPeripheral *)p + didOpenL2CAPChannel:(CBL2CAPChannel *)channel + error:(NSError *)error { + CN1BtPeripheralEntry *e = [self entryForId:p.identifier.UUIDString]; + if (e == nil || [e.pendingL2capOpens count] == 0) { + return; + } + if (error != nil || channel == nil) { + NSDictionary *job = [e.pendingL2capOpens objectAtIndex:0]; + int rid = [[job objectForKey:@"rid"] intValue]; + [e.pendingL2capOpens removeObjectAtIndex:0]; + cn1btSendNSError(rid, error, CN1_BT_ERR_IO); + return; + } + if (@available(iOS 11.0, tvOS 11.0, watchOS 4.0, *)) { + int psm = (int)channel.PSM; + NSUInteger matchIdx = NSNotFound; + for (NSUInteger i = 0; i < [e.pendingL2capOpens count]; i++) { + NSDictionary *job = [e.pendingL2capOpens objectAtIndex:i]; + if ([[job objectForKey:@"psm"] intValue] == psm) { + matchIdx = i; + break; + } + } + if (matchIdx == NSNotFound) { + matchIdx = 0; + } + NSDictionary *job = [e.pendingL2capOpens objectAtIndex:matchIdx]; + int rid = [[job objectForKey:@"rid"] intValue]; + [e.pendingL2capOpens removeObjectAtIndex:matchIdx]; + long long handle = cn1btRegisterL2capChannel(channel); + com_codename1_impl_ios_IOSBluetooth_nativeBtL2capOpened___int_int_long( + getThreadLocalData(), rid, psm, (JAVA_LONG)handle); + } +} + +#ifdef CN1_BT_PERIPHERAL_ROLE + +// ---- peripheral-role operations (run on the Bluetooth queue) --------------- + +- (void)ensurePeripheralManager { + if (peripheralMgr == nil) { + peripheralMgr = [[CBPeripheralManager alloc] initWithDelegate:self + queue:cn1btGetQueue()]; + } +} + +- (int)localIdForCharacteristic:(CBCharacteristic *)c { + for (NSNumber *k in localChars) { + if ([localChars objectForKey:k] == (id)c) { + return [k intValue]; + } + } + return -1; +} + +- (long long)stashAttRequest:(CBATTRequest *)rq + batch:(NSMutableDictionary *)batch { + long long h = ++nextAttHandle; + NSMutableDictionary *e = [NSMutableDictionary dictionary]; + [e setObject:rq forKey:@"req"]; + if (batch != nil) { + [e setObject:batch forKey:@"batch"]; + } + [attRequests setObject:e forKey:[NSNumber numberWithLongLong:h]]; + return h; +} + +/** YES when the notify job was consumed (delivered or failed for good); + * NO when CoreBluetooth's update queue is full and it must be retried from + * peripheralManagerIsReadyToUpdateSubscribers. */ +- (BOOL)processNotifyJob:(NSDictionary *)job { + int rid = [[job objectForKey:@"rid"] intValue]; + CBMutableCharacteristic *ch = [localChars objectForKey: + [job objectForKey:@"cid"]]; + if (ch == nil) { + cn1btSendRequestError(rid, CN1_BT_ERR_GATT, + @"Characteristic is no longer registered"); + return YES; + } + NSArray *targets = nil; + id centralId = [job objectForKey:@"central"]; + if (centralId != nil && centralId != (id)[NSNull null]) { + CBCentral *target = [subscribedCentrals objectForKey:centralId]; + if (target == nil) { + cn1btSendRequestError(rid, CN1_BT_ERR_NOT_CONNECTED, + @"Central is not subscribed"); + return YES; + } + targets = [NSArray arrayWithObject:target]; + } + BOOL ok = [peripheralMgr updateValue:[job objectForKey:@"data"] + forCharacteristic:ch onSubscribedCentrals:targets]; + if (ok) { + cn1btSendOpComplete(rid); + return YES; + } + return NO; +} + +- (void)openGattServer:(int)requestId { + [self ensurePeripheralManager]; + if (peripheralMgr.state == CBManagerStatePoweredOn) { + com_codename1_impl_ios_IOSBluetooth_nativeBtGattServerOpened___int( + getThreadLocalData(), requestId); + } else if (peripheralMgr.state == CBManagerStateUnsupported) { + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"BLE peripheral role unsupported"); + } else if (peripheralMgr.state == CBManagerStateUnauthorized) { + cn1btSendRequestError(requestId, CN1_BT_ERR_UNAUTHORIZED, + @"Bluetooth permission denied"); + } else if (peripheralMgr.state == CBManagerStatePoweredOff) { + cn1btSendRequestError(requestId, CN1_BT_ERR_POWERED_OFF, + @"Bluetooth is powered off"); + } else { + // unknown / resetting: wait for peripheralManagerDidUpdateState + [pendingServerOpenRids addObject: + [NSNumber numberWithInt:requestId]]; + } +} + +/** Maps the Java GattLocalCharacteristic permission bits to + * CBAttributePermissions. */ ++ (CBAttributePermissions)mapPermissions:(int)javaPerms { + CBAttributePermissions p = 0; + if (javaPerms & 0x01) { + p |= CBAttributePermissionsReadable; + } + if (javaPerms & 0x02) { + p |= CBAttributePermissionsReadEncryptionRequired; + } + if (javaPerms & 0x10) { + p |= CBAttributePermissionsWriteable; + } + if (javaPerms & 0x20) { + p |= CBAttributePermissionsWriteEncryptionRequired; + } + if (p == 0) { + p = CBAttributePermissionsReadable; + } + return p; +} + +/** Parses the S|/C|/D| definition produced by IOSGattServer.doAddService + * and registers the CBMutableService. Static characteristic values are + * NOT handed to CoreBluetooth (its cached-value mode forces read-only + * characteristics); they are kept in `staticValues` and served from + * didReceiveReadRequest without a Java round trip. */ +- (void)addService:(int)requestId definition:(NSString *)def { + @autoreleasepool { + if (peripheralMgr == nil + || peripheralMgr.state != CBManagerStatePoweredOn) { + cn1btSendRequestError(requestId, CN1_BT_ERR_UNKNOWN, + @"GATT server is not open"); + return; + } + CBMutableService *svc = nil; + int sid = 0; + NSMutableArray *chars = [NSMutableArray array]; + CBMutableCharacteristic *curChar = nil; + NSMutableArray *curDescs = nil; + for (NSString *line in [def componentsSeparatedByString:@"\n"]) { + NSArray *f = [line componentsSeparatedByString:@"|"]; + if ([f count] < 2) { + continue; + } + NSString *kind = [f objectAtIndex:0]; + if ([kind isEqualToString:@"S"] && [f count] >= 4) { + sid = [[f objectAtIndex:1] intValue]; + CBUUID *u = cn1btUuid([f objectAtIndex:2]); + if (u == nil) { + break; + } + svc = [[[CBMutableService alloc] initWithType:u + primary:[[f objectAtIndex:3] intValue] != 0] + autorelease]; + } else if ([kind isEqualToString:@"C"] && [f count] >= 5 + && svc != nil) { + if (curChar != nil && [curDescs count] > 0) { + curChar.descriptors = curDescs; + } + int cid = [[f objectAtIndex:1] intValue]; + CBUUID *u = cn1btUuid([f objectAtIndex:2]); + if (u == nil) { + curChar = nil; + curDescs = nil; + continue; + } + int props = [[f objectAtIndex:3] intValue]; + int perms = [[f objectAtIndex:4] intValue]; + NSData *sv = [f count] >= 6 + ? cn1btDataFromHex([f objectAtIndex:5]) : nil; + curChar = [[[CBMutableCharacteristic alloc] + initWithType:u + properties:(CBCharacteristicProperties)props + value:nil + permissions:[CN1BluetoothController + mapPermissions:perms]] autorelease]; + curDescs = [NSMutableArray array]; + [chars addObject:curChar]; + NSNumber *cidKey = [NSNumber numberWithInt:cid]; + [localChars setObject:curChar forKey:cidKey]; + if (sv != nil) { + [staticValues setObject:sv forKey:cidKey]; + } + } else if ([kind isEqualToString:@"D"] && [f count] >= 4 + && curChar != nil) { + CBUUID *u = cn1btUuid([f objectAtIndex:2]); + NSData *dv = [f count] >= 5 + ? cn1btDataFromHex([f objectAtIndex:4]) : nil; + if (u != nil) { + @try { + // CBMutableDescriptor supports a limited UUID set + // and requires a static value + CBMutableDescriptor *d = + [[[CBMutableDescriptor alloc] + initWithType:u + value:(dv != nil ? (id)dv + : (id)[NSData data])] autorelease]; + [curDescs addObject:d]; + } @catch (NSException *ex) { + // unsupported descriptor type -- skip it + } + } + } + } + if (curChar != nil && [curDescs count] > 0) { + curChar.descriptors = curDescs; + } + if (svc == nil) { + cn1btSendRequestError(requestId, CN1_BT_ERR_UNKNOWN, + @"Malformed service definition"); + return; + } + svc.characteristics = chars; + [localServices setObject:svc forKey:[NSNumber numberWithInt:sid]]; + [pendingAddServiceRids setObject:[NSNumber numberWithInt:requestId] + forKey:[NSNumber numberWithUnsignedLongLong: + (unsigned long long)(uintptr_t)svc]]; + [peripheralMgr addService:svc]; + } +} + +- (void)removeServiceById:(int)serviceLocalId { + NSNumber *key = [NSNumber numberWithInt:serviceLocalId]; + CBMutableService *svc = [localServices objectForKey:key]; + if (svc == nil) { + return; + } + // drop the char registrations belonging to this service + NSMutableArray *toRemove = [NSMutableArray array]; + for (NSNumber *cidKey in localChars) { + id ch = [localChars objectForKey:cidKey]; + if ([svc.characteristics containsObject:ch]) { + [toRemove addObject:cidKey]; + } + } + for (NSNumber *cidKey in toRemove) { + [localChars removeObjectForKey:cidKey]; + [staticValues removeObjectForKey:cidKey]; + } + if (peripheralMgr != nil) { + [peripheralMgr removeService:svc]; + } + [localServices removeObjectForKey:key]; +} + +- (void)closeGattServer { + if (peripheralMgr != nil) { + [peripheralMgr removeAllServices]; + } + [localServices removeAllObjects]; + [localChars removeAllObjects]; + [staticValues removeAllObjects]; + [pendingNotifies removeAllObjects]; +} + +- (void)startAdvertising:(int)requestId name:(NSString *)name + uuids:(NSArray *)uuids { + [self ensurePeripheralManager]; + if (pendingAdvertiseRid > 0) { + cn1btSendRequestError(requestId, CN1_BT_ERR_ADVERTISE_FAILED, + @"An advertising start is already pending"); + return; + } + NSMutableDictionary *ad = [NSMutableDictionary dictionary]; + if (uuids != nil) { + [ad setObject:uuids forKey:CBAdvertisementDataServiceUUIDsKey]; + } + if (name != nil) { + NSString *resolved = name; + if ([resolved length] == 0) { + resolved = [UIDevice currentDevice].name; + } + if (resolved != nil) { + [ad setObject:resolved forKey:CBAdvertisementDataLocalNameKey]; + } + } + pendingAdvertiseRid = requestId; + [pendingAdvertiseData release]; + pendingAdvertiseData = [ad retain]; + if (peripheralMgr.state == CBManagerStatePoweredOn) { + advertiseSubmitted = YES; + [peripheralMgr startAdvertising:ad]; + } else { + advertiseSubmitted = NO; + // peripheralManagerDidUpdateState starts it on poweredOn + } +} + +- (void)stopAdvertising { + if (peripheralMgr != nil) { + [peripheralMgr stopAdvertising]; + } + pendingAdvertiseRid = 0; + advertiseSubmitted = NO; + [pendingAdvertiseData release]; + pendingAdvertiseData = nil; +} + +- (void)notifyValue:(int)requestId charId:(int)charLocalId + data:(NSData *)data central:(NSString *)centralId { + if (peripheralMgr == nil + || peripheralMgr.state != CBManagerStatePoweredOn) { + cn1btSendRequestError(requestId, CN1_BT_ERR_UNKNOWN, + @"GATT server is not open"); + return; + } + NSMutableDictionary *job = [NSMutableDictionary dictionary]; + [job setObject:[NSNumber numberWithInt:requestId] forKey:@"rid"]; + [job setObject:[NSNumber numberWithInt:charLocalId] forKey:@"cid"]; + [job setObject:data forKey:@"data"]; + [job setObject:(centralId != nil ? (id)centralId : (id)[NSNull null]) + forKey:@"central"]; + if (![self processNotifyJob:job]) { + [pendingNotifies addObject:job]; + } +} + +- (void)respondToRead:(long long)handle data:(NSData *)data + status:(int)attStatus { + NSNumber *key = [NSNumber numberWithLongLong:handle]; + NSDictionary *entry = [[[attRequests objectForKey:key] retain] + autorelease]; + if (entry == nil || peripheralMgr == nil) { + return; + } + [attRequests removeObjectForKey:key]; + CBATTRequest *rq = [entry objectForKey:@"req"]; + if (attStatus != 0) { + [peripheralMgr respondToRequest:rq + withResult:(CBATTError)attStatus]; + } else { + rq.value = data != nil ? data : [NSData data]; + [peripheralMgr respondToRequest:rq withResult:CBATTErrorSuccess]; + } +} + +- (void)respondToBatch:(NSMutableDictionary *)batch status:(int)attStatus { + if ([[batch objectForKey:@"responded"] boolValue]) { + return; + } + CBATTRequest *first = [batch objectForKey:@"first"]; + if (attStatus != 0) { + [batch setObject:[NSNumber numberWithBool:YES] + forKey:@"responded"]; + [peripheralMgr respondToRequest:first + withResult:(CBATTError)attStatus]; + return; + } + int remaining = [[batch objectForKey:@"remaining"] intValue] - 1; + [batch setObject:[NSNumber numberWithInt:remaining] + forKey:@"remaining"]; + if (remaining <= 0) { + [batch setObject:[NSNumber numberWithBool:YES] + forKey:@"responded"]; + [peripheralMgr respondToRequest:first withResult:CBATTErrorSuccess]; + } +} + +- (void)respondToWrite:(long long)handle status:(int)attStatus { + NSNumber *key = [NSNumber numberWithLongLong:handle]; + NSDictionary *entry = [[[attRequests objectForKey:key] retain] + autorelease]; + if (entry == nil || peripheralMgr == nil) { + return; + } + [attRequests removeObjectForKey:key]; + NSMutableDictionary *batch = [entry objectForKey:@"batch"]; + if (batch != nil) { + [self respondToBatch:batch status:attStatus]; + } else { + CBATTRequest *rq = [entry objectForKey:@"req"]; + [peripheralMgr respondToRequest:rq withResult:(attStatus != 0 + ? (CBATTError)attStatus : CBATTErrorSuccess)]; + } +} + +- (void)publishL2cap:(int)requestId secure:(BOOL)secure { + [self ensurePeripheralManager]; + if (@available(iOS 11.0, *)) { + if (peripheralMgr.state == CBManagerStatePoweredOn) { + [publishAwaitingCallback addObject: + [NSNumber numberWithInt:requestId]]; + [peripheralMgr publishL2CAPChannelWithEncryption:secure]; + } else { + NSMutableDictionary *job = [NSMutableDictionary dictionary]; + [job setObject:[NSNumber numberWithInt:requestId] + forKey:@"rid"]; + [job setObject:[NSNumber numberWithBool:secure] + forKey:@"secure"]; + [pendingPublishRequests addObject:job]; + } + } else { + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"L2CAP requires iOS 11+"); + } +} + +- (void)unpublishL2cap:(int)psm { + if (peripheralMgr != nil) { + if (@available(iOS 11.0, *)) { + [peripheralMgr unpublishL2CAPChannel:(CBL2CAPPSM)psm]; + } + } +} + +// ---- CBPeripheralManagerDelegate --------------------------------------------- + +- (void)peripheralManagerDidUpdateState:(CBPeripheralManager *)mgr { + CBManagerState state = mgr.state; + // also forward through the adapter-state pipe so permission requests + // resolve even when only the peripheral role was used + com_codename1_impl_ios_IOSBluetooth_nativeBtStateChanged___int_int( + getThreadLocalData(), (int)state, cn1btAuthorizationValue()); + if (state == CBManagerStatePoweredOn) { + for (NSNumber *rid in pendingServerOpenRids) { + com_codename1_impl_ios_IOSBluetooth_nativeBtGattServerOpened___int( + getThreadLocalData(), [rid intValue]); + } + [pendingServerOpenRids removeAllObjects]; + if (pendingAdvertiseRid > 0 && !advertiseSubmitted + && pendingAdvertiseData != nil) { + advertiseSubmitted = YES; + [mgr startAdvertising:pendingAdvertiseData]; + } + if (@available(iOS 11.0, *)) { + for (NSDictionary *job in pendingPublishRequests) { + [publishAwaitingCallback addObject: + [job objectForKey:@"rid"]]; + [mgr publishL2CAPChannelWithEncryption: + [[job objectForKey:@"secure"] boolValue]]; + } + } + [pendingPublishRequests removeAllObjects]; + return; + } + int code; + if (state == CBManagerStateUnsupported) { + code = CN1_BT_ERR_NOT_SUPPORTED; + } else if (state == CBManagerStateUnauthorized) { + code = CN1_BT_ERR_UNAUTHORIZED; + } else if (state == CBManagerStatePoweredOff) { + code = CN1_BT_ERR_POWERED_OFF; + } else { + return; // unknown / resetting: wait for a definitive state + } + NSString *msg = [NSString stringWithFormat: + @"Bluetooth peripheral manager state %d", (int)state]; + for (NSNumber *rid in pendingServerOpenRids) { + cn1btSendRequestError([rid intValue], code, msg); + } + [pendingServerOpenRids removeAllObjects]; + if (pendingAdvertiseRid > 0 && !advertiseSubmitted) { + cn1btSendRequestError(pendingAdvertiseRid, code, msg); + pendingAdvertiseRid = 0; + [pendingAdvertiseData release]; + pendingAdvertiseData = nil; + } + for (NSDictionary *job in pendingPublishRequests) { + cn1btSendRequestError([[job objectForKey:@"rid"] intValue], code, + msg); + } + [pendingPublishRequests removeAllObjects]; +} + +- (void)peripheralManager:(CBPeripheralManager *)mgr + didAddService:(CBService *)service error:(NSError *)error { + NSNumber *key = [NSNumber numberWithUnsignedLongLong: + (unsigned long long)(uintptr_t)service]; + NSNumber *rid = [pendingAddServiceRids objectForKey:key]; + if (rid == nil) { + return; + } + [pendingAddServiceRids removeObjectForKey:key]; + if (error != nil) { + cn1btSendNSError([rid intValue], error, CN1_BT_ERR_GATT); + } else { + cn1btSendOpComplete([rid intValue]); + } +} + +- (void)peripheralManagerDidStartAdvertising:(CBPeripheralManager *)mgr + error:(NSError *)error { + int rid = pendingAdvertiseRid; + pendingAdvertiseRid = 0; + advertiseSubmitted = NO; + [pendingAdvertiseData release]; + pendingAdvertiseData = nil; + if (rid <= 0) { + return; + } + if (error != nil) { + cn1btSendNSError(rid, error, CN1_BT_ERR_ADVERTISE_FAILED); + } else { + com_codename1_impl_ios_IOSBluetooth_nativeBtAdvertiseStarted___int( + getThreadLocalData(), rid); + } +} + +- (void)peripheralManager:(CBPeripheralManager *)mgr + didReceiveReadRequest:(CBATTRequest *)request { + int cid = [self localIdForCharacteristic:request.characteristic]; + if (cid < 0) { + [mgr respondToRequest:request + withResult:CBATTErrorAttributeNotFound]; + return; + } + // serve app-supplied static values without a Java round trip, + // matching the Android port's static-value behavior + NSData *sv = [staticValues objectForKey:[NSNumber numberWithInt:cid]]; + if (sv != nil) { + if (request.offset > [sv length]) { + [mgr respondToRequest:request + withResult:CBATTErrorInvalidOffset]; + } else { + request.value = [sv subdataWithRange:NSMakeRange(request.offset, + [sv length] - request.offset)]; + [mgr respondToRequest:request withResult:CBATTErrorSuccess]; + } + return; + } + long long h = [self stashAttRequest:request batch:nil]; + com_codename1_impl_ios_IOSBluetooth_nativeBtReadRequest___long_java_lang_String_int_int_int( + getThreadLocalData(), (JAVA_LONG)h, + cn1btJString(request.central.identifier.UUIDString), cid, -1, + (int)request.offset); +} + +- (void)peripheralManager:(CBPeripheralManager *)mgr + didReceiveWriteRequests:(NSArray *)requests { + if ([requests count] == 0) { + return; + } + // Apple contract: respond exactly once, to the first request, covering + // the whole batch. The batch dict tracks outstanding per-request + // responses from Java; the first error (or the last success) answers. + NSMutableDictionary *batch = [NSMutableDictionary dictionary]; + [batch setObject:[requests objectAtIndex:0] forKey:@"first"]; + [batch setObject:[NSNumber numberWithInt:(int)[requests count]] + forKey:@"remaining"]; + [batch setObject:[NSNumber numberWithBool:NO] forKey:@"responded"]; + for (CBATTRequest *rq in requests) { + int cid = [self localIdForCharacteristic:rq.characteristic]; + long long h = [self stashAttRequest:rq batch:batch]; + com_codename1_impl_ios_IOSBluetooth_nativeBtWriteRequest___long_java_lang_String_int_int_byte_1ARRAY_int_boolean( + getThreadLocalData(), (JAVA_LONG)h, + cn1btJString(rq.central.identifier.UUIDString), cid, -1, + cn1btJBytes(rq.value), (int)rq.offset, 1); + } +} + +- (void)peripheralManager:(CBPeripheralManager *)mgr + central:(CBCentral *)c + didSubscribeToCharacteristic:(CBCharacteristic *)characteristic { + NSString *centralId = c.identifier.UUIDString; + [subscribedCentrals setObject:c forKey:centralId]; + int cid = [self localIdForCharacteristic:characteristic]; + if (cid < 0) { + return; + } + com_codename1_impl_ios_IOSBluetooth_nativeBtSubscriptionChanged___java_lang_String_int_int_boolean( + getThreadLocalData(), cn1btJString(centralId), + (int)c.maximumUpdateValueLength + 3, cid, 1); +} + +- (void)peripheralManager:(CBPeripheralManager *)mgr + central:(CBCentral *)c + didUnsubscribeFromCharacteristic:(CBCharacteristic *)characteristic { + int cid = [self localIdForCharacteristic:characteristic]; + if (cid < 0) { + return; + } + com_codename1_impl_ios_IOSBluetooth_nativeBtSubscriptionChanged___java_lang_String_int_int_boolean( + getThreadLocalData(), + cn1btJString(c.identifier.UUIDString), + (int)c.maximumUpdateValueLength + 3, cid, 0); +} + +- (void)peripheralManagerIsReadyToUpdateSubscribers: + (CBPeripheralManager *)mgr { + while ([pendingNotifies count] > 0) { + NSDictionary *job = [pendingNotifies objectAtIndex:0]; + if (![self processNotifyJob:job]) { + break; // transmit queue filled up again; wait for next ready + } + [pendingNotifies removeObjectAtIndex:0]; + } +} + +- (void)peripheralManager:(CBPeripheralManager *)mgr + didPublishL2CAPChannel:(CBL2CAPPSM)psm error:(NSError *)error { + if ([publishAwaitingCallback count] == 0) { + return; + } + int rid = [[publishAwaitingCallback objectAtIndex:0] intValue]; + [publishAwaitingCallback removeObjectAtIndex:0]; + if (error != nil) { + cn1btSendNSError(rid, error, CN1_BT_ERR_IO); + return; + } + com_codename1_impl_ios_IOSBluetooth_nativeBtL2capPublished___int_int( + getThreadLocalData(), rid, (int)psm); +} + +- (void)peripheralManager:(CBPeripheralManager *)mgr + didOpenL2CAPChannel:(CBL2CAPChannel *)channel + error:(NSError *)error { + if (error != nil || channel == nil) { + return; // nothing to route -- the central side sees its own error + } + if (@available(iOS 11.0, *)) { + long long handle = cn1btRegisterL2capChannel(channel); + com_codename1_impl_ios_IOSBluetooth_nativeBtL2capIncoming___int_long( + getThreadLocalData(), (int)channel.PSM, (JAVA_LONG)handle); + } +} + +#endif // CN1_BT_PERIPHERAL_ROLE + +@end + +// -------------------------------------------------------------------------- +// IOSNative trampolines (CN1_INCLUDE_BLUETOOTH enabled) + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBlePeripheralSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_BT_PERIPHERAL_ROLE + return JAVA_TRUE; +#else + return JAVA_FALSE; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_getBluetoothAuthorization___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return cn1btAuthorizationValue(); +} + +void com_codename1_impl_ios_IOSNative_startBluetoothStateMonitor__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + cn1btAsync(^{ + [cn1btGetController() ensureCentral]; + }); +} + +void com_codename1_impl_ios_IOSNative_btStartScan___java_lang_String_1ARRAY_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT serviceUuids, + JAVA_BOOLEAN allowDuplicates) { + NSArray *uuids = cn1btUuidArray(threadStateData, serviceUuids); + BOOL dup = allowDuplicates ? YES : NO; + cn1btAsync(^{ + [cn1btGetController() startScanUuids:uuids duplicates:dup]; + }); +} + +void com_codename1_impl_ios_IOSNative_btStopScan__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + cn1btAsync(^{ + [cn1btGetController() stopScan]; + }); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_btRetrievePeripheral___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId) { + NSString *pid = toNSString(threadStateData, peripheralId); + if (pid == nil) { + return JAVA_NULL; + } + __block NSString *name = nil; + cn1btSync(^{ + NSString *n = [cn1btGetController() retrievePeripheral:pid]; + name = [n copy]; + }); + if (name == nil) { + return JAVA_NULL; + } + JAVA_OBJECT out = fromNSString(threadStateData, name); + [name release]; + return out; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_btGetKnownPeripherals___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT serviceUuid) { + CBUUID *u = cn1btUuid(toNSString(threadStateData, serviceUuid)); + if (u == nil) { + return JAVA_NULL; + } + __block NSString *result = nil; + cn1btSync(^{ + NSString *r = [cn1btGetController() connectedPeripheralsFor:u]; + result = [r copy]; + }); + if (result == nil) { + return JAVA_NULL; + } + JAVA_OBJECT out = fromNSString(threadStateData, result); + [result release]; + return out; +} + +void com_codename1_impl_ios_IOSNative_btConnect___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId) { + NSString *pid = toNSString(threadStateData, peripheralId); + cn1btAsync(^{ + [cn1btGetController() connectPeripheral:pid]; + }); +} + +void com_codename1_impl_ios_IOSNative_btDisconnect___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId) { + NSString *pid = toNSString(threadStateData, peripheralId); + cn1btAsync(^{ + [cn1btGetController() disconnectPeripheral:pid]; + }); +} + +void com_codename1_impl_ios_IOSNative_btDiscoverServices___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId) { + NSString *pid = toNSString(threadStateData, peripheralId); + cn1btAsync(^{ + [cn1btGetController() discoverServices:requestId peripheral:pid]; + }); +} + +void com_codename1_impl_ios_IOSNative_btReadCharacteristic___int_java_lang_String_java_lang_String_int_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance) { + NSString *pid = toNSString(threadStateData, peripheralId); + NSString *su = toNSString(threadStateData, serviceUuid); + NSString *cu = toNSString(threadStateData, charUuid); + cn1btAsync(^{ + [cn1btGetController() withChar:requestId peripheral:pid svcUuid:su + svcInst:serviceInstance charUuid:cu charInst:charInstance + op:^(CN1BtPeripheralEntry *e, CBCharacteristic *ch, + NSString *key) { + [e.pendingReads setObject:[NSNumber numberWithInt:requestId] + forKey:key]; + [e.peripheral readValueForCharacteristic:ch]; + }]; + }); +} + +void com_codename1_impl_ios_IOSNative_btWriteCharacteristic___int_java_lang_String_java_lang_String_int_java_lang_String_int_byte_1ARRAY_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_OBJECT value, + JAVA_BOOLEAN withResponse) { + NSString *pid = toNSString(threadStateData, peripheralId); + NSString *su = toNSString(threadStateData, serviceUuid); + NSString *cu = toNSString(threadStateData, charUuid); + NSData *data = cn1btDataFromJavaArray(value); + BOOL wr = withResponse ? YES : NO; + cn1btAsync(^{ + [cn1btGetController() withChar:requestId peripheral:pid svcUuid:su + svcInst:serviceInstance charUuid:cu charInst:charInstance + op:^(CN1BtPeripheralEntry *e, CBCharacteristic *ch, + NSString *key) { + if (wr) { + [e.pendingWrites setObject: + [NSNumber numberWithInt:requestId] forKey:key]; + [e.peripheral writeValue:data forCharacteristic:ch + type:CBCharacteristicWriteWithResponse]; + } else { + [e.peripheral writeValue:data forCharacteristic:ch + type:CBCharacteristicWriteWithoutResponse]; + // no delegate callback for write-without-response + cn1btSendOpComplete(requestId); + } + }]; + }); +} + +void com_codename1_impl_ios_IOSNative_btReadDescriptor___int_java_lang_String_java_lang_String_int_java_lang_String_int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_OBJECT descriptorUuid) { + NSString *pid = toNSString(threadStateData, peripheralId); + NSString *su = toNSString(threadStateData, serviceUuid); + NSString *cu = toNSString(threadStateData, charUuid); + NSString *du = toNSString(threadStateData, descriptorUuid); + cn1btAsync(^{ + CN1BluetoothController *ctl = cn1btGetController(); + [ctl withChar:requestId peripheral:pid svcUuid:su + svcInst:serviceInstance charUuid:cu charInst:charInstance + op:^(CN1BtPeripheralEntry *e, CBCharacteristic *ch, + NSString *key) { + CBDescriptor *d = [ctl descIn:ch uuid:du]; + if (d == nil) { + cn1btSendRequestError(requestId, CN1_BT_ERR_GATT, + @"Descriptor not found"); + return; + } + NSString *dk = [ctl keyForDesc:d in:e.peripheral]; + if (dk != nil) { + [e.pendingDescReads setObject: + [NSNumber numberWithInt:requestId] forKey:dk]; + } + [e.peripheral readValueForDescriptor:d]; + }]; + }); +} + +void com_codename1_impl_ios_IOSNative_btWriteDescriptor___int_java_lang_String_java_lang_String_int_java_lang_String_int_java_lang_String_byte_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_OBJECT descriptorUuid, + JAVA_OBJECT value) { + NSString *pid = toNSString(threadStateData, peripheralId); + NSString *su = toNSString(threadStateData, serviceUuid); + NSString *cu = toNSString(threadStateData, charUuid); + NSString *du = toNSString(threadStateData, descriptorUuid); + NSData *data = cn1btDataFromJavaArray(value); + cn1btAsync(^{ + CN1BluetoothController *ctl = cn1btGetController(); + [ctl withChar:requestId peripheral:pid svcUuid:su + svcInst:serviceInstance charUuid:cu charInst:charInstance + op:^(CN1BtPeripheralEntry *e, CBCharacteristic *ch, + NSString *key) { + CBDescriptor *d = [ctl descIn:ch uuid:du]; + if (d == nil) { + cn1btSendRequestError(requestId, CN1_BT_ERR_GATT, + @"Descriptor not found"); + return; + } + NSString *dk = [ctl keyForDesc:d in:e.peripheral]; + if (dk != nil) { + [e.pendingDescWrites setObject: + [NSNumber numberWithInt:requestId] forKey:dk]; + } + [e.peripheral writeValue:data forDescriptor:d]; + }]; + }); +} + +void com_codename1_impl_ios_IOSNative_btSetNotify___int_java_lang_String_java_lang_String_int_java_lang_String_int_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_BOOLEAN enable) { + NSString *pid = toNSString(threadStateData, peripheralId); + NSString *su = toNSString(threadStateData, serviceUuid); + NSString *cu = toNSString(threadStateData, charUuid); + BOOL en = enable ? YES : NO; + cn1btAsync(^{ + [cn1btGetController() withChar:requestId peripheral:pid svcUuid:su + svcInst:serviceInstance charUuid:cu charInst:charInstance + op:^(CN1BtPeripheralEntry *e, CBCharacteristic *ch, + NSString *key) { + [e.pendingNotifyState setObject: + [NSNumber numberWithInt:requestId] forKey:key]; + [e.peripheral setNotifyValue:en forCharacteristic:ch]; + }]; + }); +} + +void com_codename1_impl_ios_IOSNative_btReadRssi___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId) { + NSString *pid = toNSString(threadStateData, peripheralId); + cn1btAsync(^{ + [cn1btGetController() readRssi:requestId peripheral:pid]; + }); +} + +JAVA_INT com_codename1_impl_ios_IOSNative_btGetMaxWriteLength___java_lang_String_boolean_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId, + JAVA_BOOLEAN withResponse) { + NSString *pid = toNSString(threadStateData, peripheralId); + BOOL wr = withResponse ? YES : NO; + __block int result = 0; + cn1btSync(^{ + result = [cn1btGetController() maxWriteLength:pid withResponse:wr]; + }); + return result; +} + +void com_codename1_impl_ios_IOSNative_btOpenL2cap___int_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_INT psm) { + NSString *pid = toNSString(threadStateData, peripheralId); + cn1btAsync(^{ + [cn1btGetController() openL2cap:requestId peripheral:pid psm:psm]; + }); +} + +// ---- peripheral role trampolines ------------------------------------------- + +void com_codename1_impl_ios_IOSNative_btOpenGattServer___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId) { +#ifdef CN1_BT_PERIPHERAL_ROLE + cn1btAsync(^{ + [cn1btGetController() openGattServer:requestId]; + }); +#else + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"BLE peripheral role is unavailable on this platform"); +#endif +} + +void com_codename1_impl_ios_IOSNative_btAddService___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT serviceDefinition) { +#ifdef CN1_BT_PERIPHERAL_ROLE + NSString *def = toNSString(threadStateData, serviceDefinition); + cn1btAsync(^{ + [cn1btGetController() addService:requestId definition:def]; + }); +#else + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"BLE peripheral role is unavailable on this platform"); +#endif +} + +void com_codename1_impl_ios_IOSNative_btRemoveService___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT serviceLocalId) { +#ifdef CN1_BT_PERIPHERAL_ROLE + cn1btAsync(^{ + [cn1btGetController() removeServiceById:serviceLocalId]; + }); +#endif +} + +void com_codename1_impl_ios_IOSNative_btCloseGattServer__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_BT_PERIPHERAL_ROLE + cn1btAsync(^{ + [cn1btGetController() closeGattServer]; + }); +#endif +} + +void com_codename1_impl_ios_IOSNative_btStartAdvertising___int_java_lang_String_java_lang_String_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT localName, JAVA_OBJECT serviceUuids) { +#ifdef CN1_BT_PERIPHERAL_ROLE + NSString *name = localName == JAVA_NULL + ? nil : toNSString(threadStateData, localName); + NSArray *uuids = cn1btUuidArray(threadStateData, serviceUuids); + cn1btAsync(^{ + [cn1btGetController() startAdvertising:requestId name:name + uuids:uuids]; + }); +#else + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"BLE peripheral role is unavailable on this platform"); +#endif +} + +void com_codename1_impl_ios_IOSNative_btStopAdvertising__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_BT_PERIPHERAL_ROLE + cn1btAsync(^{ + [cn1btGetController() stopAdvertising]; + }); +#endif +} + +void com_codename1_impl_ios_IOSNative_btNotifyValue___int_int_byte_1ARRAY_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT charLocalId, JAVA_OBJECT value, JAVA_OBJECT centralId) { +#ifdef CN1_BT_PERIPHERAL_ROLE + NSData *data = cn1btDataFromJavaArray(value); + NSString *cid = centralId == JAVA_NULL + ? nil : toNSString(threadStateData, centralId); + cn1btAsync(^{ + [cn1btGetController() notifyValue:requestId charId:charLocalId + data:data central:cid]; + }); +#else + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"BLE peripheral role is unavailable on this platform"); +#endif +} + +void com_codename1_impl_ios_IOSNative_btRespondToReadRequest___long_byte_1ARRAY_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG requestHandle, + JAVA_OBJECT value, JAVA_INT attStatus) { +#ifdef CN1_BT_PERIPHERAL_ROLE + NSData *data = value == JAVA_NULL ? nil : cn1btDataFromJavaArray(value); + long long h = (long long)requestHandle; + cn1btAsync(^{ + [cn1btGetController() respondToRead:h data:data status:attStatus]; + }); +#endif +} + +void com_codename1_impl_ios_IOSNative_btRespondToWriteRequest___long_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG requestHandle, + JAVA_INT attStatus) { +#ifdef CN1_BT_PERIPHERAL_ROLE + long long h = (long long)requestHandle; + cn1btAsync(^{ + [cn1btGetController() respondToWrite:h status:attStatus]; + }); +#endif +} + +void com_codename1_impl_ios_IOSNative_btPublishL2cap___int_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_BOOLEAN secure) { +#ifdef CN1_BT_PERIPHERAL_ROLE + BOOL sec = secure ? YES : NO; + cn1btAsync(^{ + [cn1btGetController() publishL2cap:requestId secure:sec]; + }); +#else + cn1btSendRequestError(requestId, CN1_BT_ERR_NOT_SUPPORTED, + @"BLE peripheral role is unavailable on this platform"); +#endif +} + +void com_codename1_impl_ios_IOSNative_btUnpublishL2cap___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT psm) { +#ifdef CN1_BT_PERIPHERAL_ROLE + cn1btAsync(^{ + [cn1btGetController() unpublishL2cap:psm]; + }); +#endif +} + +// ---- L2CAP stream I/O ------------------------------------------------------- + +JAVA_INT com_codename1_impl_ios_IOSNative_btL2capRead___long_byte_1ARRAY_int_int_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG channelHandle, + JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT len) { + CN1BtL2capChannel *w = cn1btChannelForHandle((long long)channelHandle); + if (w == nil || buffer == JAVA_NULL || len <= 0) { + return -2; + } + NSInputStream *in = w.input; + for (;;) { + if (w.closed) { + return -1; + } + NSStreamStatus st = in.streamStatus; + if (st == NSStreamStatusAtEnd || st == NSStreamStatusClosed) { + return -1; + } + if (st == NSStreamStatusError) { + return -2; + } + if (in.hasBytesAvailable) { + break; + } + usleep(10000); // 10ms poll; blocking-read contract, off the BT queue + } + JAVA_ARRAY a = (JAVA_ARRAY)buffer; + if (offset < 0 || offset + len > a->length) { + return -2; + } + NSInteger n = [in read:((uint8_t *)a->data) + offset + maxLength:(NSUInteger)len]; + if (n == 0) { + return -1; + } + if (n < 0) { + return w.closed ? -1 : -2; + } + return (JAVA_INT)n; +} + +JAVA_INT com_codename1_impl_ios_IOSNative_btL2capWrite___long_byte_1ARRAY_int_int_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG channelHandle, + JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT len) { + CN1BtL2capChannel *w = cn1btChannelForHandle((long long)channelHandle); + if (w == nil || buffer == JAVA_NULL || len <= 0) { + return -2; + } + NSOutputStream *out = w.output; + for (;;) { + if (w.closed) { + return -2; + } + NSStreamStatus st = out.streamStatus; + if (st == NSStreamStatusAtEnd || st == NSStreamStatusClosed + || st == NSStreamStatusError) { + return -2; + } + if (out.hasSpaceAvailable) { + break; + } + usleep(10000); + } + JAVA_ARRAY a = (JAVA_ARRAY)buffer; + if (offset < 0 || offset + len > a->length) { + return -2; + } + NSInteger n = [out write:((const uint8_t *)a->data) + offset + maxLength:(NSUInteger)len]; + if (n <= 0) { + return -2; + } + return (JAVA_INT)n; +} + +void com_codename1_impl_ios_IOSNative_btL2capClose___long( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG channelHandle) { + NSMutableDictionary *dict = cn1btChannels(); + CN1BtL2capChannel *w = nil; + @synchronized (dict) { + NSNumber *key = [NSNumber numberWithLongLong: + (long long)channelHandle]; + w = [[[dict objectForKey:key] retain] autorelease]; + [dict removeObjectForKey:key]; + } + if (w != nil) { + w.closed = YES; + [w.input close]; + [w.output close]; + } +} + +#else // CN1_INCLUDE_BLUETOOTH not defined ------------------------------------ + +// Stubs when CN1_INCLUDE_BLUETOOTH is not defined: the app never referenced +// com.codename1.bluetooth.*, so IOSBluetooth never calls these -- but +// ParparVM still needs the symbols to satisfy the IOSNative declarations at +// link time. No CoreBluetooth symbol is referenced on this path. + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBlePeripheralSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_INT com_codename1_impl_ios_IOSNative_getBluetoothAuthorization___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return 2; // denied +} + +void com_codename1_impl_ios_IOSNative_startBluetoothStateMonitor__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +void com_codename1_impl_ios_IOSNative_btStartScan___java_lang_String_1ARRAY_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT serviceUuids, + JAVA_BOOLEAN allowDuplicates) { +} + +void com_codename1_impl_ios_IOSNative_btStopScan__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_btRetrievePeripheral___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId) { + return JAVA_NULL; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_btGetKnownPeripherals___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT serviceUuid) { + return JAVA_NULL; +} + +void com_codename1_impl_ios_IOSNative_btConnect___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId) { +} + +void com_codename1_impl_ios_IOSNative_btDisconnect___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId) { +} + +void com_codename1_impl_ios_IOSNative_btDiscoverServices___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId) { +} + +void com_codename1_impl_ios_IOSNative_btReadCharacteristic___int_java_lang_String_java_lang_String_int_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance) { +} + +void com_codename1_impl_ios_IOSNative_btWriteCharacteristic___int_java_lang_String_java_lang_String_int_java_lang_String_int_byte_1ARRAY_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_OBJECT value, + JAVA_BOOLEAN withResponse) { +} + +void com_codename1_impl_ios_IOSNative_btReadDescriptor___int_java_lang_String_java_lang_String_int_java_lang_String_int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_OBJECT descriptorUuid) { +} + +void com_codename1_impl_ios_IOSNative_btWriteDescriptor___int_java_lang_String_java_lang_String_int_java_lang_String_int_java_lang_String_byte_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_OBJECT descriptorUuid, + JAVA_OBJECT value) { +} + +void com_codename1_impl_ios_IOSNative_btSetNotify___int_java_lang_String_java_lang_String_int_java_lang_String_int_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_OBJECT serviceUuid, + JAVA_INT serviceInstance, JAVA_OBJECT charUuid, + JAVA_INT charInstance, JAVA_BOOLEAN enable) { +} + +void com_codename1_impl_ios_IOSNative_btReadRssi___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId) { +} + +JAVA_INT com_codename1_impl_ios_IOSNative_btGetMaxWriteLength___java_lang_String_boolean_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT peripheralId, + JAVA_BOOLEAN withResponse) { + return 0; +} + +void com_codename1_impl_ios_IOSNative_btOpenGattServer___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId) { +} + +void com_codename1_impl_ios_IOSNative_btAddService___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT serviceDefinition) { +} + +void com_codename1_impl_ios_IOSNative_btRemoveService___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT serviceLocalId) { +} + +void com_codename1_impl_ios_IOSNative_btCloseGattServer__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +void com_codename1_impl_ios_IOSNative_btStartAdvertising___int_java_lang_String_java_lang_String_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT localName, JAVA_OBJECT serviceUuids) { +} + +void com_codename1_impl_ios_IOSNative_btStopAdvertising__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +void com_codename1_impl_ios_IOSNative_btNotifyValue___int_int_byte_1ARRAY_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT charLocalId, JAVA_OBJECT value, JAVA_OBJECT centralId) { +} + +void com_codename1_impl_ios_IOSNative_btRespondToReadRequest___long_byte_1ARRAY_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG requestHandle, + JAVA_OBJECT value, JAVA_INT attStatus) { +} + +void com_codename1_impl_ios_IOSNative_btRespondToWriteRequest___long_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG requestHandle, + JAVA_INT attStatus) { +} + +void com_codename1_impl_ios_IOSNative_btOpenL2cap___int_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT peripheralId, JAVA_INT psm) { +} + +void com_codename1_impl_ios_IOSNative_btPublishL2cap___int_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_BOOLEAN secure) { +} + +void com_codename1_impl_ios_IOSNative_btUnpublishL2cap___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT psm) { +} + +JAVA_INT com_codename1_impl_ios_IOSNative_btL2capRead___long_byte_1ARRAY_int_int_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG channelHandle, + JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT len) { + return -2; +} + +JAVA_INT com_codename1_impl_ios_IOSNative_btL2capWrite___long_byte_1ARRAY_int_int_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG channelHandle, + JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT len) { + return -2; +} + +void com_codename1_impl_ios_IOSNative_btL2capClose___long( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_LONG channelHandle) { +} + +#endif // CN1_INCLUDE_BLUETOOTH diff --git a/Ports/iOSPort/nativeSources/CN1WebAuthn.m b/Ports/iOSPort/nativeSources/CN1WebAuthn.m index 3922132bcbb..2861ae6837e 100644 --- a/Ports/iOSPort/nativeSources/CN1WebAuthn.m +++ b/Ports/iOSPort/nativeSources/CN1WebAuthn.m @@ -228,7 +228,7 @@ - (void)authorizationController:(ASAuthorizationController *)controller static id g_cn1WebAuthnCurrentDelegate = nil; static id g_cn1WebAuthnCurrentController = nil; -JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_webauthnSupported__( +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_webauthnSupported___R_boolean( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { if (@available(iOS 16.0, *)) { return NSClassFromString(@"ASAuthorizationPlatformPublicKeyCredentialProvider") != nil @@ -252,7 +252,7 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_webauthnSupported__( return [obj isKindOfClass:[NSDictionary class]] ? (NSDictionary *)obj : nil; } -JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnCreate___java_lang_String( +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnCreate___java_lang_String_R_java_lang_String( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { if (@available(iOS 16.0, *)) { // fall through @@ -345,7 +345,7 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnCreate___java_lang_String( return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG finalResult); } -JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnGet___java_lang_String( +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnGet___java_lang_String_R_java_lang_String( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { if (@available(iOS 16.0, *)) { // fall through @@ -450,17 +450,17 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnGet___java_lang_String( // WebAuthnNativeImpl and these natives are unreachable. ParparVM still needs // the symbols to satisfy the native-method declarations on IOSNative.java. -JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_webauthnSupported__( +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_webauthnSupported___R_boolean( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { return JAVA_FALSE; } -JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnCreate___java_lang_String( +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnCreate___java_lang_String_R_java_lang_String( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { return JAVA_NULL; } -JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnGet___java_lang_String( +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnGet___java_lang_String_R_java_lang_String( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { return JAVA_NULL; } diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 758931c0241..388f762c894 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -171,6 +171,17 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); // any passkey symbols. //#define CN1_INCLUDE_WEBAUTHN +// CN1_INCLUDE_BLUETOOTH gates the com.codename1.bluetooth native bridge +// (CN1Bluetooth.{h,m}: CoreBluetooth CBCentralManager / CBPeripheralManager, +// GATT client + server, advertising and L2CAP channels). IPhoneBuilder +// uncomments this only when the classpath scanner saw +// com.codename1.bluetooth.*, so apps that never touch Bluetooth ship +// without CoreBluetooth symbols and need no NSBluetoothAlwaysUsageDescription +// privacy entry. The BLE peripheral role (CBPeripheralManager) is +// unavailable on tvOS / watchOS; CN1Bluetooth.m compiles that section out +// per target slice and isBlePeripheralSupported reports false there. +//#define CN1_INCLUDE_BLUETOOTH + //#define INCLUDE_CN1_BACKGROUND_FETCH //#define INCLUDE_FACEBOOK_CONNECT //#define USE_FACEBOOK_CONNECT_PODS diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBleAdvertisement.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBleAdvertisement.java new file mode 100644 index 00000000000..c6795d2704e --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBleAdvertisement.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2012, 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.impl.ios; + +import com.codename1.bluetooth.le.server.BleAdvertisement; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * A live CBPeripheralManager advertisement. iOS supports one advertisement + * at a time and offers no native timeout, so the AdvertiseSettings timeout + * is enforced with a Java timer. + */ +class IOSBleAdvertisement extends BleAdvertisement { + + private static Timer timeoutTimer; + + private final IOSBluetooth bt; + private final int timeoutMillis; + private volatile boolean active; + + IOSBleAdvertisement(IOSBluetooth bt, int timeoutMillis) { + this.bt = bt; + this.timeoutMillis = timeoutMillis; + } + + void startedFromNative() { + active = true; + if (timeoutMillis > 0) { + TimerTask t = new TimerTask() { + public void run() { + stop(); + } + }; + sharedTimer().schedule(t, timeoutMillis); + } + } + + @Override + public void stop() { + synchronized (this) { + if (!active) { + return; + } + active = false; + } + bt.nativeInstance.btStopAdvertising(); + } + + @Override + public boolean isActive() { + return active; + } + + private static synchronized Timer sharedTimer() { + if (timeoutTimer == null) { + timeoutTimer = new Timer(true); + } + return timeoutTimer; + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBlePeripheral.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBlePeripheral.java new file mode 100644 index 00000000000..ed049b361a2 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBlePeripheral.java @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2012, 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.impl.ios; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.DeviceType; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionOptions; +import com.codename1.bluetooth.le.ConnectionPriority; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.io.Util; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * A remote peripheral over a retained CBPeripheral. The "address" is the + * CoreBluetooth identifier UUID string (iOS never exposes the MAC), stable + * per app install and usable with + * {@link com.codename1.bluetooth.le.BluetoothLE#getPeripheral(String)}. + * + *

The core {@link BlePeripheral} serializes GATT operations one at a + * time per peripheral, so each do* method simply registers its + * AsyncResource in the shared request registry and forwards to the native + * bridge; the matching nativeBt* callback resolves it.

+ */ +class IOSBlePeripheral extends BlePeripheral { + + private final IOSBluetooth bt; + private final String id; + private volatile String name; + + /** Canonical characteristic instances from the last discovery pass, + * keyed by serviceUuid|serviceInstance|charUuid|charInstance -- used to + * route notifications back to the objects the app subscribed on. */ + private final HashMap charIndex = + new HashMap(); + + IOSBlePeripheral(IOSBluetooth bt, String id, String name) { + this.bt = bt; + this.id = id; + this.name = name; + } + + @Override + public String getAddress() { + return id; + } + + @Override + public String getName() { + return name; + } + + @Override + public DeviceType getType() { + return DeviceType.LE; + } + + void updateName(String name) { + this.name = name; + } + + // ------------------------------------------------------------------ + // connection lifecycle SPI + // ------------------------------------------------------------------ + + @Override + protected void doConnect(ConnectionOptions options, + AsyncResource out) { + // iOS connect attempts never time out on their own; the core layer + // arms the ConnectionOptions timeout and calls doDisconnect on + // expiry, which cancels the native attempt. autoConnect has no + // CoreBluetooth equivalent (attempts are already persistent). + bt.ensureMonitor(); + bt.nativeInstance.btConnect(id); + // resolution happens through nativeBtConnected / + // nativeBtConnectFailed -> fireConnectionStateChanged + } + + @Override + protected void doDisconnect() { + bt.nativeInstance.btDisconnect(id); + } + + void connectedFromNative() { + // seed the MTU before the CONNECTED transition resolves the + // pending connect handle + int payload = bt.nativeInstance.btGetMaxWriteLength(id, false); + if (payload > 0) { + setMtu(payload + 3); + } + fireConnectionStateChanged(ConnectionState.CONNECTED, null); + } + + void disconnectedFromNative(BluetoothException reason) { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, reason); + } + + void servicesInvalidatedFromNative() { + synchronized (charIndex) { + charIndex.clear(); + } + fireServicesInvalidated(); + } + + // ------------------------------------------------------------------ + // GATT client SPI + // ------------------------------------------------------------------ + + @Override + protected void doDiscoverServices(AsyncResource> out) { + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btDiscoverServices(rid, id); + } + + @Override + protected void doReadCharacteristic(GattCharacteristic c, + AsyncResource out) { + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btReadCharacteristic(rid, id, + c.getService().getUuid().toString(), + c.getService().getInstanceId(), + c.getUuid().toString(), c.getInstanceId()); + } + + @Override + protected void doWriteCharacteristic(GattCharacteristic c, byte[] value, + boolean withResponse, AsyncResource out) { + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btWriteCharacteristic(rid, id, + c.getService().getUuid().toString(), + c.getService().getInstanceId(), + c.getUuid().toString(), c.getInstanceId(), + value == null ? new byte[0] : value, withResponse); + } + + @Override + protected void doReadDescriptor(GattDescriptor d, + AsyncResource out) { + GattCharacteristic c = d.getCharacteristic(); + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btReadDescriptor(rid, id, + c.getService().getUuid().toString(), + c.getService().getInstanceId(), + c.getUuid().toString(), c.getInstanceId(), + d.getUuid().toString()); + } + + @Override + protected void doWriteDescriptor(GattDescriptor d, byte[] value, + AsyncResource out) { + GattCharacteristic c = d.getCharacteristic(); + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btWriteDescriptor(rid, id, + c.getService().getUuid().toString(), + c.getService().getInstanceId(), + c.getUuid().toString(), c.getInstanceId(), + d.getUuid().toString(), value == null ? new byte[0] : value); + } + + @Override + protected void doSetNotifications(GattCharacteristic c, boolean enable, + boolean indication, AsyncResource out) { + // setNotifyValue covers both notifications and indications on iOS + // (CoreBluetooth writes the CCCD itself), so `indication` is moot. + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btSetNotify(rid, id, + c.getService().getUuid().toString(), + c.getService().getInstanceId(), + c.getUuid().toString(), c.getInstanceId(), enable); + } + + @Override + protected void doReadRssi(AsyncResource out) { + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btReadRssi(rid, id); + } + + @Override + protected void doRequestMtu(int mtu, AsyncResource out) { + // iOS negotiates the MTU itself; report the current effective value + // (maximumWriteValueLength for write-without-response + the 3-byte + // ATT header) immediately. + int payload = bt.nativeInstance.btGetMaxWriteLength(id, false); + int att = payload > 0 ? payload + 3 : getMtu(); + setMtu(att); + if (!out.isDone()) { + out.complete(Integer.valueOf(att)); + } + } + + @Override + protected void doRequestConnectionPriority(ConnectionPriority priority, + AsyncResource out) { + // iOS manages connection intervals itself -- a successful no-op + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + } + + @Override + protected void doCreateBond(AsyncResource out) { + // Pairing on iOS is OS-managed, triggered by access to encrypted + // characteristics -- report success without user interaction. + if (!out.isDone()) { + out.complete(Boolean.TRUE); + } + } + + @Override + protected void doOpenL2cap(int psm, boolean secure, + AsyncResource out) { + // `secure` has no central-side switch on iOS; the peripheral chose + // the encryption requirement when publishing the PSM. + if (getConnectionState() != ConnectionState.CONNECTED) { + out.error(new BluetoothException(BluetoothError.NOT_CONNECTED, + "iOS requires a connected peripheral to open L2CAP")); + return; + } + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btOpenL2cap(rid, id, psm); + } + + // ------------------------------------------------------------------ + // native event plumbing + // ------------------------------------------------------------------ + + void notificationFromNative(String serviceUuid, int serviceInstance, + String charUuid, int charInstance, byte[] value) { + GattCharacteristic c; + synchronized (charIndex) { + c = charIndex.get(charKey(serviceUuid, serviceInstance, charUuid, + charInstance)); + } + if (c != null) { + fireNotification(c, value); + } + } + + /** Parses the discovery result and rebuilds the canonical + * characteristic index. Line format (fields separated by '|'): + *
+     * S|uuid|primary(0/1)|instanceId
+     * C|uuid|properties|instanceId      (owned by the last S)
+     * D|uuid                            (owned by the last C)
+     * 
*/ + void servicesDiscoveredFromNative(String gattDb, + AsyncResource> out) { + ArrayList services = new ArrayList(); + HashMap index = + new HashMap(); + if (gattDb != null && gattDb.length() > 0) { + String[] lines = Util.split(gattDb, "\n"); + GattService curService = null; + GattCharacteristic curChar = null; + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line.length() < 3) { + continue; + } + String[] f = Util.split(line, "|"); + try { + if (line.charAt(0) == 'S' && f.length >= 4) { + curService = new GattService(this, + BluetoothUuid.fromString(f[1]), + "1".equals(f[2]), Integer.parseInt(f[3])); + curChar = null; + services.add(curService); + } else if (line.charAt(0) == 'C' && f.length >= 4 + && curService != null) { + curChar = new GattCharacteristic(curService, + BluetoothUuid.fromString(f[1]), + Integer.parseInt(f[2]), + Integer.parseInt(f[3])); + curService.addCharacteristic(curChar); + index.put(charKey( + curService.getUuid().toString(), + curService.getInstanceId(), + curChar.getUuid().toString(), + curChar.getInstanceId()), curChar); + } else if (line.charAt(0) == 'D' && f.length >= 2 + && curChar != null) { + curChar.addDescriptor(new GattDescriptor(curChar, + BluetoothUuid.fromString(f[1]))); + } + } catch (RuntimeException ignore) { + // skip malformed lines rather than failing discovery + } + } + } + synchronized (charIndex) { + charIndex.clear(); + charIndex.putAll(index); + } + if (!out.isDone()) { + out.complete(services); + } + } + + private static String charKey(String serviceUuid, int serviceInstance, + String charUuid, int charInstance) { + return IOSBluetooth.normalizeUuid(serviceUuid) + "|" + serviceInstance + + "|" + IOSBluetooth.normalizeUuid(charUuid) + "|" + + charInstance; + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBluetooth.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBluetooth.java new file mode 100644 index 00000000000..511c4490a0e --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBluetooth.java @@ -0,0 +1,798 @@ +/* + * Copyright (c) 2012, 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.impl.ios; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothPermission; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.io.Util; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * iOS / Mac Catalyst implementation of {@link Bluetooth} backed by + * CoreBluetooth (CN1Bluetooth.m in nativeSources). BLE central and + * peripheral roles plus L2CAP channels are supported; classic Bluetooth is + * not exposed by iOS so {@link #isClassicSupported()} stays {@code false} + * and the base no-op {@code getClassic()} instance is returned. + * + *

The CoreBluetooth managers are created lazily on the native side + * (creating a CBCentralManager pops the OS permission dialog), driven by + * {@link IOSNative#startBluetoothStateMonitor()} which fires on first use + * of any adapter-state / permission / scan / connect entry point.

+ * + *

The native side dispatches every event back through the static + * {@code nativeBt*} methods below on a dedicated serial GCD queue. The + * static initializer touches each callback so the ParparVM dead-code + * eliminator cannot strip them from release builds.

+ */ +public final class IOSBluetooth extends Bluetooth { + + // Error codes shared with CN1Bluetooth.m -- keep the two lists in sync. + static final int ERR_UNKNOWN = 0; + static final int ERR_NOT_SUPPORTED = 1; + static final int ERR_POWERED_OFF = 2; + static final int ERR_UNAUTHORIZED = 3; + static final int ERR_SCAN_FAILED = 4; + static final int ERR_ADVERTISE_FAILED = 5; + static final int ERR_CONNECTION_FAILED = 6; + static final int ERR_CONNECTION_LOST = 7; + static final int ERR_NOT_CONNECTED = 8; + static final int ERR_GATT = 9; + static final int ERR_TIMEOUT = 10; + static final int ERR_IO = 11; + + // CBManagerAuthorization values as reported by the native side. + static final int AUTH_NOT_DETERMINED = 0; + static final int AUTH_RESTRICTED = 1; + static final int AUTH_DENIED = 2; + static final int AUTH_ALLOWED = 3; + + /** Pending request-id registry -- see IOSNfc.REQUESTS for the pattern. */ + private static final Map REQUESTS = + new HashMap(); + private static int nextRequestId = 1; + + /** Canonical peripheral instances keyed by identifier UUID string. */ + private static final Map PERIPHERALS = + new HashMap(); + + /** Published L2CAP servers keyed by PSM, for incoming-channel routing. */ + private static final Map L2CAP_SERVERS = + new HashMap(); + + /** Permission requests waiting for the CoreBluetooth auth to settle. */ + private static final List> PENDING_PERMISSIONS = + new ArrayList>(); + + private static volatile IOSBluetooth instance; + + static { + // ---- do not remove: defeats ParparVM dead-code elimination ---- + // Placed after the static field initializers (class-init runs in + // textual order) so the sentinel calls find live registries. + nativeBtStateChanged(-1, -1); + nativeBtScanResult(null, null, 0, false, null, null, null, null, -999); + nativeBtConnected(null); + nativeBtConnectFailed(null, 0, null); + nativeBtDisconnected(null, 0, null); + nativeBtServicesDiscovered(-1, null, null); + nativeBtValue(-1, null); + nativeBtNotification(null, null, 0, null, 0, null); + nativeBtOperationComplete(-1); + nativeBtRssi(-1, 0); + nativeBtRequestError(-1, 0, null); + nativeBtServicesInvalidated(null); + nativeBtGattServerOpened(-1); + nativeBtAdvertiseStarted(-1); + nativeBtReadRequest(0, null, -1, -1, 0); + nativeBtWriteRequest(0, null, -1, -1, null, 0, false); + nativeBtSubscriptionChanged(null, 0, -1, false); + nativeBtL2capOpened(-1, 0, 0); + nativeBtL2capPublished(-1, 0); + nativeBtL2capIncoming(0, 0); + } + + final IOSNative nativeInstance; + private final IOSBluetoothLE le; + private volatile AdapterState adapterState = AdapterState.UNKNOWN; + private volatile boolean monitorStarted; + + IOSBluetooth(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + this.le = new IOSBluetoothLE(this); + instance = this; + } + + @Override + public boolean isSupported() { + return true; + } + + @Override + public boolean isLeSupported() { + return true; + } + + @Override + public boolean isClassicSupported() { + // iOS does not expose classic Bluetooth (RFCOMM) to apps; the base + // class no-op getClassic() instance is intentionally kept. + return false; + } + + @Override + public boolean isPeripheralModeSupported() { + // CBPeripheralManager is unavailable on tvOS / watchOS; the native + // capability check compiles the answer per target slice. + return nativeInstance.isBlePeripheralSupported(); + } + + @Override + public boolean isL2capSupported() { + return true; + } + + @Override + public AdapterState getAdapterState() { + // Creating the CBCentralManager is what pops the permission dialog, + // so the monitor only starts when the app actually asks about + // Bluetooth -- never as an app-startup side effect. + ensureMonitor(); + return adapterState; + } + + @Override + public boolean hasPermission(BluetoothPermission permission) { + // CoreBluetooth has a single privacy authorization covering scan, + // connect and advertise alike; all three map to it. + return nativeInstance.getBluetoothAuthorization() == AUTH_ALLOWED; + } + + @Override + public AsyncResource requestPermissions( + BluetoothPermission... permissions) { + AsyncResource r = new AsyncResource(); + int auth = nativeInstance.getBluetoothAuthorization(); + if (auth != AUTH_NOT_DETERMINED) { + r.complete(Boolean.valueOf(auth == AUTH_ALLOWED)); + return r; + } + synchronized (PENDING_PERMISSIONS) { + PENDING_PERMISSIONS.add(r); + } + // Creating the manager triggers the system dialog; the resulting + // state callback (auth != notDetermined) resolves the request. + ensureMonitor(); + return r; + } + + // requestEnable() intentionally keeps the base behavior: iOS offers no + // programmatic enable flow, so it resolves false. + + @Override + public com.codename1.bluetooth.le.BluetoothLE getLE() { + return le; + } + + // ------------------------------------------------------------------ + // package plumbing + // ------------------------------------------------------------------ + + void ensureMonitor() { + if (!monitorStarted) { + monitorStarted = true; + nativeInstance.startBluetoothStateMonitor(); + } + } + + IOSBluetoothLE getLEImpl() { + return le; + } + + static IOSBluetooth getIOSInstance() { + return instance; + } + + static int takeId(Object holder) { + synchronized (REQUESTS) { + int id = nextRequestId++; + REQUESTS.put(Integer.valueOf(id), holder); + return id; + } + } + + static Object take(int requestId) { + synchronized (REQUESTS) { + return REQUESTS.remove(Integer.valueOf(requestId)); + } + } + + @SuppressWarnings("unchecked") + static AsyncResource takeAsync(int requestId) { + Object o = take(requestId); + return o instanceof AsyncResource ? (AsyncResource) o : null; + } + + /** Returns the canonical peripheral for the identifier, creating it on + * first sight; refreshes the cached name when a non-empty one arrives. */ + static IOSBlePeripheral peripheral(String peripheralId, String name) { + synchronized (PERIPHERALS) { + IOSBlePeripheral p = PERIPHERALS.get(peripheralId); + if (p == null) { + p = new IOSBlePeripheral(instance, peripheralId, name); + PERIPHERALS.put(peripheralId, p); + } else if (name != null && name.length() > 0) { + p.updateName(name); + } + return p; + } + } + + static IOSBlePeripheral existingPeripheral(String peripheralId) { + synchronized (PERIPHERALS) { + return PERIPHERALS.get(peripheralId); + } + } + + static void registerL2capServer(int psm, IOSL2capServer server) { + synchronized (L2CAP_SERVERS) { + L2CAP_SERVERS.put(Integer.valueOf(psm), server); + } + } + + static void unregisterL2capServer(int psm) { + synchronized (L2CAP_SERVERS) { + L2CAP_SERVERS.remove(Integer.valueOf(psm)); + } + } + + static BluetoothException mapError(int code, String msg) { + BluetoothError err; + switch (code) { + case ERR_NOT_SUPPORTED: + err = BluetoothError.NOT_SUPPORTED; + break; + case ERR_POWERED_OFF: + err = BluetoothError.POWERED_OFF; + break; + case ERR_UNAUTHORIZED: + err = BluetoothError.UNAUTHORIZED; + break; + case ERR_SCAN_FAILED: + err = BluetoothError.SCAN_FAILED; + break; + case ERR_ADVERTISE_FAILED: + err = BluetoothError.ADVERTISE_FAILED; + break; + case ERR_CONNECTION_FAILED: + err = BluetoothError.CONNECTION_FAILED; + break; + case ERR_CONNECTION_LOST: + err = BluetoothError.CONNECTION_LOST; + break; + case ERR_NOT_CONNECTED: + err = BluetoothError.NOT_CONNECTED; + break; + case ERR_GATT: + err = BluetoothError.GATT_ERROR; + break; + case ERR_TIMEOUT: + err = BluetoothError.TIMEOUT; + break; + case ERR_IO: + err = BluetoothError.IO_ERROR; + break; + default: + err = BluetoothError.UNKNOWN; + break; + } + return new BluetoothException(err, msg == null ? err.name() : msg); + } + + private static AdapterState mapState(int cbManagerState) { + switch (cbManagerState) { + case 2: + return AdapterState.UNSUPPORTED; + case 3: + return AdapterState.UNAUTHORIZED; + case 4: + return AdapterState.POWERED_OFF; + case 5: + return AdapterState.POWERED_ON; + default: // 0 unknown, 1 resetting + return AdapterState.UNKNOWN; + } + } + + // Character.forDigit is not part of the device API surface (CLDC11); + // ParparVM would translate it to an undeclared C function. + private static final char[] HEX = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + static String bytesToHex(byte[] b) { + if (b == null) { + return ""; + } + StringBuilder sb = new StringBuilder(b.length * 2); + for (int i = 0; i < b.length; i++) { + int v = b[i] & 0xFF; + sb.append(HEX[v >> 4]); + sb.append(HEX[v & 0xF]); + } + return sb.toString(); + } + + static byte[] hexToBytes(String s) { + if (s == null || s.length() < 2) { + return new byte[0]; + } + int len = s.length() / 2; + byte[] out = new byte[len]; + for (int i = 0; i < len; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) { + return new byte[0]; + } + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + /** Normalizes a UUID string coming from CBUUID (which may use the short + * 4/8-character form) into the canonical 36-character representation. */ + static String normalizeUuid(String uuid) { + try { + return BluetoothUuid.fromString(uuid).toString(); + } catch (RuntimeException ex) { + return uuid == null ? "" : uuid.toLowerCase(); + } + } + + // ------------------------------------------------------------------ + // request holders for callbacks that resolve with rich objects + // ------------------------------------------------------------------ + + static final class PendingServer { + final AsyncResource result; + final IOSGattServer server; + + PendingServer(AsyncResource result, IOSGattServer server) { + this.result = result; + this.server = server; + } + } + + static final class PendingAdvertise { + final AsyncResource result; + final IOSBleAdvertisement advertisement; + + PendingAdvertise(AsyncResource result, + IOSBleAdvertisement advertisement) { + this.result = result; + this.advertisement = advertisement; + } + } + + static final class PendingL2capServer { + final AsyncResource result; + + PendingL2capServer(AsyncResource result) { + this.result = result; + } + } + + // ---- Callbacks invoked from native code (do not rename) ---------------- + + /** CBCentralManager state / authorization change. `state` uses the raw + * CBManagerState values, `auth` the raw CBManagerAuthorization values. */ + public static void nativeBtStateChanged(int state, int auth) { + if (state < 0 && auth < 0) { + return; // static-initializer touch + } + if (auth != AUTH_NOT_DETERMINED && auth >= 0) { + Object[] pending = null; + synchronized (PENDING_PERMISSIONS) { + if (!PENDING_PERMISSIONS.isEmpty()) { + pending = PENDING_PERMISSIONS.toArray(); + PENDING_PERMISSIONS.clear(); + } + } + if (pending != null) { + Boolean granted = Boolean.valueOf(auth == AUTH_ALLOWED); + for (int i = 0; i < pending.length; i++) { + AsyncResource r = + (AsyncResource) pending[i]; + if (!r.isDone()) { + r.complete(granted); + } + } + } + } + IOSBluetooth b = instance; + if (b == null || state < 0) { + return; + } + AdapterState mapped = mapState(state); + if (mapped != b.adapterState) { + b.adapterState = mapped; + b.fireAdapterStateChanged(mapped); + } + if (mapped != AdapterState.POWERED_ON + && mapped != AdapterState.UNKNOWN) { + b.le.platformScanLostFromNative(mapped); + } + } + + /** One advertisement sighting from CBCentralManager + * didDiscoverPeripheral. `serviceUuids` is comma-separated, + * `manufacturerData` is the raw CoreBluetooth blob (2-byte little-endian + * company id prefix), `serviceData` is `uuid=hex` pairs comma-separated + * and `txPower` uses -999 when absent. */ + public static void nativeBtScanResult(String peripheralId, String name, + int rssi, boolean connectable, String serviceUuids, + String localName, byte[] manufacturerData, String serviceData, + int txPower) { + if (peripheralId == null) { + return; + } + IOSBluetooth b = instance; + if (b == null) { + return; + } + IOSBlePeripheral p = peripheral(peripheralId, name); + AdvertisementData ad = new AdvertisementData(); + if (localName != null && localName.length() > 0) { + ad.setLocalName(localName); + } + if (serviceUuids != null && serviceUuids.length() > 0) { + String[] uuids = Util.split(serviceUuids, ","); + for (int i = 0; i < uuids.length; i++) { + if (uuids[i].length() > 0) { + try { + ad.addServiceUuid(BluetoothUuid.fromString(uuids[i])); + } catch (RuntimeException ignore) { + } + } + } + } + if (manufacturerData != null && manufacturerData.length >= 2) { + int companyId = (manufacturerData[0] & 0xFF) + | ((manufacturerData[1] & 0xFF) << 8); + byte[] payload = new byte[manufacturerData.length - 2]; + System.arraycopy(manufacturerData, 2, payload, 0, payload.length); + ad.addManufacturerData(companyId, payload); + } + if (serviceData != null && serviceData.length() > 0) { + String[] entries = Util.split(serviceData, ","); + for (int i = 0; i < entries.length; i++) { + int eq = entries[i].indexOf('='); + if (eq <= 0) { + continue; + } + try { + ad.addServiceData( + BluetoothUuid.fromString( + entries[i].substring(0, eq)), + hexToBytes(entries[i].substring(eq + 1))); + } catch (RuntimeException ignore) { + } + } + } + if (txPower != -999) { + ad.setTxPowerLevel(Integer.valueOf(txPower)); + } + b.le.scanResultFromNative(new ScanResult(p, rssi, ad, connectable, + System.currentTimeMillis())); + } + + /** CBCentralManager didConnectPeripheral. */ + public static void nativeBtConnected(String peripheralId) { + if (peripheralId == null) { + return; + } + IOSBlePeripheral p = existingPeripheral(peripheralId); + if (p != null) { + p.connectedFromNative(); + } + } + + /** CBCentralManager didFailToConnectPeripheral. */ + public static void nativeBtConnectFailed(String peripheralId, int code, + String message) { + if (peripheralId == null) { + return; + } + IOSBlePeripheral p = existingPeripheral(peripheralId); + if (p != null) { + p.disconnectedFromNative(mapError( + code == 0 ? ERR_CONNECTION_FAILED : code, message)); + } + } + + /** CBCentralManager didDisconnectPeripheral; `code` 0 means a clean, + * app-requested disconnect. */ + public static void nativeBtDisconnected(String peripheralId, int code, + String message) { + if (peripheralId == null) { + return; + } + IOSBlePeripheral p = existingPeripheral(peripheralId); + if (p != null) { + p.disconnectedFromNative( + code == 0 ? null : mapError(code, message)); + } + } + + /** Full GATT database after the aggregated + * services/characteristics/descriptors discovery pass. See + * IOSBlePeripheral.parseGattDb for the line format. */ + public static void nativeBtServicesDiscovered(int requestId, + String peripheralId, String gattDb) { + AsyncResource> r = + takeAsync(requestId); + if (r == null) { + return; + } + IOSBlePeripheral p = peripheralId == null + ? null : existingPeripheral(peripheralId); + if (p == null) { + if (!r.isDone()) { + r.error(new BluetoothException(BluetoothError.UNKNOWN, + "Unknown peripheral in discovery result")); + } + return; + } + p.servicesDiscoveredFromNative(gattDb, r); + } + + /** Characteristic / descriptor read result. */ + public static void nativeBtValue(int requestId, byte[] value) { + AsyncResource r = takeAsync(requestId); + if (r != null && !r.isDone()) { + r.complete(value == null ? new byte[0] : value); + } + } + + /** Notification / indication outside a pending read. */ + public static void nativeBtNotification(String peripheralId, + String serviceUuid, int serviceInstance, String charUuid, + int charInstance, byte[] value) { + if (peripheralId == null) { + return; + } + IOSBlePeripheral p = existingPeripheral(peripheralId); + if (p != null) { + p.notificationFromNative(serviceUuid, serviceInstance, charUuid, + charInstance, value == null ? new byte[0] : value); + } + } + + /** Generic completion for writes, notify-state changes, server service + * removal-free operations and sent notifications. */ + public static void nativeBtOperationComplete(int requestId) { + AsyncResource r = takeAsync(requestId); + if (r != null && !r.isDone()) { + r.complete(Boolean.TRUE); + } + } + + /** didReadRSSI result. */ + public static void nativeBtRssi(int requestId, int rssi) { + AsyncResource r = takeAsync(requestId); + if (r != null && !r.isDone()) { + r.complete(Integer.valueOf(rssi)); + } + } + + /** Failure path for any pending request id. */ + public static void nativeBtRequestError(int requestId, int code, + String message) { + Object o = take(requestId); + if (o == null) { + return; + } + BluetoothException ex = mapError(code, message); + AsyncResource r = null; + if (o instanceof AsyncResource) { + r = (AsyncResource) o; + } else if (o instanceof PendingServer) { + r = ((PendingServer) o).result; + } else if (o instanceof PendingAdvertise) { + r = ((PendingAdvertise) o).result; + } else if (o instanceof PendingL2capServer) { + r = ((PendingL2capServer) o).result; + } + if (r != null && !r.isDone()) { + r.error(ex); + } + } + + /** CBPeripheralDelegate didModifyServices. */ + public static void nativeBtServicesInvalidated(String peripheralId) { + if (peripheralId == null) { + return; + } + IOSBlePeripheral p = existingPeripheral(peripheralId); + if (p != null) { + p.servicesInvalidatedFromNative(); + } + } + + /** CBPeripheralManager reached poweredOn for an openGattServer call. */ + public static void nativeBtGattServerOpened(int requestId) { + Object o = take(requestId); + if (!(o instanceof PendingServer)) { + return; + } + PendingServer ps = (PendingServer) o; + IOSBluetooth b = instance; + if (b != null) { + b.le.serverOpenedFromNative(ps.server); + } + if (!ps.result.isDone()) { + ps.result.complete(ps.server); + } + } + + /** CBPeripheralManager didStartAdvertising without error. */ + public static void nativeBtAdvertiseStarted(int requestId) { + Object o = take(requestId); + if (!(o instanceof PendingAdvertise)) { + return; + } + PendingAdvertise pa = (PendingAdvertise) o; + pa.advertisement.startedFromNative(); + if (!pa.result.isDone()) { + pa.result.complete(pa.advertisement); + } + } + + /** GATT server read request; the CBATTRequest is parked natively under + * `requestHandle` until btRespondToReadRequest is called. `descLocalId` + * is -1 for characteristic reads (iOS never surfaces descriptor + * requests -- descriptors are static-value only). */ + public static void nativeBtReadRequest(long requestHandle, + String centralId, int charLocalId, int descLocalId, int offset) { + if (centralId == null) { + return; + } + IOSBluetooth b = instance; + IOSGattServer server = b == null ? null : b.le.getActiveServer(); + if (server == null) { + if (b != null) { + // 0x0e = unlikely error + b.nativeInstance.btRespondToReadRequest(requestHandle, null, + 0x0e); + } + return; + } + server.readRequestFromNative(requestHandle, centralId, charLocalId, + descLocalId, offset); + } + + /** GATT server write request; mirror of nativeBtReadRequest. */ + public static void nativeBtWriteRequest(long requestHandle, + String centralId, int charLocalId, int descLocalId, byte[] value, + int offset, boolean responseRequired) { + if (centralId == null) { + return; + } + IOSBluetooth b = instance; + IOSGattServer server = b == null ? null : b.le.getActiveServer(); + if (server == null) { + if (b != null && responseRequired) { + b.nativeInstance.btRespondToWriteRequest(requestHandle, 0x0e); + } + return; + } + server.writeRequestFromNative(requestHandle, centralId, charLocalId, + descLocalId, value == null ? new byte[0] : value, offset, + responseRequired); + } + + /** CBPeripheralManager central did(Un)SubscribeToCharacteristic. */ + public static void nativeBtSubscriptionChanged(String centralId, + int centralMtu, int charLocalId, boolean subscribed) { + if (centralId == null) { + return; + } + IOSBluetooth b = instance; + IOSGattServer server = b == null ? null : b.le.getActiveServer(); + if (server != null) { + server.subscriptionFromNative(centralId, centralMtu, charLocalId, + subscribed); + } + } + + /** Central-role CBPeripheral didOpenL2CAPChannel. */ + public static void nativeBtL2capOpened(int requestId, int psm, + long channelHandle) { + AsyncResource r = + takeAsync(requestId); + if (r == null) { + return; + } + IOSBluetooth b = instance; + if (b == null) { + return; + } + if (!r.isDone()) { + r.complete(new IOSL2capChannel(b, psm, channelHandle)); + } + } + + /** CBPeripheralManager didPublishL2CAPChannel. */ + public static void nativeBtL2capPublished(int requestId, int psm) { + Object o = take(requestId); + if (!(o instanceof PendingL2capServer)) { + return; + } + PendingL2capServer pl = (PendingL2capServer) o; + IOSBluetooth b = instance; + if (b == null) { + return; + } + IOSL2capServer server = new IOSL2capServer(b, psm); + registerL2capServer(psm, server); + if (!pl.result.isDone()) { + pl.result.complete(server); + } + } + + /** Peripheral-role CBPeripheralManager didOpenL2CAPChannel (incoming). */ + public static void nativeBtL2capIncoming(int psm, long channelHandle) { + if (channelHandle == 0) { + return; + } + IOSBluetooth b = instance; + if (b == null) { + return; + } + IOSL2capServer server; + synchronized (L2CAP_SERVERS) { + server = L2CAP_SERVERS.get(Integer.valueOf(psm)); + } + if (server != null) { + server.incomingFromNative(new IOSL2capChannel(b, psm, + channelHandle)); + } else { + b.nativeInstance.btL2capClose(channelHandle); + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBluetoothLE.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBluetoothLE.java new file mode 100644 index 00000000000..a4c5d8794bd --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBluetoothLE.java @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2012, 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.impl.ios; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.ScanSettings; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleAdvertisement; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.io.Util; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/** + * iOS BLE central / peripheral role entry point over CoreBluetooth. + * + *

Scanning: the ScanFilter fields are not readable by ports (setters + * only), so the platform scan always runs unfiltered and the core + * demultiplexer applies the per-handle filters and duplicate suppression. + * CBCentralManagerScanOptionAllowDuplicatesKey is enabled only while at + * least one active handle asked for duplicates.

+ */ +class IOSBluetoothLE extends BluetoothLE { + + private final IOSBluetooth bt; + private final Object scanStateLock = new Object(); + private boolean scanning; + private boolean scanDuplicates; + private IOSGattServer activeServer; + + IOSBluetoothLE(IOSBluetooth bt) { + this.bt = bt; + } + + // ------------------------------------------------------------------ + // scanning SPI + // ------------------------------------------------------------------ + + @Override + protected boolean isScanSupported() { + return true; + } + + @Override + protected void startPlatformScan() { + bt.ensureMonitor(); + synchronized (scanStateLock) { + scanning = true; + scanDuplicates = anyDuplicates(); + bt.nativeInstance.btStartScan(null, scanDuplicates); + } + } + + @Override + protected void stopPlatformScan() { + synchronized (scanStateLock) { + if (scanning) { + scanning = false; + bt.nativeInstance.btStopScan(); + } + } + } + + @Override + protected void onScanRegistrationsChanged() { + synchronized (scanStateLock) { + if (!scanning) { + return; + } + if (getActiveScanSettings().isEmpty()) { + // fireScanFailed clears the registry without calling + // stopPlatformScan -- stop the native scan here + scanning = false; + bt.nativeInstance.btStopScan(); + return; + } + boolean dup = anyDuplicates(); + if (dup != scanDuplicates) { + scanDuplicates = dup; + // re-issuing scanForPeripherals replaces the options + bt.nativeInstance.btStartScan(null, dup); + } + } + } + + private boolean anyDuplicates() { + List settings = getActiveScanSettings(); + int size = settings.size(); + for (int i = 0; i < size; i++) { + if (settings.get(i).isAllowDuplicates()) { + return true; + } + } + return false; + } + + void scanResultFromNative(ScanResult result) { + fireScanResult(result); + } + + /** Adapter left POWERED_ON while a platform scan was running. */ + void platformScanLostFromNative(AdapterState state) { + synchronized (scanStateLock) { + if (!scanning) { + return; + } + scanning = false; + } + BluetoothError err; + if (state == AdapterState.POWERED_OFF) { + err = BluetoothError.POWERED_OFF; + } else if (state == AdapterState.UNAUTHORIZED) { + err = BluetoothError.UNAUTHORIZED; + } else { + err = BluetoothError.SCAN_FAILED; + } + fireScanFailed(new BluetoothException(err, + "Scan aborted: Bluetooth adapter is " + state)); + } + + // ------------------------------------------------------------------ + // known peripherals + // ------------------------------------------------------------------ + + @Override + public BlePeripheral getPeripheral(String address) { + if (address == null || address.length() == 0) { + return null; + } + bt.ensureMonitor(); + String id = address.trim().toUpperCase(); + String name = bt.nativeInstance.btRetrievePeripheral(id); + if (name == null) { + return null; + } + return IOSBluetooth.peripheral(id, + name.length() == 0 ? null : name); + } + + @Override + public List getConnectedPeripherals( + BluetoothUuid serviceFilter) { + ArrayList out = new ArrayList(); + if (serviceFilter == null) { + // retrieveConnectedPeripheralsWithServices requires a service + // list -- iOS cannot enumerate all system connections + return out; + } + bt.ensureMonitor(); + String s = bt.nativeInstance.btGetKnownPeripherals( + serviceFilter.toString()); + if (s == null || s.length() == 0) { + return out; + } + String[] lines = Util.split(s, "\n"); + for (int i = 0; i < lines.length; i++) { + if (lines[i].length() == 0) { + continue; + } + String[] fields = Util.split(lines[i], "\t"); + String name = fields.length > 1 && fields[1].length() > 0 + ? fields[1] : null; + out.add(IOSBluetooth.peripheral(fields[0], name)); + } + return out; + } + + // getBondedPeripherals(): iOS has no bonded-device registry; the base + // class empty list is the correct answer. + + // ------------------------------------------------------------------ + // peripheral role + // ------------------------------------------------------------------ + + @Override + public AsyncResource openGattServer( + GattServerListener listener) { + AsyncResource r = new AsyncResource(); + if (listener == null) { + r.error(new BluetoothException(BluetoothError.UNKNOWN, + "openGattServer requires a listener")); + return r; + } + if (!bt.isPeripheralModeSupported()) { + r.error(peripheralUnsupported()); + return r; + } + synchronized (this) { + if (activeServer != null) { + r.error(new BluetoothException(BluetoothError.BUSY, + "A GATT server is already open; close it first")); + return r; + } + } + IOSGattServer server = new IOSGattServer(bt, this, listener); + int rid = IOSBluetooth.takeId( + new IOSBluetooth.PendingServer(r, server)); + bt.nativeInstance.btOpenGattServer(rid); + return r; + } + + @Override + public AsyncResource startAdvertising( + AdvertiseSettings settings, AdvertiseData data, + AdvertiseData scanResponse) { + AsyncResource r = + new AsyncResource(); + if (!bt.isPeripheralModeSupported()) { + r.error(peripheralUnsupported()); + return r; + } + AdvertiseSettings st = settings == null + ? new AdvertiseSettings() : settings; + AdvertiseData d = data == null ? new AdvertiseData() : data; + // iOS only advertises the local name and service UUIDs + // (CBAdvertisementDataLocalNameKey / ServiceUUIDsKey); manufacturer + // and service data are silently dropped by CoreBluetooth, and + // advertising is always connectable while CBPeripheralManager runs. + ArrayList uuids = new ArrayList(); + appendUuids(uuids, d); + if (scanResponse != null) { + appendUuids(uuids, scanResponse); + } + boolean includeName = d.isIncludeDeviceName() + || (scanResponse != null && scanResponse.isIncludeDeviceName()); + // empty string asks the native side for the device name + String localName = includeName ? "" : null; + IOSBleAdvertisement adv = new IOSBleAdvertisement(bt, + st.getTimeout()); + int rid = IOSBluetooth.takeId( + new IOSBluetooth.PendingAdvertise(r, adv)); + bt.nativeInstance.btStartAdvertising(rid, localName, + uuids.toArray(new String[uuids.size()])); + return r; + } + + private static void appendUuids(ArrayList out, AdvertiseData d) { + List uuids = d.getServiceUuids(); + int size = uuids.size(); + for (int i = 0; i < size; i++) { + String s = uuids.get(i).toString(); + if (!out.contains(s)) { + out.add(s); + } + } + } + + @Override + public AsyncResource openL2capServer(boolean secure) { + AsyncResource r = new AsyncResource(); + if (!bt.isPeripheralModeSupported()) { + r.error(peripheralUnsupported()); + return r; + } + int rid = IOSBluetooth.takeId( + new IOSBluetooth.PendingL2capServer(r)); + bt.nativeInstance.btPublishL2cap(rid, secure); + return r; + } + + private static BluetoothException peripheralUnsupported() { + return new BluetoothException(BluetoothError.NOT_SUPPORTED, + "BLE peripheral mode is not supported on this device"); + } + + // ------------------------------------------------------------------ + // package plumbing + // ------------------------------------------------------------------ + + synchronized IOSGattServer getActiveServer() { + return activeServer; + } + + synchronized void serverOpenedFromNative(IOSGattServer server) { + activeServer = server; + } + + synchronized void clearActiveServer(IOSGattServer server) { + if (activeServer == server) { + activeServer = null; + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSGattServer.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSGattServer.java new file mode 100644 index 00000000000..dd3b26374fa --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSGattServer.java @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2012, 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.impl.ios; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattStatus; +import com.codename1.bluetooth.le.server.BleCentral; +import com.codename1.bluetooth.le.server.GattLocalCharacteristic; +import com.codename1.bluetooth.le.server.GattLocalDescriptor; +import com.codename1.bluetooth.le.server.GattLocalService; +import com.codename1.bluetooth.le.server.GattReadRequest; +import com.codename1.bluetooth.le.server.GattServer; +import com.codename1.bluetooth.le.server.GattServerListener; +import com.codename1.bluetooth.le.server.GattWriteRequest; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * GATT server over CBPeripheralManager. Every local attribute added + * through {@link #doAddService} is assigned an integer local id shared + * with the native side, so the CBATTRequest / subscription callbacks can + * address the exact GattLocal* instance without UUID ambiguity. + * + *

iOS specifics:

+ *
    + *
  • CoreBluetooth serves descriptor reads/writes itself from the static + * values supplied at service creation -- descriptor requests never reach + * the listener.
  • + *
  • The CCCD (0x2902) is managed by iOS; a user-supplied CCCD descriptor + * is skipped when building the native service.
  • + *
  • Centrals are visible to a CBPeripheralManager only through their + * subscriptions, so {@code getConnectedCentrals()} lists subscribed + * centrals and centralConnected / centralDisconnected fire on the first / + * last subscription of a central.
  • + *
+ */ +class IOSGattServer extends GattServer { + + private static int nextLocalId = 1; + + private final IOSBluetooth bt; + private final IOSBluetoothLE le; + + private final Map charsById = + new HashMap(); + private final Map charIds = + new HashMap(); + private final Map descsById = + new HashMap(); + private final Map serviceIds = + new HashMap(); + + /** Subscribed centrals by identifier, insertion ordered. */ + private final LinkedHashMap centrals = + new LinkedHashMap(); + /** Characteristic local ids each central is subscribed to. */ + private final Map> subscriptions = + new HashMap>(); + + IOSGattServer(IOSBluetooth bt, IOSBluetoothLE le, + GattServerListener listener) { + super(listener); + this.bt = bt; + this.le = le; + } + + private static synchronized int allocId() { + return nextLocalId++; + } + + // ------------------------------------------------------------------ + // GattServer SPI + // ------------------------------------------------------------------ + + @Override + protected void doAddService(GattLocalService service, + AsyncResource out) { + StringBuilder sb = new StringBuilder(); + synchronized (charsById) { + if (serviceIds.containsKey(service)) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "Service was already added to this server")); + return; + } + int sid = allocId(); + serviceIds.put(service, Integer.valueOf(sid)); + sb.append("S|").append(sid).append('|') + .append(service.getUuid().toString()).append('|') + .append(service.isPrimary() ? '1' : '0').append('\n'); + List chars = + service.getCharacteristics(); + int charCount = chars.size(); + for (int i = 0; i < charCount; i++) { + GattLocalCharacteristic c = chars.get(i); + int cid = allocId(); + charsById.put(Integer.valueOf(cid), c); + charIds.put(c, Integer.valueOf(cid)); + sb.append("C|").append(cid).append('|') + .append(c.getUuid().toString()).append('|') + .append(c.getProperties()).append('|') + .append(c.getPermissions()).append('|') + .append(IOSBluetooth.bytesToHex(c.getValue())) + .append('\n'); + List descs = c.getDescriptors(); + int descCount = descs.size(); + for (int j = 0; j < descCount; j++) { + GattLocalDescriptor d = descs.get(j); + if (BluetoothUuid.CCCD.equals(d.getUuid())) { + // iOS owns the CCCD; adding it explicitly throws + continue; + } + int did = allocId(); + descsById.put(Integer.valueOf(did), d); + sb.append("D|").append(did).append('|') + .append(d.getUuid().toString()).append('|') + .append(d.getPermissions()).append('|') + .append(IOSBluetooth.bytesToHex(d.getValue())) + .append('\n'); + } + } + } + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btAddService(rid, sb.toString()); + } + + @Override + public void removeService(GattLocalService service) { + Integer sid; + synchronized (charsById) { + sid = serviceIds.remove(service); + if (sid != null) { + List chars = + service.getCharacteristics(); + int charCount = chars.size(); + for (int i = 0; i < charCount; i++) { + GattLocalCharacteristic c = chars.get(i); + Integer cid = charIds.remove(c); + if (cid != null) { + charsById.remove(cid); + } + } + } + } + if (sid != null) { + bt.nativeInstance.btRemoveService(sid.intValue()); + } + } + + @Override + public void close() { + synchronized (charsById) { + charsById.clear(); + charIds.clear(); + descsById.clear(); + serviceIds.clear(); + } + synchronized (centrals) { + centrals.clear(); + subscriptions.clear(); + } + bt.nativeInstance.btCloseGattServer(); + le.clearActiveServer(this); + } + + @Override + public List getConnectedCentrals() { + synchronized (centrals) { + return new ArrayList(centrals.values()); + } + } + + @Override + protected void doNotify(BleCentral central, + GattLocalCharacteristic characteristic, byte[] value, + boolean confirm, AsyncResource out) { + Integer cid; + synchronized (charsById) { + cid = charIds.get(characteristic); + } + if (cid == null) { + out.error(new BluetoothException(BluetoothError.UNKNOWN, + "Characteristic was not added to this server")); + return; + } + // iOS picks notification vs indication from the characteristic's + // properties; `confirm` has no per-update switch. + int rid = IOSBluetooth.takeId(out); + bt.nativeInstance.btNotifyValue(rid, cid.intValue(), + value == null ? new byte[0] : value, + central == null ? null : central.getAddress()); + } + + // ------------------------------------------------------------------ + // native event plumbing + // ------------------------------------------------------------------ + + void readRequestFromNative(long handle, String centralId, int charLocalId, + int descLocalId, int offset) { + GattLocalCharacteristic c; + GattLocalDescriptor d; + synchronized (charsById) { + c = charsById.get(Integer.valueOf(charLocalId)); + d = descLocalId >= 0 + ? descsById.get(Integer.valueOf(descLocalId)) : null; + } + if (c == null && d == null) { + bt.nativeInstance.btRespondToReadRequest(handle, null, + GattStatus.INVALID_HANDLE.getAttCode()); + return; + } + IOSGattReadRequest rq = new IOSGattReadRequest(bt, handle, + centralFor(centralId, 0), c, d, offset); + if (d != null) { + fireDescriptorReadRequest(rq); + } else { + fireCharacteristicReadRequest(rq); + } + } + + void writeRequestFromNative(long handle, String centralId, + int charLocalId, int descLocalId, byte[] value, int offset, + boolean responseRequired) { + GattLocalCharacteristic c; + GattLocalDescriptor d; + synchronized (charsById) { + c = charsById.get(Integer.valueOf(charLocalId)); + d = descLocalId >= 0 + ? descsById.get(Integer.valueOf(descLocalId)) : null; + } + if (c == null && d == null) { + if (responseRequired) { + bt.nativeInstance.btRespondToWriteRequest(handle, + GattStatus.INVALID_HANDLE.getAttCode()); + } + return; + } + IOSGattWriteRequest rq = new IOSGattWriteRequest(bt, handle, + centralFor(centralId, 0), c, d, value, offset, + responseRequired); + if (d != null) { + fireDescriptorWriteRequest(rq); + } else { + fireCharacteristicWriteRequest(rq); + } + } + + void subscriptionFromNative(String centralId, int centralMtu, + int charLocalId, boolean subscribed) { + GattLocalCharacteristic c; + synchronized (charsById) { + c = charsById.get(Integer.valueOf(charLocalId)); + } + if (c == null) { + return; + } + IOSBleCentral central; + boolean firstSubscription = false; + boolean lastSubscription = false; + synchronized (centrals) { + central = centrals.get(centralId); + HashSet subs = subscriptions.get(centralId); + if (subscribed) { + if (central == null) { + central = new IOSBleCentral(centralId); + centrals.put(centralId, central); + firstSubscription = true; + } + if (subs == null) { + subs = new HashSet(); + subscriptions.put(centralId, subs); + } + subs.add(Integer.valueOf(charLocalId)); + } else { + if (central == null) { + central = new IOSBleCentral(centralId); + } + if (subs != null) { + subs.remove(Integer.valueOf(charLocalId)); + if (subs.isEmpty()) { + subscriptions.remove(centralId); + centrals.remove(centralId); + lastSubscription = true; + } + } + } + if (centralMtu > 0) { + central.mtuFromNative(centralMtu); + } + } + if (firstSubscription) { + fireCentralConnected(central); + } + fireSubscriptionChanged(central, c, subscribed); + if (lastSubscription) { + fireCentralDisconnected(central); + } + } + + private IOSBleCentral centralFor(String centralId, int mtu) { + synchronized (centrals) { + IOSBleCentral central = centrals.get(centralId); + if (central == null) { + // reads/writes can arrive from centrals that never + // subscribed; represent them without registering + central = new IOSBleCentral(centralId); + } + if (mtu > 0) { + central.mtuFromNative(mtu); + } + return central; + } + } + + // ------------------------------------------------------------------ + // helper types + // ------------------------------------------------------------------ + + static final class IOSBleCentral extends BleCentral { + private final String id; + + IOSBleCentral(String id) { + this.id = id; + } + + @Override + public String getAddress() { + return id; + } + + void mtuFromNative(int mtu) { + setMtu(mtu); + } + } + + static final class IOSGattReadRequest extends GattReadRequest { + private final IOSBluetooth bt; + private final long handle; + + IOSGattReadRequest(IOSBluetooth bt, long handle, BleCentral central, + GattLocalCharacteristic characteristic, + GattLocalDescriptor descriptor, int offset) { + super(central, characteristic, descriptor, offset); + this.bt = bt; + this.handle = handle; + } + + @Override + public void respond(byte[] value) { + bt.nativeInstance.btRespondToReadRequest(handle, + value == null ? new byte[0] : value, 0); + } + + @Override + public void reject(GattStatus status) { + bt.nativeInstance.btRespondToReadRequest(handle, null, + status == null + ? GattStatus.UNLIKELY_ERROR.getAttCode() + : status.getAttCode()); + } + } + + static final class IOSGattWriteRequest extends GattWriteRequest { + private final IOSBluetooth bt; + private final long handle; + private final boolean needsResponse; + + IOSGattWriteRequest(IOSBluetooth bt, long handle, BleCentral central, + GattLocalCharacteristic characteristic, + GattLocalDescriptor descriptor, byte[] value, int offset, + boolean responseRequired) { + super(central, characteristic, descriptor, value, offset, + responseRequired); + this.bt = bt; + this.handle = handle; + this.needsResponse = responseRequired; + } + + @Override + public void respond() { + if (needsResponse) { + bt.nativeInstance.btRespondToWriteRequest(handle, 0); + } + } + + @Override + public void reject(GattStatus status) { + if (needsResponse) { + bt.nativeInstance.btRespondToWriteRequest(handle, + status == null + ? GattStatus.UNLIKELY_ERROR.getAttCode() + : status.getAttCode()); + } + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index ac3d0995f5f..64bd1163577 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -4255,6 +4255,7 @@ public static void appDidLaunchWithLocation() { private IOSBiometrics biometrics; private IOSSecureStorage secureStorage; private IOSNfc nfc; + private static IOSBluetooth bluetooth; private IOSDeviceIntegrity deviceIntegrity; @Override @@ -4294,6 +4295,16 @@ public com.codename1.nfc.Nfc getNfc() { return nfc; } + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + synchronized (IOSImplementation.class) { + if (bluetooth == null) { + bluetooth = new IOSBluetooth(nativeInstance); + } + return bluetooth; + } + } + public LocationManager getLocationManager() { if (!nativeInstance.checkLocationUsage()) { throw new RuntimeException("Please add the ios.NSLocationUsageDescription or ios.NSLocationAlwaysUsageDescription build hint"); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSL2capChannel.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSL2capChannel.java new file mode 100644 index 00000000000..34e52ac8e38 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSL2capChannel.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2012, 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.impl.ios; + +import com.codename1.bluetooth.le.L2capChannel; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * An open CBL2CAPChannel. The native side wraps the channel's + * NSInput/NSOutputStream pair behind an opaque long handle; the blocking + * btL2capRead / btL2capWrite natives poll those streams off the Bluetooth + * dispatch queue, so the streams here must be consumed off the EDT (as the + * core API requires). + * + *

Native return convention: byte count on success, -1 on orderly end of + * stream, -2 on error / closed handle.

+ */ +class IOSL2capChannel extends L2capChannel { + + private final IOSBluetooth bt; + private final long handle; + private volatile boolean open = true; + + private final InputStream in = new InputStream() { + public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n < 0 ? -1 : one[0] & 0xFF; + } + + public int read(byte[] b, int off, int len) throws IOException { + if (b == null) { + throw new IOException("null buffer"); + } + if (len == 0) { + return 0; + } + if (off < 0 || len < 0 || off + len > b.length) { + throw new IOException("invalid offset/length"); + } + if (!open) { + return -1; + } + int n = bt.nativeInstance.btL2capRead(handle, b, off, len); + if (n == -2) { + throw new IOException("L2CAP read failed"); + } + return n; + } + }; + + private final OutputStream out = new OutputStream() { + public void write(int b) throws IOException { + write(new byte[] {(byte) b}, 0, 1); + } + + public void write(byte[] b, int off, int len) throws IOException { + if (b == null) { + throw new IOException("null buffer"); + } + if (off < 0 || len < 0 || off + len > b.length) { + throw new IOException("invalid offset/length"); + } + while (len > 0) { + if (!open) { + throw new IOException("L2CAP channel is closed"); + } + int n = bt.nativeInstance.btL2capWrite(handle, b, off, len); + if (n < 0) { + throw new IOException("L2CAP write failed"); + } + off += n; + len -= n; + } + } + }; + + IOSL2capChannel(IOSBluetooth bt, int psm, long handle) { + super(psm); + this.bt = bt; + this.handle = handle; + } + + @Override + public InputStream getInputStream() throws IOException { + return in; + } + + @Override + public OutputStream getOutputStream() throws IOException { + return out; + } + + @Override + public void close() throws IOException { + synchronized (this) { + if (!open) { + return; + } + open = false; + } + bt.nativeInstance.btL2capClose(handle); + } + + @Override + public boolean isOpen() { + return open; + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSL2capServer.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSL2capServer.java new file mode 100644 index 00000000000..c4f99f74bea --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSL2capServer.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2012, 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.impl.ios; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.bluetooth.le.L2capServer; +import com.codename1.util.AsyncResource; + +import java.util.LinkedList; + +/** + * A published CBPeripheralManager L2CAP endpoint. Incoming channels from + * peripheralManager:didOpenL2CAPChannel: are queued until the app calls + * {@link #accept()}; pending accepts are resolved in FIFO order. + */ +class IOSL2capServer extends L2capServer { + + private final IOSBluetooth bt; + private final int psm; + private final LinkedList pendingChannels = + new LinkedList(); + private final LinkedList> pendingAccepts = + new LinkedList>(); + private boolean closed; + + IOSL2capServer(IOSBluetooth bt, int psm) { + this.bt = bt; + this.psm = psm; + } + + @Override + public int getPsm() { + return psm; + } + + @Override + public AsyncResource accept() { + AsyncResource r = new AsyncResource(); + IOSL2capChannel ready = null; + synchronized (this) { + if (closed) { + r.error(new BluetoothException(BluetoothError.IO_ERROR, + "L2CAP server is closed")); + return r; + } + if (!pendingChannels.isEmpty()) { + ready = pendingChannels.removeFirst(); + } else { + pendingAccepts.add(r); + } + } + if (ready != null) { + r.complete(ready); + } + return r; + } + + @Override + public void close() { + Object[] toFail = null; + synchronized (this) { + if (closed) { + return; + } + closed = true; + if (!pendingAccepts.isEmpty()) { + toFail = pendingAccepts.toArray(); + pendingAccepts.clear(); + } + pendingChannels.clear(); + } + IOSBluetooth.unregisterL2capServer(psm); + bt.nativeInstance.btUnpublishL2cap(psm); + if (toFail != null) { + BluetoothException ex = new BluetoothException( + BluetoothError.IO_ERROR, "L2CAP server closed"); + for (int i = 0; i < toFail.length; i++) { + AsyncResource r = + (AsyncResource) toFail[i]; + if (!r.isDone()) { + r.error(ex); + } + } + } + } + + /** Incoming channel from the native side; safe to call from any + * thread. */ + void incomingFromNative(IOSL2capChannel channel) { + AsyncResource waiting = null; + synchronized (this) { + if (closed) { + try { + channel.close(); + } catch (java.io.IOException ignore) { + } + return; + } + if (!pendingAccepts.isEmpty()) { + waiting = pendingAccepts.removeFirst(); + } else { + pendingChannels.add(channel); + } + } + if (waiting != null && !waiting.isDone()) { + waiting.complete(channel); + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 7e99f2e7fe7..ac616fa351e 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1154,6 +1154,157 @@ native void startTagRead(int requestId, String alertMessage, /** Sends the HCE response for the APDU currently outstanding on CardSession. */ native void hceSendResponse(byte[] response); + // --- Bluetooth (Core Bluetooth) ------------------------------------------ + // + // Implemented in nativeSources/CN1Bluetooth.m, gated on + // CN1_INCLUDE_BLUETOOTH. Asynchronous results and events come back + // through the static IOSBluetooth.nativeBt* callbacks; request ids are + // allocated by IOSBluetooth.takeId. + + /** True when the BLE peripheral role (CBPeripheralManager) exists on + * this target slice -- false on tvOS / watchOS. */ + native boolean isBlePeripheralSupported(); + + /** Raw CBManagerAuthorization: 0 notDetermined, 1 restricted, 2 denied, + * 3 allowedAlways. Does not create a manager / prompt the user. */ + native int getBluetoothAuthorization(); + + /** Lazily creates the CBCentralManager (this pops the permission + * dialog on first run) and starts forwarding state changes through + * IOSBluetooth.nativeBtStateChanged. Idempotent. */ + native void startBluetoothStateMonitor(); + + /** Starts / retunes the platform scan. `serviceUuids` may be null for + * an unfiltered scan; results via IOSBluetooth.nativeBtScanResult. */ + native void btStartScan(String[] serviceUuids, boolean allowDuplicates); + + /** Stops the platform scan. */ + native void btStopScan(); + + /** Resolves a persisted identifier via + * retrievePeripheralsWithIdentifiers and retains the CBPeripheral. + * Returns the peripheral name ("" when unnamed) or null when the + * identifier cannot be resolved. */ + native String btRetrievePeripheral(String peripheralId); + + /** System-connected peripherals offering the given service + * (retrieveConnectedPeripheralsWithServices). Returns + * "id\tname\n"-joined entries; empty/null when none. */ + native String btGetKnownPeripherals(String serviceUuid); + + /** Connects; resolution via IOSBluetooth.nativeBtConnected / + * nativeBtConnectFailed. */ + native void btConnect(String peripheralId); + + /** Disconnects / cancels a pending connect + * (cancelPeripheralConnection). */ + native void btDisconnect(String peripheralId); + + /** Full discovery pass (services, then characteristics, then + * descriptors); one aggregated result via + * IOSBluetooth.nativeBtServicesDiscovered. */ + native void btDiscoverServices(int requestId, String peripheralId); + + /** Reads a characteristic; result via IOSBluetooth.nativeBtValue. */ + native void btReadCharacteristic(int requestId, String peripheralId, + String serviceUuid, int serviceInstance, String charUuid, + int charInstance); + + /** Writes a characteristic; completion via + * IOSBluetooth.nativeBtOperationComplete (immediate for + * write-without-response). */ + native void btWriteCharacteristic(int requestId, String peripheralId, + String serviceUuid, int serviceInstance, String charUuid, + int charInstance, byte[] value, boolean withResponse); + + /** Reads a descriptor; result via IOSBluetooth.nativeBtValue. */ + native void btReadDescriptor(int requestId, String peripheralId, + String serviceUuid, int serviceInstance, String charUuid, + int charInstance, String descriptorUuid); + + /** Writes a descriptor; completion via + * IOSBluetooth.nativeBtOperationComplete. */ + native void btWriteDescriptor(int requestId, String peripheralId, + String serviceUuid, int serviceInstance, String charUuid, + int charInstance, String descriptorUuid, byte[] value); + + /** Arms / disarms notifications (setNotifyValue); completion via + * IOSBluetooth.nativeBtOperationComplete. */ + native void btSetNotify(int requestId, String peripheralId, + String serviceUuid, int serviceInstance, String charUuid, + int charInstance, boolean enable); + + /** Reads the connection RSSI; result via IOSBluetooth.nativeBtRssi. */ + native void btReadRssi(int requestId, String peripheralId); + + /** Synchronous maximumWriteValueLengthForType (ATT MTU minus 3); + * 0 when the peripheral is unknown / disconnected. */ + native int btGetMaxWriteLength(String peripheralId, boolean withResponse); + + /** Creates the CBPeripheralManager (lazily) and reports through + * IOSBluetooth.nativeBtGattServerOpened once poweredOn. */ + native void btOpenGattServer(int requestId); + + /** Adds a service from the serialized definition (see + * IOSGattServer.doAddService for the S|/C|/D| line format); completion + * via IOSBluetooth.nativeBtOperationComplete. */ + native void btAddService(int requestId, String serviceDefinition); + + /** Removes a service previously added under the given local id. */ + native void btRemoveService(int serviceLocalId); + + /** Removes all services from the peripheral manager. */ + native void btCloseGattServer(); + + /** Starts advertising. `localName` null omits the name, "" uses the + * device name; result via IOSBluetooth.nativeBtAdvertiseStarted. */ + native void btStartAdvertising(int requestId, String localName, + String[] serviceUuids); + + /** Stops advertising. */ + native void btStopAdvertising(); + + /** updateValue:forCharacteristic:onSubscribedCentrals; retried on + * peripheralManagerIsReadyToUpdateSubscribers until accepted, then + * IOSBluetooth.nativeBtOperationComplete. `centralId` null targets all + * subscribed centrals. */ + native void btNotifyValue(int requestId, int charLocalId, byte[] value, + String centralId); + + /** Responds to a parked CBATTRequest read. `attStatus` 0 is success, + * otherwise the ATT error code (GattStatus.getAttCode). */ + native void btRespondToReadRequest(long requestHandle, byte[] value, + int attStatus); + + /** Responds to a parked CBATTRequest write. */ + native void btRespondToWriteRequest(long requestHandle, int attStatus); + + /** Opens an L2CAP channel to a connected peripheral; result via + * IOSBluetooth.nativeBtL2capOpened. */ + native void btOpenL2cap(int requestId, String peripheralId, int psm); + + /** Publishes an L2CAP endpoint + * (publishL2CAPChannelWithEncryption); PSM via + * IOSBluetooth.nativeBtL2capPublished, incoming channels via + * IOSBluetooth.nativeBtL2capIncoming. */ + native void btPublishL2cap(int requestId, boolean secure); + + /** Unpublishes a previously published PSM. */ + native void btUnpublishL2cap(int psm); + + /** Blocking read from an open L2CAP channel. Returns the byte count, + * -1 on orderly end of stream, -2 on error. */ + native int btL2capRead(long channelHandle, byte[] buffer, int offset, + int len); + + /** Blocking write to an open L2CAP channel. Returns the byte count + * written (possibly short), or -2 on error. */ + native int btL2capWrite(long channelHandle, byte[] buffer, int offset, + int len); + + /** Closes an L2CAP channel and releases its native wrapper. */ + native void btL2capClose(long channelHandle); + native long gausianBlurImage(long peer, float radius); /** diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNfc.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNfc.java index ddbd4639c09..6fbb0cf82f7 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNfc.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNfc.java @@ -55,6 +55,15 @@ */ public final class IOSNfc extends Nfc { + private static final Map REQUESTS = + new HashMap(); + private static final Map TAGS = new HashMap(); + private static int nextRequestId = 1; + private static volatile HostCardEmulationService hceService; + + // Static initializers run in textual order, so this touch block MUST + // come after the field initializers above -- the sentinel calls reach + // takeAsync()/takeId(), which synchronize on REQUESTS. static { nativeNdefResult(-1, null); nativeTagDiscovered(-1, 0L, 0, null); @@ -65,12 +74,6 @@ public final class IOSNfc extends Nfc { nativeHceDeactivated(0); } - private static final Map REQUESTS = - new HashMap(); - private static final Map TAGS = new HashMap(); - private static int nextRequestId = 1; - private static volatile HostCardEmulationService hceService; - private final IOSNative nativeInstance; private final Set listeners = new HashSet(); private int activeReadRequestId; diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava001Snippet.java new file mode 100644 index 00000000000..324c0ad6c19 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava001Snippet.java @@ -0,0 +1,119 @@ +/* + * 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothPermission; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.BleScan; +import com.codename1.bluetooth.le.ScanFilter; +import com.codename1.bluetooth.le.ScanSettings; + +class BluetoothJava001Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::bluetooth-java-001[] + Bluetooth bt = Bluetooth.getInstance(); + if (!bt.isLeSupported()) { + // hide the feature on ports without BLE + return; + } + bt.requestPermissions(BluetoothPermission.SCAN, BluetoothPermission.CONNECT) + .onResult((granted, permissionErr) -> { + if (permissionErr != null || !granted) { + return; + } + ScanSettings settings = new ScanSettings() + .addFilter(new ScanFilter() + .setServiceUuid(BluetoothUuid.fromShort(0x180D)) + .setNamePrefix("Polar")); + BleScan[] scan = new BleScan[1]; + scan[0] = bt.getLE().startScan(settings, sighting -> { + scan[0].stop(); + sighting.getPeripheral().connect() + .onResult((peripheral, connectErr) -> { + if (connectErr != null) { + return; + } + peripheral.discoverServices() + .onResult((services, discoverErr) -> { + // the GATT database is ready -- see the + // following sections + }); + }); + }); + }); + // end::bluetooth-java-001[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava002Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava002Snippet.java new file mode 100644 index 00000000000..02f914eaba8 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava002Snippet.java @@ -0,0 +1,110 @@ +/* + * 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.le.BlePeripheral; + +class BluetoothJava002Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + BlePeripheral peripheral; + void updateHeartRateLabel(int bpm) { + } + void snippet() throws Exception { + // tag::bluetooth-java-002[] + GattCharacteristic measurement = peripheral.getCharacteristic( + BluetoothUuid.fromShort(0x180D), + BluetoothUuid.fromShort(0x2A37)); + if (measurement != null && measurement.canRead()) { + measurement.read().onResult((data, err) -> { + if (err == null) { + // raw bytes, delivered on the EDT + updateHeartRateLabel(data[1] & 0xFF); + } + }); + } + + GattCharacteristic controlPoint = peripheral.getCharacteristic( + BluetoothUuid.fromShort(0x180D), + BluetoothUuid.fromShort(0x2A39)); + if (controlPoint != null && controlPoint.canWrite()) { + controlPoint.write(new byte[] {1}) + .onResult((acknowledged, err) -> { + // resolves once the peripheral acknowledged the write + }); + } + // end::bluetooth-java-002[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava003Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava003Snippet.java new file mode 100644 index 00000000000..24a6256b39a --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava003Snippet.java @@ -0,0 +1,105 @@ +/* + * 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattNotificationListener; +import com.codename1.bluetooth.le.BlePeripheral; + +class BluetoothJava003Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + BlePeripheral peripheral; + void updateHeartRateLabel(int bpm) { + } + void snippet() throws Exception { + // tag::bluetooth-java-003[] + GattCharacteristic measurement = peripheral.getCharacteristic( + BluetoothUuid.fromShort(0x180D), + BluetoothUuid.fromShort(0x2A37)); + GattNotificationListener listener = (characteristic, data) -> { + // fires on the EDT for every notification while subscribed + updateHeartRateLabel(data[1] & 0xFF); + }; + measurement.subscribe(listener).onResult((armed, err) -> { + if (err != null) { + // the CCCD write was rejected or the link dropped + } + }); + + // later, e.g. when leaving the form: + measurement.unsubscribe(listener); + // end::bluetooth-java-003[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava004Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava004Snippet.java new file mode 100644 index 00000000000..48a2c068755 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava004Snippet.java @@ -0,0 +1,139 @@ +/* + * 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.le.server.AdvertiseData; +import com.codename1.bluetooth.le.server.AdvertiseSettings; +import com.codename1.bluetooth.le.server.BleCentral; +import com.codename1.bluetooth.le.server.GattLocalCharacteristic; +import com.codename1.bluetooth.le.server.GattLocalService; +import com.codename1.bluetooth.le.server.GattServerAdapter; +import com.codename1.bluetooth.le.server.GattWriteRequest; + +class BluetoothJava004Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::bluetooth-java-004[] + Bluetooth bt = Bluetooth.getInstance(); + if (!bt.isPeripheralModeSupported()) { + return; + } + GattLocalCharacteristic status = new GattLocalCharacteristic( + BluetoothUuid.fromShort(0x2A37), + GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_NOTIFY, + GattLocalCharacteristic.PERMISSION_READ) + .setValue(new byte[] {0, 72}); + GattLocalService service = new GattLocalService( + BluetoothUuid.fromShort(0x180D)) + .addCharacteristic(status); + bt.getLE().openGattServer(new GattServerAdapter() { + @Override + public void centralConnected(BleCentral central) { + // a central connected to this device + } + + @Override + public void characteristicWriteRequest(GattWriteRequest request) { + // handle the written bytes, then acknowledge + if (request.isResponseRequired()) { + request.respond(); + } + } + }).onResult((server, err) -> { + if (err != null) { + return; + } + server.addService(service).onResult((added, addErr) -> { + if (addErr != null) { + return; + } + bt.getLE().startAdvertising( + new AdvertiseSettings(), + new AdvertiseData() + .addServiceUuid(BluetoothUuid.fromShort(0x180D)) + .setIncludeDeviceName(true), + null).onResult((advertisement, advErr) -> { + // broadcasting; keep the handle and stop() it when done + }); + // push a new value to every subscribed central + server.notifyValue(status, new byte[] {0, 80}); + }); + }); + // end::bluetooth-java-004[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava005Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava005Snippet.java new file mode 100644 index 00000000000..5271833da07 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava005Snippet.java @@ -0,0 +1,124 @@ +/* + * 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.le.BlePeripheral; + +class BluetoothJava005Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + BlePeripheral peripheral; + int psm = 0x81; + void snippet() throws Exception { + // tag::bluetooth-java-005[] + if (!Bluetooth.getInstance().isL2capSupported()) { + return; + } + peripheral.openL2capChannel(psm, true).onResult((channel, err) -> { + if (err != null) { + return; + } + // the streams block -- always consume them off the EDT + CN.startThread(() -> { + try { + OutputStream out = channel.getOutputStream(); + out.write(new byte[] {1, 2, 3}); + out.flush(); + InputStream in = channel.getInputStream(); + byte[] buffer = new byte[4096]; + int size = in.read(buffer); + // process the response... + } catch (IOException ioErr) { + // transport failure + } finally { + try { + channel.close(); + } catch (IOException ignored) { + } + } + }, "L2CAP IO").start(); + }); + + // the peripheral side listens and publishes its PSM to centrals + Bluetooth.getInstance().getLE().openL2capServer(true) + .onResult((server, serverErr) -> { + if (serverErr == null) { + int assignedPsm = server.getPsm(); + // advertise assignedPsm through a GATT characteristic, + // then server.accept() incoming channels + } + }); + // end::bluetooth-java-005[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava006Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava006Snippet.java new file mode 100644 index 00000000000..bcb4ebd5e74 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava006Snippet.java @@ -0,0 +1,114 @@ +/* + * 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.classic.BluetoothClassic; + +class BluetoothJava006Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::bluetooth-java-006[] + Bluetooth bt = Bluetooth.getInstance(); + if (!bt.isClassicSupported()) { + // classic Bluetooth is unavailable -- iOS among others + return; + } + BluetoothClassic classic = bt.getClassic(); + classic.startDiscovery(sighting -> { + if ("SerialPrinter".equals(sighting.getDevice().getName())) { + classic.connect(sighting.getDevice(), BluetoothUuid.SPP, true) + .onResult((connection, err) -> { + if (err != null) { + return; + } + // consume the blocking streams off the EDT, exactly + // like the L2CAP example + }); + } + }); + + // accepting incoming SPP clients + classic.listen("Codename One SPP", BluetoothUuid.SPP, true) + .onResult((server, err) -> { + if (err == null) { + server.accept().onResult((connection, acceptErr) -> { + // one client accepted; call accept() again for the next + }); + } + }); + // end::bluetooth-java-006[] + } +} diff --git a/docs/developer-guide/Bluetooth.asciidoc b/docs/developer-guide/Bluetooth.asciidoc new file mode 100644 index 00000000000..924da33d4fd --- /dev/null +++ b/docs/developer-guide/Bluetooth.asciidoc @@ -0,0 +1,190 @@ +== Bluetooth + +Codename One ships a first-class Bluetooth API under `com.codename1.bluetooth` that covers both Bluetooth Low Energy roles and classic Bluetooth: scan for and connect to BLE peripherals (the GATT client), act as a BLE peripheral (a local GATT server plus advertising), stream bytes over L2CAP connection-oriented channels, and exchange RFCOMM streams with classic serial devices. + +`Bluetooth.getInstance()` is the single entry point and never returns `null` -- ports without Bluetooth return a fallback whose operations fail fast with `BluetoothError.NOT_SUPPORTED`, so calling code needs no platform-specific `if`. Every callback of the API -- `AsyncResource` results, scan sightings, connection events, notifications, adapter state changes -- is delivered on the EDT; the only exceptions are the blocking RFCOMM/L2CAP streams, which must be consumed off the EDT. + +[options="header"] +|=== +| Capability | Android | iOS / Mac Catalyst | Simulator (mock) | Simulator (native backend) | JavaScript +| BLE central (scan, GATT client) | yes | yes | yes | yes | browser chooser only +| BLE peripheral (GATT server + advertising) | yes | yes (not tvOS/watchOS) | yes | -- | -- +| L2CAP channels | yes (Android 10+) | yes (iOS 11+) | yes | -- | -- +| Classic RFCOMM | yes | -- | yes | -- | -- +| Bonding | yes | OS-managed | yes | -- | -- +|=== + +On the JavaScript port, Web Bluetooth limits the central role to the browser's device chooser (Chrome-family browsers over HTTPS): `startScan(...)` surfaces the chooser built from the scan filters and delivers the single user-picked device as one `ScanResult`. No further sightings ever arrive, the RSSI is unknown, the MTU is fixed at 512 and peripheral mode, classic Bluetooth and L2CAP are unavailable. + +Always branch through the capability queries -- `isLeSupported()`, `isPeripheralModeSupported()`, `isL2capSupported()`, `isClassicSupported()` -- rather than through platform detection. + +=== Quick start: Scan and connect + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava001Snippet.java[tag=bluetooth-java-001,indent=0] +---- + +`ScanFilter` criteria on one filter are AND-combined; multiple filters added to the same `ScanSettings` are OR-combined, and a scan without filters reports every advertising device. Any number of scans may run concurrently -- each `BleScan` handle only sees advertisements matching its own filters, and the platform scan stops when the last handle stops. On iOS a service-UUID filter is also what keeps scans alive in the background, so prefer filtered scans. + +Peripheral addresses returned by `BluetoothDevice.getAddress()` are stable per app install -- the MAC address on Android and desktop, the per-app CoreBluetooth identifier on iOS -- and safe to persist for later reconnection through `BluetoothLE.getPeripheral(String)`. They're not portable across devices or platforms, so never treat them as the peripheral's real hardware address. + +`getAdapterState()` reports where the local radio stands (`POWERED_ON`, `POWERED_OFF`, `UNAUTHORIZED`, ...) and `addAdapterStateListener(...)` fires on the EDT whenever it changes -- the natural place to pause scans and gray out UI. `requestEnable()` shows the Android system dialog asking the user to turn the adapter on; on iOS there is no programmatic enable flow, so it resolves `false` and the app should direct the user to the Settings app instead. + +Connection lifecycle is equally explicit. `connect(ConnectionOptions)` accepts a timeout (iOS connect requests never time out on their own, so set one) and an `autoConnect` flag that asks the platform to re-establish the link whenever the peripheral comes back into range. `addConnectionListener(...)` reports every transition including link loss, and every failure across the API surfaces as a `BluetoothException` whose `getError()` returns a typed `BluetoothError` constant -- no string matching required. + +=== Reading and writing characteristics + +`discoverServices()` caches the peripheral's GATT database; from there `getService(...)`, `getCharacteristic(...)` and the convenience accessors on `GattCharacteristic` cover the day-to-day operations: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava002Snippet.java[tag=bluetooth-java-002,indent=0] +---- + +All GATT operations return independent `AsyncResource` handles and may be issued concurrently: an internal per-peripheral queue serializes them toward the platform stack (which allows only one in-flight request per connection) and applies a safety timeout, so a lost platform callback can never wedge the queue. Operations on different peripherals run fully concurrently. `writeWithoutResponse(...)` resolves once the value is queued to the controller -- there is no remote acknowledgement. + +A default BLE connection carries 23 bytes per attribute operation; `requestMtu(...)` negotiates a larger value and resolves with what was granted (on iOS the OS negotiates by itself, so the request resolves immediately with the current value -- check `getMtu()` before chunking data). `requestConnectionPriority(...)` trades latency against battery on Android and is a successful no-op on iOS. `createBond()` initiates pairing where the peripheral requires an encrypted link; on iOS bonding is OS-managed and triggered automatically by encrypted characteristics, so the call resolves `true` without user interaction. + +=== Notifications + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava003Snippet.java[tag=bluetooth-java-003,indent=0] +---- + +The Client Characteristic Configuration descriptor (CCCD) is handled automatically: the first listener triggers the descriptor write that arms notifications (indications are used when the characteristic only supports those) and removing the last listener disarms it again. Values stream to the listeners on the EDT until unsubscribed or disconnected. + +=== Peripheral mode + +When `isPeripheralModeSupported()` returns `true` the device can serve data itself -- open a local GATT server, add service definitions and advertise them: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava004Snippet.java[tag=bluetooth-java-004,indent=0] +---- + +Characteristics with a static value (`setValue(...)`) are served without involving the listener; characteristics without one route every read to `characteristicReadRequest(...)`, which must answer via `respond(...)` or `reject(...)` -- centrals time out unanswered requests. + +Two iOS platform notes: CoreBluetooth only broadcasts the local name and service UUIDs, dropping manufacturer data, service data and TX power from advertisements (Android broadcasts everything). And iOS serves descriptors from their static values only -- descriptor read/write requests never reach the listener there, so always give local descriptors a static value for cross-platform behavior. + +=== L2CAP channels + +L2CAP connection-oriented channels provide a raw bidirectional byte stream -- much higher throughput than chunking data through GATT characteristics. The peripheral side opens a listener and publishes its Protocol/Service Multiplexer (PSM), typically through a GATT characteristic; the central side connects to that PSM: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava005Snippet.java[tag=bluetooth-java-005,indent=0] +---- + +On Android an L2CAP channel establishes its own link and doesn't require `connect()` first; on iOS the peripheral must be connected. The streams throw plain `java.io.IOException` on transport failure and, like all Bluetooth streams, block -- never touch them on the EDT. + +=== Classic Bluetooth + +Classic (BR/EDR) Bluetooth covers inquiry discovery, bonding and RFCOMM stream connections -- the Serial Port Profile (SPP) world of printers, scanners and industrial equipment. iOS doesn't expose classic Bluetooth to applications, so this role is Android, desktop and simulator only; `isClassicSupported()` is the gate: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BluetoothJava006Snippet.java[tag=bluetooth-java-006,indent=0] +---- + +`startDiscovery(...)` runs an inquiry scan (about 12 seconds) and returns a live `ClassicDiscovery` handle that resolves when the inquiry ends. `getBondedDevices()` lists already-paired devices without a discovery, and `connect(String, BluetoothUuid, boolean)` reconnects to a persisted address directly. + +=== Permissions and build hints + +The build pipeline scans the application's bytecode and injects only what the referenced packages need -- apps that never touch `com.codename1.bluetooth` see no manifest or plist change, and a central-only app never carries advertise permissions. + +[options="header"] +|=== +| Detected usage | Android injected (target SDK 31+) | iOS injected +| Any `com.codename1.bluetooth` reference | `BLUETOOTH` (capped at `maxSdkVersion="30"`), `` | `NSBluetoothAlwaysUsageDescription` + `NSBluetoothPeripheralUsageDescription` defaults (only if unset), CoreBluetooth.framework, the CN1 Bluetooth natives +| Scanning (`ScanSettings` / `BleScan` / classic discovery) | `BLUETOOTH_ADMIN` (capped at 30), `BLUETOOTH_SCAN` with `usesPermissionFlags="neverForLocation"`, `ACCESS_FINE_LOCATION` (capped at 30) | -- +| Connections (GATT, `BlePeripheral`, RFCOMM, L2CAP) | `BLUETOOTH_CONNECT` | -- +| Peripheral mode (`com.codename1.bluetooth.le.server`) | `BLUETOOTH_ADMIN` (capped at 30), `BLUETOOTH_ADVERTISE` | -- +| Classic (`com.codename1.bluetooth.classic`) | `` | not supported +|=== + +Builds targeting an SDK below 31 receive the legacy permissions uncapped and none of the Android 12 ones. Permissions you declare yourself (via `android.xpermissions`) are never duplicated. + +Override the defaults with the matching build hints: + +[options="header"] +|=== +| Build hint | Default | Notes +| `android.bluetooth.neverForLocation` | `true` | Declares that scan results never derive the user's location, which caps `ACCESS_FINE_LOCATION` at API 30 and flags `BLUETOOTH_SCAN` accordingly +| `android.bluetooth.required` | `false` | Marks the BLE hardware feature required so stores hide the app on devices without it +| `ios.bluetooth.background` | (none) | `central`, `peripheral` or `central,peripheral` -- merged into `UIBackgroundModes` as `bluetooth-central` / `bluetooth-peripheral` +| `ios.NSBluetoothAlwaysUsageDescription` | "Communicates with nearby Bluetooth accessories." | Localise this -- Apple rejects builds that ship generic privacy copy +|=== + +Beacon and indoor-positioning apps are the one case where the `neverForLocation` default is wrong: an app that derives location from BLE sightings must set `android.bluetooth.neverForLocation=false`, otherwise Android 12+ filters beacon advertisements out of its scan results. With the hint set to `false` the location permission stays uncapped and the app keeps prompting for it on Android 12+. + +=== Simulator support + +The JavaSE simulator implements the whole API against a scriptable virtual Bluetooth stack. `Simulate -> Bluetooth Simulation` opens a window with a tree of the simulated adapter, the virtual peripherals and the app's own peripheral role; selecting a node opens its detail editor (hex value editors on characteristics), a toolbar stages demo devices, failure injection arms the next operation of a chosen kind (`connect`, `read`, `write`, `discover`, `subscribe`, `scan` and the rest) to fail with a chosen `BluetoothError`, a latency spinner delays every asynchronous completion and a live event log shows what the stack does. All settings persist between simulator runs. + +image::img/bluetooth-simulator-devices.png[The Bluetooth Simulation window with the device tree and a characteristic editor,scaledwidth=80%] + +The window's backend selector switches the simulator between the virtual stack and a native backend that drives the host machine's real Bluetooth radio through a bundled btleplug helper process (CoreBluetooth on macOS, BlueZ on Linux, WinRT on Windows). The native backend is central-only -- real scanning, connections and GATT operations against physical devices, without leaving the simulator. The initial backend comes from the `cn1.bluetooth.backend` system property (default `simulator`). + +image::img/bluetooth-simulator-log.png[The Bluetooth Simulation event log while a scan and connect run,scaledwidth=80%] + +The `Simulate -> Bluetooth` menu exposes the same operations as one-click hooks; every item is also callable from cross-platform code via `CN.execute("bluetooth:itemN")`: + +[options="header"] +|=== +| Hook | Menu label | Effect +| `bluetooth:item1` | Toggle Adapter | Flips the simulated adapter between `POWERED_ON` and `POWERED_OFF` +| `bluetooth:item2` | Add Demo Peripheral | Registers the canonical demo device: `AA:BB:CC:DD:EE:01` "SimulatedSensor" with service `0x180D`, a read/write/notify characteristic `0x2A37` (carrying a CCCD) and a writable `0x2A39` +| `bluetooth:item3` | Push Demo Notification | Pushes a rolling one-byte notification from the demo peripheral's `0x2A37` characteristic +| `bluetooth:item4` | Disconnect All | Drops every live connection from the remote side +| `bluetooth:item5` | Clear Peripherals | Removes every registered virtual peripheral +| `bluetooth:item6` | Use Simulator Backend | Switches back to the virtual stack +| `bluetooth:item7` | Use Native Backend | Switches to the host machine's real radio +| `bluetooth:item8` | (API only) | Arms the next GATT read to fail with `GATT_ERROR` +|=== + +Everything the window does goes through the scriptable `BluetoothSimulator` API in `com.codename1.impl.javase.bluetooth`, so tests and sample apps can stage the same devices programmatically. This code runs against the JavaSE port only -- it isn't part of the cross-platform API and won't compile in a portable code base, so keep it in your test or debug scaffolding: + +[source] +---- +BluetoothSimulator.addPeripheral( + new VirtualPeripheral("AA:BB:CC:DD:EE:10") + .setName("Thermometer") + .addAdvertisedServiceUuid(BluetoothUuid.fromShort(0x1809)) + .withService(new VirtualService(BluetoothUuid.fromShort(0x1809)) + .withCharacteristic(new VirtualCharacteristic( + BluetoothUuid.fromShort(0x2A1C), + GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_INDICATE, + new byte[] {0, 0, 0, 98}) + .withDescriptor(new VirtualDescriptor( + BluetoothUuid.CCCD, new byte[] {0, 0}))))); +BluetoothSimulator.setLatencyMillis(50); +BluetoothSimulator.failNext("connect", + BluetoothError.CONNECTION_FAILED, "Injected for testing"); +---- + +==== Record and replay + +Fixtures bridge the two backends: the window's "Record from real hardware" button (or `scripts/bluetooth/capture-fixture.sh`) scans the host machine's real radio through the native backend for a chosen duration, captures device identities, RSSI timelines, advertisement payloads and GATT databases, scrambles the trace deterministically so committed fixtures never carry real device identities, and writes it as JSON. `BluetoothSimulator.loadFixture(InputStream)` replays the trace into the virtual stack -- devices appear at their recorded first-sighting times and their RSSI timelines replay on the stack's scheduler, so a scan against messy real-world radio traffic becomes a reproducible test input. + +=== Testing + +Cross-platform test code drives the simulation through the hooks: `CN.execute("bluetooth:item2")` stages the demo peripheral, the test scans for `SimulatedSensor`, connects and subscribes to `0x2A37`, and `CN.execute("bluetooth:item3")` pushes a notification to assert on -- no simulator-only imports needed, so the same test class compiles for device targets and runs unchanged in the simulator's test runner. Failure paths test the same way: `CN.execute("bluetooth:item8")` arms the next GATT read to fail with `GATT_ERROR`, letting a portable test verify the app's error handling. + +For fully deterministic unit tests the JavaSE port also allows constructing a private `SimulatedBluetoothStack` on a `ManualScheduler`, where time only advances when the test pumps it -- no sleeps, no flakiness from real timers; see the Bluetooth tests under the `javase` Maven module for the pattern. + +=== Platform behaviour + +[options="header"] +|=== +| Platform | Address semantics | MTU | Bonding | Notes +| Android | Real MAC address | `requestMtu(...)` negotiates, default 23 | `createBond()` prompts the user | Background scanning is throttled by the OS; use filtered scans and expect slower sightings +| iOS / Mac Catalyst | Per-app CoreBluetooth identifier UUID | Negotiated by the OS; `requestMtu(...)` resolves with the current value | OS-managed, triggered by encrypted characteristics; `getBondedPeripherals()` is empty | Background operation needs `ios.bluetooth.background` plus service-UUID scan filters; advertisements carry only name and service UUIDs +| Simulator (mock) | Synthetic (`AA:BB:CC:DD:EE:01`) | Simulated negotiation | Simulated | Timestamps come from a monotonic counter -- treat them as ordered, not as wall-clock times +| Simulator (native backend) | Host-OS device identifier | Real negotiation | Not supported | Central role only; requires the bundled helper binary and OS Bluetooth permission for the terminal/IDE +| JavaScript | Per-origin Web Bluetooth identifier | Fixed at 512 | Browser-managed | One user-picked device per chooser; `startScan` requires a user gesture (call it from a button handler); RSSI unavailable +|=== + +Persist addresses only for same-install reconnection, branch on the capability queries rather than on `Display.getPlatformName()`, and keep every byte-stream consumer off the EDT -- those three rules keep one code base working across every column of the capability matrix. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index b21b9277658..73e7081ddb6 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -143,6 +143,8 @@ include::Ai-And-Speech.asciidoc[] include::Near-Field-Communication.asciidoc[] +include::Bluetooth.asciidoc[] + include::Printing.asciidoc[] include::Network-Connectivity.asciidoc[] diff --git a/docs/developer-guide/img/bluetooth-simulator-devices.png b/docs/developer-guide/img/bluetooth-simulator-devices.png new file mode 100644 index 00000000000..73727d66b71 Binary files /dev/null and b/docs/developer-guide/img/bluetooth-simulator-devices.png differ diff --git a/docs/developer-guide/img/bluetooth-simulator-log.png b/docs/developer-guide/img/bluetooth-simulator-log.png new file mode 100644 index 00000000000..5b90dd3deb2 Binary files /dev/null and b/docs/developer-guide/img/bluetooth-simulator-log.png differ diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 5056ab5eb72..1df9ae0cf16 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -617,3 +617,17 @@ stdio JSON-RPC subprocess Codex + +# Bluetooth chapter terms (Bluetooth.asciidoc). The all-caps and +# digit-containing generic shapes above already cover GATT, CCCD, MTU, +# PSM, SPP, RSSI, RFCOMM, BLE and L2CAP; the entries below are the +# remaining proper nouns and technical adjectives. +# The Rust BLE library that powers the simulator's native backend. +btleplug +# The Linux Bluetooth stack (trailing single capital defeats the +# PascalCase shape). +BlueZ +[Ss]criptable +# Canonical 36 character Bluetooth UUID form, in case one appears +# outside a code span (0000180d-0000-1000-8000-00805f9b34fb). +[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java index 9f0bb2d9158..589034db84d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java @@ -181,6 +181,23 @@ public final class AiDependencyTable { .androidGradle("androidx.camera:camera-video:1.3.4") .description("Cross-platform camera (preview + frames + photo + video)")); + // First-class Bluetooth (com.codename1.bluetooth.*): CoreBluetooth + // on iOS with the two privacy strings defaulted only-if-unset via + // the standard entry application. Android permissions are + // deliberately NOT listed here -- the Android 12 permission split + // needs maxSdkVersion / usesPermissionFlags attributes this table + // cannot express, so AndroidGradleBuilder injects nuanced manifest + // fragments through BluetoothManifestFragments instead. The + // framework linking + CN1_INCLUDE_BLUETOOTH define flip likewise + // happen in IPhoneBuilder (iosFrameworks is documentation-only). + e.add(new Entry("com/codename1/bluetooth/") + .iosFrameworks("CoreBluetooth") + .iosPlist("NSBluetoothAlwaysUsageDescription", + "Communicates with nearby Bluetooth accessories.") + .iosPlist("NSBluetoothPeripheralUsageDescription", + "Communicates with nearby Bluetooth accessories.") + .description("Cross-platform Bluetooth (BLE central/peripheral, L2CAP, classic RFCOMM)")); + // On-device Stable Diffusion: bundled Core ML model on iOS, // ONNX runtime on Android. Flag the >2 GB upload concern so // the cloud build server can abort early with a helpful diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 12a137ea514..76f3e0ad150 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -168,7 +168,10 @@ public File getGradleProjectDirectory() { "android.permission.BIND_WALLPAPER", "android.permission.BLUETOOTH", "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_PRIVILEGED", + "android.permission.BLUETOOTH_SCAN", "android.permission.BODY_SENSORS", "android.permission.BROADCAST_PACKAGE_REMOVED", "android.permission.BROADCAST_SMS", @@ -346,6 +349,18 @@ public File getGradleProjectDirectory() { private boolean usesUsbHost; private boolean usesNetworkTypeListener; + // First-class Bluetooth (com.codename1.bluetooth.*). The scanner sets + // per-capability flags keyed on the permission-aligned package layout + // (le/ = central, le/server/ = advertise, classic/ = BR/EDR) so a + // central-only app never carries BLUETOOTH_ADVERTISE and a non-Bluetooth + // app sees no manifest change. Injection happens through + // BluetoothManifestFragments to keep the Android 12 nuances testable. + private boolean usesBluetooth; + private boolean usesBluetoothScan; + private boolean usesBluetoothConnect; + private boolean usesBluetoothPeripheral; + private boolean usesBluetoothClassic; + private boolean integrateMoPub = false; private static final boolean isMac; @@ -1453,6 +1468,38 @@ public void usesClass(String cls) { if (cls.equals("com/codename1/io/NetworkTypeListener")) { usesNetworkTypeListener = true; } + + // First-class Bluetooth: the package layout is + // permission-aligned so class references map straight + // onto the Android 12 permission split. Scan/connect + // are refined by which model classes the app touches + // (Scan* handles vs the GATT/stream types); the + // usesClassMethod hook below catches facade-only + // callers. + if (cls.indexOf("com/codename1/bluetooth/") == 0) { + usesBluetooth = true; + if (cls.indexOf("com/codename1/bluetooth/le/server/") == 0) { + usesBluetoothPeripheral = true; + } else if (cls.indexOf("com/codename1/bluetooth/classic/") == 0) { + usesBluetoothClassic = true; + if (cls.indexOf("com/codename1/bluetooth/classic/ClassicDiscovery") == 0) { + usesBluetoothScan = true; + } + if (cls.indexOf("com/codename1/bluetooth/classic/Rfcomm") == 0 + || cls.indexOf("com/codename1/bluetooth/classic/BluetoothClassic") == 0) { + usesBluetoothConnect = true; + } + } else if (cls.indexOf("com/codename1/bluetooth/gatt/") == 0) { + usesBluetoothConnect = true; + } else if (cls.indexOf("com/codename1/bluetooth/le/Scan") == 0 + || cls.indexOf("com/codename1/bluetooth/le/BleScan") == 0) { + usesBluetoothScan = true; + } else if (cls.indexOf("com/codename1/bluetooth/le/BlePeripheral") == 0 + || cls.indexOf("com/codename1/bluetooth/le/Connection") == 0 + || cls.indexOf("com/codename1/bluetooth/le/L2cap") == 0) { + usesBluetoothConnect = true; + } + } } @@ -1462,6 +1509,37 @@ public void usesClassMethod(String cls, String method) { vibratePermission = true; } + // Bluetooth facade-only callers: refine scan/connect/ + // peripheral flags from the invoked entry-point methods + // when the app never references the model classes + // directly. + if (cls.indexOf("com/codename1/bluetooth/le/BluetoothLE") == 0) { + if (method.indexOf("startScan") > -1) { + usesBluetoothScan = true; + } + if (method.indexOf("openGattServer") > -1 + || method.indexOf("startAdvertising") > -1 + || method.indexOf("openL2capServer") > -1) { + usesBluetoothPeripheral = true; + } + if (method.indexOf("getPeripheral") > -1 + || method.indexOf("getConnectedPeripherals") > -1 + || method.indexOf("getBondedPeripherals") > -1) { + usesBluetoothConnect = true; + } + } + if (cls.indexOf("com/codename1/bluetooth/classic/BluetoothClassic") == 0) { + if (method.indexOf("startDiscovery") > -1 + || method.indexOf("requestDiscoverable") > -1) { + usesBluetoothScan = true; + } + if (method.indexOf("connect") > -1 + || method.indexOf("listen") > -1 + || method.indexOf("createBond") > -1) { + usesBluetoothConnect = true; + } + } + // Apps that call the low-level CN/Display review entry point // directly (without the com.codename1.appreview facade). if (!usesAppReview @@ -1710,6 +1788,27 @@ public void usesClassMethod(String cls, String method) { } } + // First-class Bluetooth permission injection. The scanner above set + // per-capability flags; BluetoothManifestFragments handles the + // Android 12 permission split (BLUETOOTH_SCAN/CONNECT/ADVERTISE with + // legacy permissions capped at API 30) and quote-delimited dedup so + // projects migrating from the old BLE cn1lib (which merged legacy + // permissions via android.xpermissions) don't get duplicates. + // android.bluetooth.neverForLocation (default true) declares scan + // results are never used for location -- beacon/location apps must + // set it to false; android.bluetooth.required=true hides the app + // from devices without BLE hardware. + if (usesBluetooth) { + boolean neverForLocation = !"false".equalsIgnoreCase( + request.getArg("android.bluetooth.neverForLocation", "true")); + boolean bleRequired = "true".equalsIgnoreCase( + request.getArg("android.bluetooth.required", "false")); + xPermissions = BluetoothManifestFragments.inject(xPermissions, + usesBluetoothScan, usesBluetoothConnect, + usesBluetoothPeripheral, usesBluetoothClassic, + neverForLocation, bleRequired, targetSDKVersionInt); + } + boolean useFCM = pushPermission && "fcm".equalsIgnoreCase(request.getArg("android.messagingService", "fcm")); if (useFCM) { request.putArgument("android.fcm.minPlayServicesVersion", "12.0.1"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BluetoothManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BluetoothManifestFragments.java new file mode 100644 index 00000000000..f35db397ee2 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BluetoothManifestFragments.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2012, 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.builders; + +/** + * Builds the AndroidManifest permission/feature fragments injected when the + * bytecode scanner detects usage of the {@code com.codename1.bluetooth} API. + * Extracted into a pure static helper so the Android 12 permission-split + * nuances (maxSdkVersion caps, {@code neverForLocation}) are unit-testable + * and so the BuildDaemon copy of this class stays trivially diffable -- + * keep this file in sync with + * {@code com.codename1.build.daemon.BluetoothManifestFragments}. + * + *

Duplicate suppression uses quote-delimited tokens + * ({@code "android.permission.BLUETOOTH\""}) rather than plain substring + * checks: {@code BLUETOOTH_SCAN} contains {@code BLUETOOTH}, so a loose + * check would wrongly skip the legacy permission when the new one is + * present (and vice versa a user-declared legacy permission must not + * suppress the Android 12 ones).

+ */ +final class BluetoothManifestFragments { + + private BluetoothManifestFragments() { + } + + /** + * Returns {@code xPermissions} with the Bluetooth fragments prepended. + * + * @param xPermissions the current accumulated manifest fragment + * @param scan scanning/discovery API usage detected + * @param connect connection/GATT/RFCOMM/L2CAP usage detected + * @param peripheral peripheral-mode (advertise + GATT server) + * usage detected + * @param classic classic (BR/EDR) API usage detected + * @param neverForLocation {@code android.bluetooth.neverForLocation} + * hint (default true): declare that scan + * results are never used to derive location, + * capping the legacy location permission at + * API 30. Beacon apps set the hint to false. + * @param bleFeatureRequired {@code android.bluetooth.required} hint: + * marks the BLE hardware feature required so + * stores hide the app on devices without it + * @param targetSdkVersion the build's target SDK level; below 31 the + * Android 12 permissions are not injected and + * the legacy ones are left uncapped + */ + static String inject(String xPermissions, boolean scan, boolean connect, + boolean peripheral, boolean classic, boolean neverForLocation, + boolean bleFeatureRequired, int targetSdkVersion) { + String out = xPermissions == null ? "" : xPermissions; + boolean modern = targetSdkVersion >= 31; + String legacyCap = modern ? " android:maxSdkVersion=\"30\"" : ""; + + // base: any com.codename1.bluetooth usage + out = addPermission(out, "android.permission.BLUETOOTH", legacyCap); + out = addFeature(out, "android.hardware.bluetooth_le", + bleFeatureRequired); + if (classic) { + out = addFeature(out, "android.hardware.bluetooth", false); + } + if (scan) { + out = addPermission(out, "android.permission.BLUETOOTH_ADMIN", + legacyCap); + if (modern) { + out = addPermission(out, "android.permission.BLUETOOTH_SCAN", + neverForLocation + ? " android:usesPermissionFlags=\"neverForLocation\"" + : ""); + } + // BLE scan results require a location grant up to API 30; when + // the app derives location from beacons (neverForLocation=false) + // the permission must stay uncapped so 12+ keeps granting it. + out = addPermission(out, "android.permission.ACCESS_FINE_LOCATION", + modern && neverForLocation + ? " android:maxSdkVersion=\"30\"" : ""); + } + if (connect || peripheral) { + if (modern) { + out = addPermission(out, + "android.permission.BLUETOOTH_CONNECT", ""); + } + } + if (peripheral) { + out = addPermission(out, "android.permission.BLUETOOTH_ADMIN", + legacyCap); + if (modern) { + out = addPermission(out, + "android.permission.BLUETOOTH_ADVERTISE", ""); + } + } + return out; + } + + private static String addPermission(String xPermissions, String name, + String extraAttributes) { + if (xPermissions.contains("\"" + name + "\"")) { + return xPermissions; + } + return " \n" + xPermissions; + } + + private static String addFeature(String xPermissions, String name, + boolean required) { + if (xPermissions.contains("\"" + name + "\"")) { + return xPermissions; + } + return " \n" + + xPermissions; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 154297a272c..900cfcc4975 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -114,6 +114,8 @@ public class IPhoneBuilder extends Executor { private boolean usesCryptoGcm; private boolean usesBiometrics; private boolean usesNfc; + private boolean usesBluetooth; + private boolean usesBluetoothPeripheral; private boolean usesCn1Camera; private boolean usesCn1Ar; // Set when the app references com.codename1.car.* (Apple CarPlay support). Gates the @@ -846,6 +848,19 @@ public void usesClass(String cls) { usesNfcHce = true; } } + // First-class Bluetooth (com.codename1.bluetooth.*). + // Gated on actual usage so CoreBluetooth and the + // CN1Bluetooth natives are only linked/compiled for apps + // that reference the API. The peripheral flag keys on + // the permission-aligned le/server/ package so the + // bluetooth-peripheral background mode is only ever + // offered to apps that actually advertise. + if (cls.indexOf("com/codename1/bluetooth/") == 0) { + usesBluetooth = true; + if (cls.indexOf("com/codename1/bluetooth/le/server/") == 0) { + usesBluetoothPeripheral = true; + } + } // Low-level camera API (com.codename1.camera.*). Gated on // actual usage -- NOT on the camera privacy description -- // so the old modal Capture API (which only sets @@ -2322,6 +2337,54 @@ public void usesClassMethod(String cls, String method) { } } + // First-class Bluetooth: weak-link CoreBluetooth and compile in + // the CN1Bluetooth natives only when the app references + // com.codename1.bluetooth.*. The NSBluetooth* privacy strings + // are defaulted (only-if-unset) by the AiDependencyTable entry + // through the standard plist application above. Background + // operation is opt-in through the ios.bluetooth.background hint + // ("central", "peripheral" or "central,peripheral"), merged into + // ios.background_modes so the standard UIBackgroundModes + // assembly (and its plistInject-conflict check) applies. + if (usesBluetooth) { + String coreBt = "CoreBluetooth.framework"; + if (addLibs == null || addLibs.length() == 0) { + addLibs = coreBt; + } else if (!addLibs.toLowerCase().contains("corebluetooth")) { + addLibs = addLibs + ";" + coreBt; + } + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define CN1_INCLUDE_BLUETOOTH", + "#define CN1_INCLUDE_BLUETOOTH"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_BLUETOOTH", ex); + } + String btBackground = request.getArg("ios.bluetooth.background", ""); + if (btBackground.length() > 0) { + String modes = request.getArg("ios.background_modes", ""); + if (btBackground.contains("central") + && !modes.contains("bluetooth-central")) { + modes = modes.length() == 0 ? "bluetooth-central" + : modes + ",bluetooth-central"; + } + if (btBackground.contains("peripheral") + && !modes.contains("bluetooth-peripheral")) { + if (!usesBluetoothPeripheral) { + log("Warning: ios.bluetooth.background requests the " + + "bluetooth-peripheral mode but the app never " + + "references com.codename1.bluetooth.le.server; " + + "adding it anyway."); + } + modes = modes.length() == 0 ? "bluetooth-peripheral" + : modes + ",bluetooth-peripheral"; + } + request.putArgument("ios.background_modes", modes); + } + } + // Uncomment INCLUDE_CN1_CAMERA in CodenameOne_GLViewController.h // so the com.codename1.camera native bridge (CN1Camera.{h,m}) // compiles in. This is deliberately independent of diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java index 7aca82af21f..f3d291de18b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java @@ -232,6 +232,38 @@ void nonArEntriesCarryNoMetaData() { } } + @Test + void bluetoothEntryFiresForEverySubPackage() { + String[] classes = { + "com/codename1/bluetooth/Bluetooth", + "com/codename1/bluetooth/le/BlePeripheral", + "com/codename1/bluetooth/le/server/GattServer", + "com/codename1/bluetooth/gatt/GattCharacteristic", + "com/codename1/bluetooth/classic/RfcommConnection" + }; + for (String cls : classes) { + List hits = AiDependencyTable.matchesFor(cls); + assertEquals(1, hits.size(), "expected the bluetooth entry for " + cls); + AiDependencyTable.Entry e = hits.get(0); + assertNotNull(findPlistDefault(e, "NSBluetoothAlwaysUsageDescription")); + assertNotNull(findPlistDefault(e, "NSBluetoothPeripheralUsageDescription")); + assertTrue(e.iosFrameworks().contains("CoreBluetooth")); + // Android permissions deliberately live in + // BluetoothManifestFragments (maxSdkVersion / neverForLocation + // nuances the table cannot express), not in the entry. + assertTrue(e.androidPermissions().isEmpty(), + "bluetooth Android permissions must come from BluetoothManifestFragments"); + } + } + + @Test + void bluetoothEntryDoesNotFireForUnrelatedClasses() { + assertTrue(AiDependencyTable.matchesFor("com/codename1/ui/Form").isEmpty()); + // "bluetoothle" cn1lib package must NOT trigger the core entry + assertTrue(AiDependencyTable.matchesFor( + "com/codename1/bluetoothle/Bluetooth").isEmpty()); + } + private static String findPlistDefault(AiDependencyTable.Entry e, String key) { for (String[] entry : e.iosPlistEntries()) { if (key.equals(entry[0])) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/BluetoothManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/BluetoothManifestFragmentsTest.java new file mode 100644 index 00000000000..a28c5565513 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/BluetoothManifestFragmentsTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2012, 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the Android 12 permission-split fragments injected for the + * {@code com.codename1.bluetooth} API, including the quote-delimited + * duplicate suppression that protects projects migrating from the old BLE + * cn1lib (whose merged {@code android.xpermissions} already declares the + * legacy permissions). + */ +class BluetoothManifestFragmentsTest { + + private static int count(String haystack, String needle) { + int count = 0; + int idx = haystack.indexOf(needle); + while (idx >= 0) { + count++; + idx = haystack.indexOf(needle, idx + needle.length()); + } + return count; + } + + @Test + void scanOnlyOnModernTarget() { + String out = BluetoothManifestFragments.inject("", true, false, false, + false, true, false, 34); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH_SCAN\" " + + "android:usesPermissionFlags=\"neverForLocation\"")); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH\" " + + "android:maxSdkVersion=\"30\"")); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH_ADMIN\" " + + "android:maxSdkVersion=\"30\"")); + assertTrue(out.contains( + "android:name=\"android.permission.ACCESS_FINE_LOCATION\" " + + "android:maxSdkVersion=\"30\"")); + assertFalse(out.contains("BLUETOOTH_CONNECT")); + assertFalse(out.contains("BLUETOOTH_ADVERTISE")); + assertTrue(out.contains( + "android:name=\"android.hardware.bluetooth_le\" " + + "android:required=\"false\"")); + assertFalse(out.contains("\"android.hardware.bluetooth\"")); + } + + @Test + void beaconAppsKeepLocationUncapped() { + String out = BluetoothManifestFragments.inject("", true, false, false, + false, false, false, 34); + // no neverForLocation flag on SCAN + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH_SCAN\" />")); + // location permission stays valid on Android 12+ + assertTrue(out.contains( + "android:name=\"android.permission.ACCESS_FINE_LOCATION\" />")); + assertFalse(out.contains( + "ACCESS_FINE_LOCATION\" android:maxSdkVersion")); + } + + @Test + void connectOnlyGetsConnectAndBase() { + String out = BluetoothManifestFragments.inject("", false, true, false, + false, true, false, 34); + assertTrue(out.contains("android.permission.BLUETOOTH_CONNECT")); + assertTrue(out.contains("\"android.permission.BLUETOOTH\"")); + assertFalse(out.contains("BLUETOOTH_SCAN")); + assertFalse(out.contains("BLUETOOTH_ADVERTISE")); + assertFalse(out.contains("ACCESS_FINE_LOCATION")); + } + + @Test + void peripheralGetsAdvertiseAndConnect() { + String out = BluetoothManifestFragments.inject("", false, false, true, + false, true, false, 34); + assertTrue(out.contains("android.permission.BLUETOOTH_ADVERTISE")); + assertTrue(out.contains("android.permission.BLUETOOTH_CONNECT")); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH_ADMIN\" " + + "android:maxSdkVersion=\"30\"")); + } + + @Test + void classicAddsClassicFeature() { + String out = BluetoothManifestFragments.inject("", true, true, false, + true, true, false, 34); + assertTrue(out.contains( + "android:name=\"android.hardware.bluetooth\" " + + "android:required=\"false\"")); + assertTrue(out.contains( + "android:name=\"android.hardware.bluetooth_le\"")); + } + + @Test + void requiredHintFlipsBleFeature() { + String out = BluetoothManifestFragments.inject("", true, true, false, + false, true, true, 34); + assertTrue(out.contains( + "android:name=\"android.hardware.bluetooth_le\" " + + "android:required=\"true\"")); + } + + @Test + void legacyTargetSkipsModernPermissionsAndCaps() { + String out = BluetoothManifestFragments.inject("", true, true, true, + false, true, false, 30); + assertFalse(out.contains("BLUETOOTH_SCAN")); + assertFalse(out.contains("BLUETOOTH_CONNECT")); + assertFalse(out.contains("BLUETOOTH_ADVERTISE")); + assertFalse(out.contains("maxSdkVersion")); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH\" />")); + assertTrue(out.contains( + "android:name=\"android.permission.ACCESS_FINE_LOCATION\" />")); + } + + @Test + void legacyPermissionFromOldCn1libDoesNotSuppressModernOnes() { + // The old BLE cn1lib merges these via android.xpermissions. The + // substring trap: "android.permission.BLUETOOTH" is a prefix of + // "android.permission.BLUETOOTH_SCAN" -- the quote-delimited check + // must still inject SCAN/CONNECT while skipping the duplicates. + String existing = + " \n" + + " \n" + + " \n"; + String out = BluetoothManifestFragments.inject(existing, true, true, + false, false, true, false, 34); + assertEquals(1, count(out, "\"android.permission.BLUETOOTH\"")); + assertEquals(1, count(out, "\"android.permission.BLUETOOTH_ADMIN\"")); + assertTrue(out.contains("android.permission.BLUETOOTH_SCAN")); + assertTrue(out.contains("android.permission.BLUETOOTH_CONNECT")); + assertTrue(out.contains("android.permission.ACCESS_FINE_LOCATION")); + } + + @Test + void modernPermissionsDoNotSuppressLegacyBase() { + // Reverse direction of the substring trap: a user-declared + // BLUETOOTH_SCAN must not suppress the legacy BLUETOOTH entry. + String existing = + " \n"; + String out = BluetoothManifestFragments.inject(existing, true, false, + false, false, true, false, 34); + assertEquals(1, count(out, "\"android.permission.BLUETOOTH_SCAN\"")); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH\" " + + "android:maxSdkVersion=\"30\"")); + } + + @Test + void injectionIsIdempotent() { + String once = BluetoothManifestFragments.inject("", true, true, true, + true, true, false, 34); + String twice = BluetoothManifestFragments.inject(once, true, true, + true, true, true, false, 34); + assertEquals(once, twice); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/AdapterLifecycleTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/AdapterLifecycleTest.java new file mode 100644 index 00000000000..8c5b7a2d02a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/AdapterLifecycleTest.java @@ -0,0 +1,137 @@ +/* + * 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.bluetooth; + +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Adapter state plumbing on the {@link Bluetooth} facade: listener + * registration/removal, EDT dispatch of state transitions fired from a + * port thread, and the {@code isEnabled()} derivation. + */ +class AdapterLifecycleTest extends UITestBase { + + private FakeBluetooth fake; + + @BeforeEach + void installFake() { + fake = new FakeBluetooth(); + implementation.setBluetooth(fake); + } + + @Test + void stateChangesDispatchToRegisteredListeners() { + List seen = new ArrayList(); + fake.addAdapterStateListener(seen::add); + + fake.changeAdapterState(AdapterState.TURNING_OFF); + fake.changeAdapterState(AdapterState.POWERED_OFF); + flushSerialCalls(); + + assertEquals(2, seen.size()); + assertEquals(AdapterState.TURNING_OFF, seen.get(0)); + assertEquals(AdapterState.POWERED_OFF, seen.get(1)); + } + + @Test + void removedListenersNoLongerReceiveStateChanges() { + List seen1 = new ArrayList(); + List seen2 = new ArrayList(); + AdapterStateListener l1 = seen1::add; + fake.addAdapterStateListener(l1); + fake.addAdapterStateListener(seen2::add); + + fake.changeAdapterState(AdapterState.POWERED_OFF); + flushSerialCalls(); + assertEquals(1, seen1.size()); + assertEquals(1, seen2.size()); + + fake.removeAdapterStateListener(l1); + fake.changeAdapterState(AdapterState.POWERED_ON); + flushSerialCalls(); + assertEquals(1, seen1.size(), + "removed listener must not be notified"); + assertEquals(2, seen2.size()); + } + + @Test + void duplicateListenerRegistrationNotifiesOnce() { + List seen = new ArrayList(); + AdapterStateListener l = seen::add; + fake.addAdapterStateListener(l); + fake.addAdapterStateListener(l); + + fake.changeAdapterState(AdapterState.POWERED_OFF); + flushSerialCalls(); + assertEquals(1, seen.size()); + } + + @Test + void nullListenerRegistrationIsIgnored() { + fake.addAdapterStateListener(null); + fake.removeAdapterStateListener(null); + // must not throw when a change fires with no valid listeners + fake.changeAdapterState(AdapterState.POWERED_OFF); + flushSerialCalls(); + } + + @Test + void isEnabledDerivesFromTheAdapterState() { + fake.setAdapterState(AdapterState.POWERED_ON); + assertTrue(fake.isEnabled()); + for (AdapterState state : AdapterState.values()) { + if (state != AdapterState.POWERED_ON) { + fake.setAdapterState(state); + assertFalse(fake.isEnabled(), + "isEnabled must be false for " + state); + } + } + } + + @Test + void facadeExposesTheScriptedAdapterState() { + fake.setAdapterState(AdapterState.TURNING_ON); + assertEquals(AdapterState.TURNING_ON, + Bluetooth.getInstance().getAdapterState()); + assertFalse(Bluetooth.getInstance().isEnabled()); + fake.setAdapterState(AdapterState.POWERED_ON); + assertTrue(Bluetooth.getInstance().isEnabled()); + } + + @Test + void scriptedPermissionAndEnableFlowsResolveAsConfigured() { + fake.setPermissionGranted(true).setEnableGranted(true); + assertTrue(fake.hasPermission(BluetoothPermission.CONNECT)); + assertEquals(Boolean.TRUE, Bluetooth.getInstance() + .requestPermissions(BluetoothPermission.SCAN).get()); + assertEquals(Boolean.TRUE, + Bluetooth.getInstance().requestEnable().get()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/AdvertisementDataParseTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/AdvertisementDataParseTest.java new file mode 100644 index 00000000000..7d503d834d2 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/AdvertisementDataParseTest.java @@ -0,0 +1,196 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.le.AdvertisementData; +import org.junit.jupiter.api.Test; + +import static com.codename1.bluetooth.BtTestUtil.bytes; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link AdvertisementData#parse(byte[])} over raw AD structures + * (length, type, payload sequences): names, the three service-UUID widths + * (little-endian!), manufacturer and service data, TX power, skipped flags + * and the malformed-input guarantees. Pure parsing -- no Display required. + */ +class AdvertisementDataParseTest { + + @Test + void parsesCompleteLocalName() { + AdvertisementData ad = AdvertisementData.parse( + bytes(0x04, 0x09, 'H', 'R', 'M')); + assertEquals("HRM", ad.getLocalName()); + } + + @Test + void parsesShortenedLocalName() { + AdvertisementData ad = AdvertisementData.parse( + bytes(0x03, 0x08, 'H', 'R')); + assertEquals("HR", ad.getLocalName()); + } + + @Test + void parsesSixteenBitServiceUuidsLittleEndian() { + // two 16-bit UUIDs in one complete-list structure: 0x180D, 0x180F + AdvertisementData ad = AdvertisementData.parse( + bytes(0x05, 0x03, 0x0D, 0x18, 0x0F, 0x18)); + assertEquals(2, ad.getServiceUuids().size()); + assertTrue(ad.getServiceUuids().contains( + BluetoothUuid.fromShort(0x180D))); + assertTrue(ad.getServiceUuids().contains( + BluetoothUuid.fromShort(0x180F))); + } + + @Test + void parsesIncompleteSixteenBitServiceUuidList() { + AdvertisementData ad = AdvertisementData.parse( + bytes(0x03, 0x02, 0x0D, 0x18)); + assertTrue(ad.getServiceUuids().contains( + BluetoothUuid.fromShort(0x180D))); + } + + @Test + void parsesThirtyTwoBitServiceUuidsLittleEndian() { + AdvertisementData ad = AdvertisementData.parse( + bytes(0x05, 0x05, 0x78, 0x56, 0x34, 0x12)); + assertEquals(1, ad.getServiceUuids().size()); + assertTrue(ad.getServiceUuids().contains( + BluetoothUuid.fromShort(0x12345678))); + } + + @Test + void parsesOneHundredTwentyEightBitServiceUuidLittleEndian() { + // 0000180d-0000-1000-8000-00805f9b34fb transmitted little-endian + AdvertisementData ad = AdvertisementData.parse(bytes( + 0x11, 0x07, + 0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x0D, 0x18, 0x00, 0x00)); + assertEquals(1, ad.getServiceUuids().size()); + assertEquals(BluetoothUuid.fromShort(0x180D), + ad.getServiceUuids().get(0)); + } + + @Test + void parsesManufacturerDataWithLittleEndianCompanyId() { + AdvertisementData ad = AdvertisementData.parse( + bytes(0x05, 0xFF, 0x4C, 0x00, 0x01, 0x02)); + assertArrayEquals(bytes(0x01, 0x02), ad.getManufacturerData(0x004C)); + assertNull(ad.getManufacturerData(0x4C00)); + assertArrayEquals(new int[] {0x004C}, ad.getManufacturerIds()); + } + + @Test + void parsesSixteenBitServiceData() { + AdvertisementData ad = AdvertisementData.parse( + bytes(0x05, 0x16, 0x0D, 0x18, 0xAA, 0xBB)); + assertArrayEquals(bytes(0xAA, 0xBB), + ad.getServiceData(BluetoothUuid.fromShort(0x180D))); + assertNull(ad.getServiceData(BluetoothUuid.fromShort(0x180F))); + } + + @Test + void parsesOneHundredTwentyEightBitServiceData() { + AdvertisementData ad = AdvertisementData.parse(bytes( + 0x13, 0x21, + 0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x0D, 0x18, 0x00, 0x00, + 0xCC, 0xDD)); + assertArrayEquals(bytes(0xCC, 0xDD), + ad.getServiceData(BluetoothUuid.fromShort(0x180D))); + } + + @Test + void parsesTxPowerAsSignedByte() { + AdvertisementData ad = AdvertisementData.parse( + bytes(0x02, 0x0A, 0xF8)); + assertEquals(Integer.valueOf(-8), ad.getTxPowerLevel()); + } + + @Test + void flagsStructureIsSkippedWithoutBreakingLaterStructures() { + AdvertisementData ad = AdvertisementData.parse(bytes( + 0x02, 0x01, 0x06, + 0x04, 0x09, 'H', 'R', 'M')); + assertEquals("HRM", ad.getLocalName()); + } + + @Test + void unknownTypeIsSkippedWithoutBreakingLaterStructures() { + AdvertisementData ad = AdvertisementData.parse(bytes( + 0x03, 0x77, 0x01, 0x02, + 0x02, 0x0A, 0x04)); + assertEquals(Integer.valueOf(4), ad.getTxPowerLevel()); + } + + @Test + void truncatedTrailingStructureEndsParsingWithoutThrowing() { + // valid TX power, then a structure claiming 5 payload bytes with + // only 1 present + AdvertisementData ad = AdvertisementData.parse(bytes( + 0x02, 0x0A, 0xF4, + 0x05, 0x09, 'A')); + assertEquals(Integer.valueOf(-12), ad.getTxPowerLevel()); + assertNull(ad.getLocalName()); + } + + @Test + void zeroLengthStructureEndsParsing() { + AdvertisementData ad = AdvertisementData.parse(bytes( + 0x00, 0x09, 'A')); + assertNull(ad.getLocalName()); + assertTrue(ad.getServiceUuids().isEmpty()); + } + + @Test + void emptyInputYieldsEmptyData() { + AdvertisementData ad = AdvertisementData.parse(new byte[0]); + assertNull(ad.getLocalName()); + assertTrue(ad.getServiceUuids().isEmpty()); + assertEquals(0, ad.getManufacturerIds().length); + assertNull(ad.getTxPowerLevel()); + assertEquals(0, ad.getRawBytes().length); + } + + @Test + void nullInputYieldsEmptyDataWithoutThrowing() { + AdvertisementData ad = AdvertisementData.parse(null); + assertNotNull(ad); + assertNull(ad.getLocalName()); + assertTrue(ad.getServiceUuids().isEmpty()); + assertNull(ad.getRawBytes()); + } + + @Test + void rawBytesAreRetained() { + byte[] raw = bytes(0x02, 0x0A, 0x00); + assertSame(raw, AdvertisementData.parse(raw).getRawBytes()); + } + + @Test + void duplicateServiceUuidsAreCollapsed() { + AdvertisementData ad = AdvertisementData.parse(bytes( + 0x05, 0x03, 0x0D, 0x18, 0x0D, 0x18)); + assertEquals(1, ad.getServiceUuids().size()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/BleScanTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BleScanTest.java new file mode 100644 index 00000000000..71cf6e28fca --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BleScanTest.java @@ -0,0 +1,316 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.BleScan; +import com.codename1.bluetooth.le.ScanFilter; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.ScanSettings; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.codename1.bluetooth.BtTestUtil.assertFailedWith; +import static com.codename1.bluetooth.BtTestUtil.bytes; +import static org.junit.jupiter.api.Assertions.*; + +/** + * The multi-handle scan registry of {@link com.codename1.bluetooth.le.BluetoothLE}: + * several concurrent scans multiplexed over one platform scan, per-handle + * filtering and duplicate suppression, last-stop-stops-platform-scan, scan + * failure fan-out, and the {@link ScanFilter} matching matrix. All results + * are injected synchronously through the fake and only listener dispatch + * crosses onto the EDT, drained via {@code flushSerialCalls()}. + */ +class BleScanTest extends UITestBase { + + private static final BluetoothUuid HEART_RATE = + BluetoothUuid.fromShort(0x180D); + private static final BluetoothUuid BATTERY = + BluetoothUuid.fromShort(0x180F); + + private FakeBluetooth fake; + private FakeBluetoothLE le; + + @BeforeEach + void installFake() { + fake = new FakeBluetooth(); + implementation.setBluetooth(fake); + le = fake.getFakeLE(); + } + + private static ScanResult sighting(String address, String name, + BluetoothUuid advertisedService) { + AdvertisementData ad = new AdvertisementData(); + if (name != null) { + ad.setLocalName(name); + } + if (advertisedService != null) { + ad.addServiceUuid(advertisedService); + } + return new ScanResult(new FakeBlePeripheral(address, name), -42, ad, + true, 1000L); + } + + @Test + void installedFakeIsReturnedByTheFacade() { + assertSame(fake, Bluetooth.getInstance()); + assertSame(le, Bluetooth.getInstance().getLE()); + } + + @Test + void twoScansWithDifferentFiltersShareOnePlatformScanAndOnlySeeMatches() { + List seen1 = new ArrayList(); + List seen2 = new ArrayList(); + BleScan s1 = le.startScan(new ScanSettings().addFilter( + new ScanFilter().setServiceUuid(HEART_RATE)), seen1::add); + BleScan s2 = le.startScan(new ScanSettings().addFilter( + new ScanFilter().setServiceUuid(BATTERY)), seen2::add); + assertTrue(s1.isActive()); + assertTrue(s2.isActive()); + assertEquals(1, le.getStartCount(), + "both handles must share a single platform scan"); + assertTrue(le.isPlatformScanActive()); + + le.injectScanResult(sighting("AA:00", "hrm", HEART_RATE)); + le.injectScanResult(sighting("BB:00", "batt", BATTERY)); + flushSerialCalls(); + + assertEquals(1, seen1.size()); + assertEquals("AA:00", seen1.get(0).getPeripheral().getAddress()); + assertEquals(1, seen2.size()); + assertEquals("BB:00", seen2.get(0).getPeripheral().getAddress()); + + s1.stop(); + s2.stop(); + } + + @Test + void duplicateSuppressionIsPerHandle() { + List dedup = new ArrayList(); + List all = new ArrayList(); + BleScan s1 = le.startScan(new ScanSettings(), dedup::add); + BleScan s2 = le.startScan( + new ScanSettings().setAllowDuplicates(true), all::add); + + le.injectScanResult(sighting("AA:00", "hrm", HEART_RATE)); + le.injectScanResult(sighting("AA:00", "hrm", HEART_RATE)); + flushSerialCalls(); + + assertEquals(1, dedup.size(), + "allowDuplicates=false must report each device once"); + assertEquals(2, all.size(), + "allowDuplicates=true must report every sighting"); + + s1.stop(); + s2.stop(); + } + + @Test + void stoppingOneHandleKeepsThePlatformScanRunningUntilTheLastStops() { + BleScan s1 = le.startScan(new ScanSettings(), result -> { }); + BleScan s2 = le.startScan(new ScanSettings(), result -> { }); + assertEquals(1, le.getStartCount()); + + s1.stop(); + assertTrue(le.isPlatformScanActive(), + "platform scan must survive while another handle is active"); + assertEquals(0, le.getStopCount()); + assertFalse(s1.isActive()); + assertTrue(s1.isDone()); + assertEquals(Boolean.TRUE, s1.get(), + "a stopped handle resolves with true"); + + s2.stop(); + assertFalse(le.isPlatformScanActive()); + assertEquals(1, le.getStopCount()); + assertEquals(Boolean.TRUE, s2.get()); + + // stop on an ended handle is a no-op + s2.stop(); + assertEquals(1, le.getStopCount()); + } + + @Test + void stoppedHandleNoLongerReceivesResults() { + List seen1 = new ArrayList(); + List seen2 = new ArrayList(); + BleScan s1 = le.startScan( + new ScanSettings().setAllowDuplicates(true), seen1::add); + BleScan s2 = le.startScan( + new ScanSettings().setAllowDuplicates(true), seen2::add); + + le.injectScanResult(sighting("AA:00", "a", null)); + s1.stop(); + le.injectScanResult(sighting("AA:00", "a", null)); + flushSerialCalls(); + + assertEquals(1, seen1.size()); + assertEquals(2, seen2.size()); + s2.stop(); + } + + @Test + void scanFailureErrorsEveryActiveHandleAndClearsTheRegistry() { + BleScan s1 = le.startScan(new ScanSettings(), result -> { }); + BleScan s2 = le.startScan(new ScanSettings(), result -> { }); + + le.failScan(new BluetoothException(BluetoothError.SCAN_FAILED, + "OS aborted")); + + assertFailedWith(s1, BluetoothError.SCAN_FAILED); + assertFailedWith(s2, BluetoothError.SCAN_FAILED); + + // the registry was cleared: the next scan starts a fresh platform scan + BleScan s3 = le.startScan(new ScanSettings(), result -> { }); + assertEquals(2, le.getStartCount()); + s3.stop(); + } + + @Test + void scanFailureWithNullReasonSubstitutesScanFailed() { + BleScan s1 = le.startScan(new ScanSettings(), result -> { }); + le.failScan(null); + assertFailedWith(s1, BluetoothError.SCAN_FAILED); + } + + @Test + void startScanWithoutScanSupportFailsNotSupported() { + le.setScanSupported(false); + BleScan s = le.startScan(new ScanSettings(), result -> { }); + assertFailedWith(s, BluetoothError.NOT_SUPPORTED); + assertEquals(0, le.getStartCount()); + } + + @Test + void platformStartFailureFailsTheInitiatingHandle() { + le.failNextStart(new IllegalStateException("adapter busy")); + BleScan s = le.startScan(new ScanSettings(), result -> { }); + assertFailedWith(s, BluetoothError.SCAN_FAILED); + // the failed registration was removed: a new scan starts cleanly + BleScan s2 = le.startScan(new ScanSettings(), result -> { }); + assertTrue(s2.isActive()); + assertEquals(1, le.getStartCount()); + s2.stop(); + } + + // ------------------------------------------------------------------ + // ScanFilter matching matrix + // ------------------------------------------------------------------ + + @Test + void filterMatchesExactName() { + ScanResult r = sighting("AA:00", "Polar H10", null); + assertTrue(new ScanFilter().setName("Polar H10").matches(r)); + assertFalse(new ScanFilter().setName("Polar H9").matches(r)); + } + + @Test + void filterNameFallsBackToPeripheralNameWhenAdvertisementHasNone() { + ScanResult r = new ScanResult( + new FakeBlePeripheral("AA:00", "Polar H10"), -40, + new AdvertisementData(), true, 0); + assertTrue(new ScanFilter().setName("Polar H10").matches(r)); + assertTrue(new ScanFilter().setNamePrefix("Polar").matches(r)); + } + + @Test + void filterMatchesNamePrefix() { + ScanResult r = sighting("AA:00", "Polar H10", null); + assertTrue(new ScanFilter().setNamePrefix("Polar").matches(r)); + assertFalse(new ScanFilter().setNamePrefix("Garmin").matches(r)); + assertFalse(new ScanFilter().setNamePrefix("Polar").matches( + sighting("BB:00", null, null))); + } + + @Test + void filterMatchesAddress() { + ScanResult r = sighting("AA:00", "x", null); + assertTrue(new ScanFilter().setAddress("AA:00").matches(r)); + assertFalse(new ScanFilter().setAddress("BB:00").matches(r)); + } + + @Test + void filterMatchesServiceUuid() { + ScanResult r = sighting("AA:00", "x", HEART_RATE); + assertTrue(new ScanFilter().setServiceUuid(HEART_RATE).matches(r)); + assertFalse(new ScanFilter().setServiceUuid(BATTERY).matches(r)); + } + + @Test + void filterMatchesManufacturerDataUnderMask() { + AdvertisementData ad = new AdvertisementData(); + ad.addManufacturerData(0x004C, bytes(0x01, 0x99, 0x55)); + ScanResult r = new ScanResult(new FakeBlePeripheral("AA:00", "x"), + -40, ad, true, 0); + + // masked compare: second byte ignored + assertTrue(new ScanFilter().setManufacturerData(0x004C, + bytes(0x01, 0x02), bytes(0xFF, 0x00)).matches(r)); + // exact compare (null mask) fails on the second byte + assertFalse(new ScanFilter().setManufacturerData(0x004C, + bytes(0x01, 0x02), null).matches(r)); + // exact compare succeeds on the true prefix + assertTrue(new ScanFilter().setManufacturerData(0x004C, + bytes(0x01, 0x99), null).matches(r)); + // null data matches any payload for the company + assertTrue(new ScanFilter().setManufacturerData(0x004C, null, null) + .matches(r)); + // company absent entirely + assertFalse(new ScanFilter().setManufacturerData(0x0059, null, null) + .matches(r)); + // pattern longer than the payload + assertFalse(new ScanFilter().setManufacturerData(0x004C, + bytes(0x01, 0x99, 0x55, 0x77), null).matches(r)); + } + + @Test + void criteriaOnOneFilterAreAndCombined() { + ScanFilter f = new ScanFilter().setNamePrefix("Polar") + .setServiceUuid(HEART_RATE); + assertTrue(f.matches(sighting("AA:00", "Polar H10", HEART_RATE))); + assertFalse(f.matches(sighting("AA:00", "Polar H10", BATTERY))); + assertFalse(f.matches(sighting("AA:00", "Garmin", HEART_RATE))); + } + + @Test + void multipleFiltersOnSettingsAreOrCombined() { + ScanSettings settings = new ScanSettings() + .addFilter(new ScanFilter().setServiceUuid(HEART_RATE)) + .addFilter(new ScanFilter().setServiceUuid(BATTERY)); + assertTrue(settings.matches(sighting("AA:00", "a", HEART_RATE))); + assertTrue(settings.matches(sighting("BB:00", "b", BATTERY))); + assertFalse(settings.matches(sighting("CC:00", "c", + BluetoothUuid.fromShort(0x1800)))); + } + + @Test + void settingsWithoutFiltersMatchEverything() { + assertTrue(new ScanSettings().matches( + sighting("AA:00", null, null))); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothEdtTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothEdtTest.java new file mode 100644 index 00000000000..6c4aa2ddb3d --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothEdtTest.java @@ -0,0 +1,153 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BleScan; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.ScanSettings; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The threading contract: every application-facing callback of the + * Bluetooth API is delivered on the EDT even though the fakes fire the + * underlying events from the test thread. Each test records + * {@code Display.isEdt()} inside the callback, flushes the EDT and asserts + * both delivery and thread. + */ +class BluetoothEdtTest extends UITestBase { + + private boolean offEdt() { + return !Display.getInstance().isEdt(); + } + + @Test + void scanResultsAreDeliveredOnTheEdt() { + assertTrue(offEdt(), "the test itself must run off the EDT"); + FakeBluetooth fake = new FakeBluetooth(); + implementation.setBluetooth(fake); + FakeBluetoothLE le = fake.getFakeLE(); + + final List onEdt = new ArrayList(); + BleScan scan = le.startScan(new ScanSettings(), + result -> onEdt.add(Display.getInstance().isEdt())); + le.injectScanResult(new ScanResult( + new FakeBlePeripheral("AA:00", "x"), -40, null, true, 0)); + flushSerialCalls(); + + assertEquals(1, onEdt.size()); + assertEquals(Boolean.TRUE, onEdt.get(0)); + scan.stop(); + } + + @Test + void connectionEventsAreDeliveredOnTheEdt() { + assertTrue(offEdt()); + FakeBlePeripheral p = new FakeBlePeripheral("AA:01", "p"); + final List onEdt = new ArrayList(); + p.addConnectionListener( + event -> onEdt.add(Display.getInstance().isEdt())); + + p.connectNow(); + flushSerialCalls(); + + assertEquals(2, onEdt.size(), "CONNECTING and CONNECTED events"); + assertEquals(Boolean.TRUE, onEdt.get(0)); + assertEquals(Boolean.TRUE, onEdt.get(1)); + } + + @Test + void notificationsAreDeliveredOnTheEdt() { + assertTrue(offEdt()); + FakeBlePeripheral p = new FakeBlePeripheral("AA:02", "p"); + GattService s = p.buildService(BluetoothUuid.fromShort(0x180D)); + GattCharacteristic c = p.buildCharacteristic(s, + BluetoothUuid.fromShort(0x2A37), + GattCharacteristic.PROPERTY_NOTIFY); + p.connectNow(); + p.subscribe(c, (chr, value) -> { }); + + final List onEdt = new ArrayList(); + p.subscribe(c, (chr, value) -> onEdt.add( + Display.getInstance().isEdt())); + p.completeNext(Boolean.TRUE); + + p.notifyValue(c, new byte[] {1}); + flushSerialCalls(); + + assertEquals(1, onEdt.size()); + assertEquals(Boolean.TRUE, onEdt.get(0)); + } + + @Test + void adapterStateChangesAreDeliveredOnTheEdt() { + assertTrue(offEdt()); + FakeBluetooth fake = new FakeBluetooth(); + implementation.setBluetooth(fake); + + final List onEdt = new ArrayList(); + fake.addAdapterStateListener( + state -> onEdt.add(Display.getInstance().isEdt())); + fake.changeAdapterState(AdapterState.POWERED_OFF); + flushSerialCalls(); + + assertEquals(1, onEdt.size()); + assertEquals(Boolean.TRUE, onEdt.get(0)); + } + + @Test + void asyncResourceCallbacksRegisteredOnTheEdtFireOnTheEdt() { + assertTrue(offEdt()); + final FakeBlePeripheral p = new FakeBlePeripheral("AA:03", "p"); + p.connectNow(); + + final AsyncResource r = p.readRssi(); + final List onEdt = new ArrayList(); + final AtomicInteger value = new AtomicInteger(); + // register the callback ON the EDT... + display.callSeriallyAndWait(() -> r.onResult((v, err) -> { + onEdt.add(Display.getInstance().isEdt()); + if (v != null) { + value.set(v.intValue()); + } + })); + // ...then complete the operation from the test thread + p.completeNext(Integer.valueOf(-47)); + flushSerialCalls(); + + assertEquals(1, onEdt.size()); + assertEquals(Boolean.TRUE, onEdt.get(0), + "a callback registered on the EDT must be invoked on the EDT"); + assertEquals(-47, value.get()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothErrorMappingTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothErrorMappingTest.java new file mode 100644 index 00000000000..123a7e5f262 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothErrorMappingTest.java @@ -0,0 +1,179 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.gatt.GattStatus; +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.Test; + +import static com.codename1.bluetooth.BtTestUtil.assertFailedWith; +import static com.codename1.bluetooth.BtTestUtil.errorOf; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Error typing: {@link BluetoothException} never carries a {@code null} + * error code, GATT status codes travel with the exception, fake-scripted + * failures surface unchanged through the operation queue, and + * {@link GattStatus#fromAttCode(int)} maps the ATT codes. + */ +class BluetoothErrorMappingTest extends UITestBase { + + @Test + void nullErrorCodeIsCoercedToUnknownInEveryConstructor() { + assertEquals(BluetoothError.UNKNOWN, + new BluetoothException(null).getError()); + assertEquals(BluetoothError.UNKNOWN, + new BluetoothException(null, "m").getError()); + assertEquals(BluetoothError.UNKNOWN, + new BluetoothException(null, "m", + new RuntimeException()).getError()); + assertEquals(BluetoothError.UNKNOWN, + new BluetoothException(null, "m", 0x05).getError()); + } + + @Test + void errorCodeMessageAndCauseAreCarried() { + RuntimeException cause = new RuntimeException("root"); + BluetoothException e = new BluetoothException( + BluetoothError.IO_ERROR, "boom", cause); + assertEquals(BluetoothError.IO_ERROR, e.getError()); + assertEquals("boom", e.getMessage()); + assertSame(cause, e.getCause()); + } + + @Test + void gattStatusIsCarriedAndDefaultsToMinusOne() { + BluetoothException withStatus = new BluetoothException( + BluetoothError.GATT_ERROR, "write rejected", 0x03); + assertEquals(0x03, withStatus.getGattStatus()); + assertEquals(-1, + new BluetoothException(BluetoothError.GATT_ERROR) + .getGattStatus()); + assertEquals(-1, + new BluetoothException(BluetoothError.GATT_ERROR, "m") + .getGattStatus()); + } + + @Test + void scriptedFailureSurfacesUnchangedThroughTheOperationQueue() { + FakeBlePeripheral p = new FakeBlePeripheral("AA:BB:CC:DD:EE:03", "x"); + GattService s = p.buildService(BluetoothUuid.fromShort(0x180D)); + GattCharacteristic c = p.buildCharacteristic(s, + BluetoothUuid.fromShort(0x2A37), + GattCharacteristic.PROPERTY_READ); + p.connectNow(); + + BluetoothException scripted = new BluetoothException( + BluetoothError.GATT_ERROR, "read not permitted", + GattStatus.READ_NOT_PERMITTED.getAttCode()); + AsyncResource r = p.readCharacteristic(c); + p.failNext(scripted); + + BluetoothException surfaced = + assertFailedWith(r, BluetoothError.GATT_ERROR); + assertSame(scripted, surfaced, + "the scripted exception must surface unchanged"); + assertEquals(GattStatus.READ_NOT_PERMITTED.getAttCode(), + surfaced.getGattStatus()); + assertEquals(GattStatus.READ_NOT_PERMITTED, + GattStatus.fromAttCode(surfaced.getGattStatus())); + } + + @Test + void scriptedConvenienceApiFailureSurfacesThroughTheCharacteristic() { + FakeBlePeripheral p = new FakeBlePeripheral("AA:BB:CC:DD:EE:04", "x"); + GattService s = p.buildService(BluetoothUuid.fromShort(0x180D)); + GattCharacteristic c = p.buildCharacteristic(s, + BluetoothUuid.fromShort(0x2A37), + GattCharacteristic.PROPERTY_WRITE); + p.connectNow(); + + AsyncResource r = c.write(new byte[] {1}); + p.failNext(new BluetoothException(BluetoothError.BUSY, "busy")); + assertFailedWith(r, BluetoothError.BUSY); + } + + @Test + void nonBluetoothSpiFailureIsWrappedWithATypedError() { + FakeBlePeripheral p = new FakeBlePeripheral("AA:BB:CC:DD:EE:05", "x") { + @Override + protected void doReadRssi(AsyncResource out) { + throw new IllegalStateException("raw platform error"); + } + }; + p.connectNow(); + AsyncResource r = p.readRssi(); + BluetoothException e = assertFailedWith(r, BluetoothError.UNKNOWN); + assertTrue(e.getCause() instanceof IllegalStateException); + } + + @Test + void connectFailureWithForeignExceptionIsWrappedAsConnectionFailed() { + FakeBlePeripheral p = new FakeBlePeripheral("AA:BB:CC:DD:EE:06", "x"); + AsyncResource r = p.connect(); + FakeBlePeripheral.PendingOp op = p.takeNext(); + op.out.error(new IllegalStateException("stack gone")); + Throwable t = errorOf(r); + assertTrue(t instanceof IllegalStateException, + "the connect handle reports the raw failure"); + // ...but the connection-event reason is a typed BluetoothException + assertEquals(com.codename1.bluetooth.le.ConnectionState.DISCONNECTED, + p.getConnectionState()); + } + + @Test + void gattStatusMapsEveryDedicatedAttCode() { + assertEquals(GattStatus.SUCCESS, GattStatus.fromAttCode(0x00)); + assertEquals(GattStatus.INVALID_HANDLE, GattStatus.fromAttCode(0x01)); + assertEquals(GattStatus.READ_NOT_PERMITTED, + GattStatus.fromAttCode(0x02)); + assertEquals(GattStatus.WRITE_NOT_PERMITTED, + GattStatus.fromAttCode(0x03)); + assertEquals(GattStatus.INSUFFICIENT_AUTHENTICATION, + GattStatus.fromAttCode(0x05)); + assertEquals(GattStatus.REQUEST_NOT_SUPPORTED, + GattStatus.fromAttCode(0x06)); + assertEquals(GattStatus.INVALID_OFFSET, GattStatus.fromAttCode(0x07)); + assertEquals(GattStatus.INVALID_ATTRIBUTE_VALUE_LENGTH, + GattStatus.fromAttCode(0x0D)); + assertEquals(GattStatus.UNLIKELY_ERROR, GattStatus.fromAttCode(0x0E)); + assertEquals(GattStatus.INSUFFICIENT_ENCRYPTION, + GattStatus.fromAttCode(0x0F)); + } + + @Test + void gattStatusFallsBackToUnlikelyErrorForUnknownCodes() { + assertEquals(GattStatus.UNLIKELY_ERROR, GattStatus.fromAttCode(0x99)); + assertEquals(GattStatus.UNLIKELY_ERROR, GattStatus.fromAttCode(-1)); + } + + @Test + void gattStatusRoundTripsItsAttCode() { + for (GattStatus status : GattStatus.values()) { + assertEquals(status, GattStatus.fromAttCode(status.getAttCode())); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothFallbackTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothFallbackTest.java new file mode 100644 index 00000000000..e8917d1e683 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothFallbackTest.java @@ -0,0 +1,139 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.classic.BluetoothClassic; +import com.codename1.bluetooth.classic.ClassicDiscovery; +import com.codename1.bluetooth.le.BleScan; +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.bluetooth.le.ScanSettings; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.Test; + +import static com.codename1.bluetooth.BtTestUtil.assertFailedWith; +import static org.junit.jupiter.api.Assertions.*; + +/** + * The no-op contract of the fallback base classes returned on ports without + * Bluetooth: {@code TestCodenameOneImplementation.getBluetooth()} returns + * {@code null} by default, so {@link Bluetooth#getInstance()} must substitute + * a stable non-null instance whose capability queries are all {@code false} + * and whose operations fail fast with {@link BluetoothError#NOT_SUPPORTED}. + */ +class BluetoothFallbackTest extends UITestBase { + + @Test + void getInstanceIsNeverNullAndStableWhenPortHasNoBluetooth() { + assertNull(display.getBluetooth(), + "test impl should report no port Bluetooth by default"); + Bluetooth a = Bluetooth.getInstance(); + Bluetooth b = Bluetooth.getInstance(); + assertNotNull(a); + assertSame(a, b); + } + + @Test + void fallbackCapabilityQueriesAreAllFalse() { + Bluetooth bt = Bluetooth.getInstance(); + assertFalse(bt.isSupported()); + assertFalse(bt.isLeSupported()); + assertFalse(bt.isClassicSupported()); + assertFalse(bt.isPeripheralModeSupported()); + assertFalse(bt.isL2capSupported()); + assertFalse(bt.hasPermission(BluetoothPermission.SCAN)); + assertEquals(AdapterState.UNSUPPORTED, bt.getAdapterState()); + assertFalse(bt.isEnabled()); + } + + @Test + void roleEntryPointsAreNeverNullAndStable() { + Bluetooth bt = Bluetooth.getInstance(); + BluetoothLE le = bt.getLE(); + BluetoothClassic classic = bt.getClassic(); + assertNotNull(le); + assertNotNull(classic); + assertSame(le, bt.getLE()); + assertSame(classic, bt.getClassic()); + } + + @Test + void fallbackStartScanReturnsAnAlreadyFailedHandle() { + BleScan scan = Bluetooth.getInstance().getLE() + .startScan(new ScanSettings(), result -> fail( + "fallback scan must never deliver results")); + assertNotNull(scan); + assertFalse(scan.isActive()); + assertFailedWith(scan, BluetoothError.NOT_SUPPORTED); + } + + @Test + void fallbackLeQueriesAreEmptyAndPeripheralRoleFailsNotSupported() { + BluetoothLE le = Bluetooth.getInstance().getLE(); + assertNull(le.getPeripheral("00:11:22:33:44:55")); + assertTrue(le.getConnectedPeripherals(null).isEmpty()); + assertTrue(le.getBondedPeripherals().isEmpty()); + assertFailedWith(le.openGattServer(null), + BluetoothError.NOT_SUPPORTED); + assertFailedWith(le.startAdvertising(null, null, null), + BluetoothError.NOT_SUPPORTED); + assertFailedWith(le.openL2capServer(false), + BluetoothError.NOT_SUPPORTED); + } + + @Test + void fallbackClassicOperationsFailNotSupported() { + BluetoothClassic classic = Bluetooth.getInstance().getClassic(); + ClassicDiscovery d = classic.startDiscovery(result -> fail( + "fallback discovery must never deliver results")); + assertNotNull(d); + assertFailedWith(d, BluetoothError.NOT_SUPPORTED); + assertTrue(classic.getBondedDevices().isEmpty()); + assertFailedWith(classic.createBond(null), + BluetoothError.NOT_SUPPORTED); + assertFailedWith(classic.connect("00:11:22:33:44:55", + BluetoothUuid.SPP, true), BluetoothError.NOT_SUPPORTED); + assertFailedWith(classic.listen("svc", BluetoothUuid.SPP, true), + BluetoothError.NOT_SUPPORTED); + assertEquals(Boolean.FALSE, classic.requestDiscoverable(120).get()); + } + + @Test + void fallbackRequestEnableAndPermissionsResolveFalseRatherThanFail() { + Bluetooth bt = Bluetooth.getInstance(); + assertEquals(Boolean.FALSE, bt.requestEnable().get()); + assertEquals(Boolean.FALSE, bt.requestPermissions( + BluetoothPermission.SCAN, BluetoothPermission.CONNECT).get()); + } + + @Test + void fallbackAdapterListenerRegistrationIsANoOp() { + Bluetooth bt = Bluetooth.getInstance(); + AdapterStateListener l = state -> fail( + "fallback never fires adapter changes"); + // none of these should throw, including null tolerance + bt.addAdapterStateListener(l); + bt.removeAdapterStateListener(l); + bt.addAdapterStateListener(null); + bt.removeAdapterStateListener(null); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothOpQueueTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothOpQueueTest.java new file mode 100644 index 00000000000..428eed9eb29 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothOpQueueTest.java @@ -0,0 +1,205 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static com.codename1.bluetooth.BtTestUtil.bytes; +import static org.junit.jupiter.api.Assertions.*; + +/** + * The per-peripheral GATT operation queue observed through the public + * {@link com.codename1.bluetooth.le.BlePeripheral} API: strict + * serialization toward the platform SPI, per-call result correlation, + * skipping of operations cancelled while queued, and the safety timeout + * that fails a lost operation without wedging the queue. The only + * wall-clock dependency is the deliberately short 50ms operation timeout, + * awaited through a latch with a generous hang-guard bound. + */ +class BluetoothOpQueueTest extends UITestBase { + + private static final BluetoothUuid SVC = BluetoothUuid.fromShort(0x180D); + + private FakeBlePeripheral p; + private GattCharacteristic c1; + private GattCharacteristic c2; + private GattCharacteristic c3; + + @BeforeEach + void connectPeripheral() { + p = new FakeBlePeripheral("AA:BB:CC:DD:EE:01", "queued"); + GattService s = p.buildService(SVC); + c1 = p.buildCharacteristic(s, BluetoothUuid.fromShort(0x2A37), + GattCharacteristic.PROPERTY_READ); + c2 = p.buildCharacteristic(s, BluetoothUuid.fromShort(0x2A38), + GattCharacteristic.PROPERTY_READ); + c3 = p.buildCharacteristic(s, BluetoothUuid.fromShort(0x2A39), + GattCharacteristic.PROPERTY_READ); + p.connectNow(); + } + + @Test + void secondOperationIsNotStartedUntilTheFirstCompletes() { + AsyncResource r1 = p.readCharacteristic(c1); + AsyncResource r2 = p.readCharacteristic(c2); + + assertEquals(1, p.pendingCount(), + "second do* must not be invoked while the first is in flight"); + assertSame(c1, p.peekNext().characteristic); + assertFalse(r1.isDone()); + assertFalse(r2.isDone()); + + FakeBlePeripheral.PendingOp op1 = p.completeNext(bytes(0x11)); + assertSame(c1, op1.characteristic); + assertTrue(r1.isDone()); + assertFalse(r2.isDone(), "completing op1 must not resolve op2"); + + // completing op1 released op2 to the SPI + assertEquals(1, p.pendingCount()); + FakeBlePeripheral.PendingOp op2 = p.completeNext(bytes(0x22)); + assertSame(c2, op2.characteristic); + + // results correlate to the right handle + assertArrayEquals(bytes(0x11), r1.get()); + assertArrayEquals(bytes(0x22), r2.get()); + } + + @Test + void resultsCorrelateAcrossMixedOperationKinds() { + AsyncResource read = p.readCharacteristic(c1); + AsyncResource write = p.writeCharacteristic(c2, + bytes(0x7F), true); + AsyncResource rssi = p.readRssi(); + + assertEquals(FakeBlePeripheral.OpKind.READ_CHARACTERISTIC, + p.completeNext(bytes(0x01)).kind); + FakeBlePeripheral.PendingOp writeOp = p.peekNext(); + assertEquals(FakeBlePeripheral.OpKind.WRITE_CHARACTERISTIC, + writeOp.kind); + assertArrayEquals(bytes(0x7F), writeOp.value); + assertTrue(writeOp.withResponse); + p.completeNext(Boolean.TRUE); + assertEquals(FakeBlePeripheral.OpKind.READ_RSSI, + p.completeNext(Integer.valueOf(-51)).kind); + + assertArrayEquals(bytes(0x01), read.get()); + assertEquals(Boolean.TRUE, write.get()); + assertEquals(Integer.valueOf(-51), rssi.get()); + } + + @Test + void operationCancelledWhileQueuedIsSkipped() { + AsyncResource r1 = p.readCharacteristic(c1); + AsyncResource r2 = p.readCharacteristic(c2); + AsyncResource r3 = p.readCharacteristic(c3); + + assertTrue(r2.cancel(true)); + assertTrue(r2.isDone()); + + p.completeNext(bytes(0x11)); + // r2 was skipped: the next SPI call is for c3 + assertEquals(1, p.pendingCount()); + assertSame(c3, p.peekNext().characteristic); + p.completeNext(bytes(0x33)); + + assertArrayEquals(bytes(0x11), r1.get()); + assertTrue(r2.isCancelled()); + assertArrayEquals(bytes(0x33), r3.get()); + } + + @Test + void timedOutOperationFailsWithTimeoutAndTheQueueAdvances() + throws InterruptedException { + p.setOpTimeout(50); + + AsyncResource r1 = p.readCharacteristic(c1); + AsyncResource r2 = p.readCharacteristic(c2); + // r1 is already armed with the 50ms timeout; disable the timeout + // again so r2 (armed when the timer thread advances the queue) + // cannot race the test's own completeNext call + p.setOpTimeout(0); + + final CountDownLatch timedOut = new CountDownLatch(1); + final AtomicReference err = + new AtomicReference(); + r1.except(t -> { + err.set(t); + timedOut.countDown(); + }); + + // generous hang guard; the timeout itself is 50ms + assertTrue(timedOut.await(20, TimeUnit.SECONDS), + "operation timeout never fired"); + assertTrue(err.get() instanceof BluetoothException); + assertEquals(BluetoothError.TIMEOUT, + ((BluetoothException) err.get()).getError()); + + // the queue is not wedged: the second op reaches the SPI (the timer + // thread advances it, so wait with a hang guard) + p.awaitPendingCount(2, 20000); + // drain the abandoned first op, then serve the second + assertSame(c1, p.takeNext().characteristic); + FakeBlePeripheral.PendingOp op2 = p.completeNext(bytes(0x22)); + assertSame(c2, op2.characteristic); + assertArrayEquals(bytes(0x22), r2.get()); + } + + @Test + void spiThrowingSynchronouslyFailsTheOperationAndAdvances() { + // a read on a fresh, never-connected peripheral is fail-fast; here + // instead we exercise a do* that throws: subclass the fake inline + FakeBlePeripheral throwing = new FakeBlePeripheral("AA:00", "boom") { + private boolean first = true; + + @Override + protected void doReadRssi(AsyncResource out) { + if (first) { + first = false; + throw new IllegalStateException("stack hiccup"); + } + super.doReadRssi(out); + } + }; + throwing.connectNow(); + AsyncResource r1 = throwing.readRssi(); + AsyncResource r2 = throwing.readRssi(); + + assertTrue(r1.isDone(), "a throwing do* must fail its operation"); + assertEquals(BluetoothError.UNKNOWN, + ((BluetoothException) BtTestUtil.errorOf(r1)).getError()); + + // the queue advanced to the second operation + assertEquals(1, throwing.pendingCount()); + throwing.completeNext(Integer.valueOf(-60)); + assertEquals(Integer.valueOf(-60), r2.get()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothUuidTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothUuidTest.java new file mode 100644 index 00000000000..3f232395a11 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BluetoothUuidTest.java @@ -0,0 +1,166 @@ +/* + * 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.bluetooth; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The {@link BluetoothUuid} value-type contract: SIG short-form expansion, + * string parsing in the 4/8/36 character forms, canonical lowercase + * rendering, equality and the well-known constants. Pure value type -- no + * Display required. + */ +class BluetoothUuidTest { + + private static final String HEART_RATE = + "0000180d-0000-1000-8000-00805f9b34fb"; + + @Test + void fromShortExpandsOverTheBaseUuid() { + BluetoothUuid u = BluetoothUuid.fromShort(0x180D); + assertEquals(HEART_RATE, u.toString()); + assertEquals(0x0000180d00001000L, u.getMostSignificantBits()); + assertEquals(0x800000805F9B34FBL, u.getLeastSignificantBits()); + } + + @Test + void fromStringAcceptsFourHexDigits() { + assertEquals(BluetoothUuid.fromShort(0x180D), + BluetoothUuid.fromString("180D")); + } + + @Test + void fromStringAcceptsEightHexDigits() { + assertEquals(BluetoothUuid.fromShort(0x180D), + BluetoothUuid.fromString("0000180D")); + assertEquals(BluetoothUuid.fromShort(0x12345678), + BluetoothUuid.fromString("12345678")); + } + + @Test + void fromStringAcceptsCanonicalThirtySixCharacterForm() { + BluetoothUuid u = BluetoothUuid.fromString( + "5f47a3c0-1234-4e6b-9d00-000000000001"); + assertEquals(0x5F47A3C012344E6BL, u.getMostSignificantBits()); + assertEquals(0x9D00000000000001L, u.getLeastSignificantBits()); + assertEquals("5f47a3c0-1234-4e6b-9d00-000000000001", u.toString()); + } + + @Test + void fromStringIsCaseInsensitive() { + assertEquals(BluetoothUuid.fromString(HEART_RATE), + BluetoothUuid.fromString(HEART_RATE.toUpperCase())); + assertEquals(BluetoothUuid.fromString("180d"), + BluetoothUuid.fromString("180D")); + } + + @Test + void fromStringRejectsMalformedInput() { + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString(null)); + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString("")); + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString("180")); + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString("180x")); + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString("0000180g")); + // 35 characters -- one short of the canonical form + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString( + HEART_RATE.substring(0, 35))); + // 37 characters + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString(HEART_RATE + "0")); + // dash in the wrong place + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString( + "0000180d00000-1000-8000-00805f9b34fb")); + // non-hex character inside the canonical form + assertThrows(IllegalArgumentException.class, + () -> BluetoothUuid.fromString( + "0000180d-0000-1000-8000-00805f9b34fg")); + } + + @Test + void toStringIsCanonicalLowercase() { + BluetoothUuid u = BluetoothUuid.fromString(HEART_RATE.toUpperCase()); + assertEquals(HEART_RATE, u.toString()); + assertEquals("00002902-0000-1000-8000-00805f9b34fb", + BluetoothUuid.CCCD.toString()); + } + + @Test + void isShortUuidRecognizesBaseUuidDerivations() { + assertTrue(BluetoothUuid.fromShort(0x180D).isShortUuid()); + assertTrue(BluetoothUuid.fromString(HEART_RATE).isShortUuid()); + assertEquals(0x180D, + BluetoothUuid.fromString(HEART_RATE).getShortValue()); + assertEquals(0x12345678, + BluetoothUuid.fromShort(0x12345678).getShortValue()); + assertFalse(BluetoothUuid.fromString( + "5f47a3c0-1234-4e6b-9d00-000000000001").isShortUuid()); + } + + @Test + void getShortValueThrowsOnNonBaseDerivations() { + BluetoothUuid custom = BluetoothUuid.fromString( + "5f47a3c0-1234-4e6b-9d00-000000000001"); + assertThrows(IllegalStateException.class, custom::getShortValue); + } + + @Test + void equalsAndHashCodeAreValueBased() { + BluetoothUuid a = BluetoothUuid.fromShort(0x180D); + BluetoothUuid b = BluetoothUuid.fromString("180d"); + BluetoothUuid c = BluetoothUuid.fromShort(0x180F); + assertEquals(a, a); + assertEquals(a, b); + assertEquals(b, a); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + assertNotEquals(a, null); + assertNotEquals(a, HEART_RATE); + } + + @Test + void wellKnownConstantsAreCorrect() { + assertEquals("00000000-0000-1000-8000-00805f9b34fb", + BluetoothUuid.BASE.toString()); + assertEquals(0x2902, BluetoothUuid.CCCD.getShortValue()); + assertEquals(0x1101, BluetoothUuid.SPP.getShortValue()); + assertEquals(BluetoothUuid.fromShort(0x2902), BluetoothUuid.CCCD); + assertEquals(BluetoothUuid.fromShort(0x1101), BluetoothUuid.SPP); + } + + @Test + void rawHalvesConstructorMirrorsJavaUtilUuid() { + BluetoothUuid u = new BluetoothUuid(0x0000180d00001000L, + 0x800000805F9B34FBL); + assertEquals(BluetoothUuid.fromShort(0x180D), u); + assertEquals(HEART_RATE, u.toString()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/BtTestUtil.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BtTestUtil.java new file mode 100644 index 00000000000..def8f6c1864 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/BtTestUtil.java @@ -0,0 +1,78 @@ +/* + * 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.bluetooth; + +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Shared assertions for the Bluetooth suites. {@code errorOf} mirrors the + * pattern of {@code NfcTest}: an {@code except} callback registered off the + * EDT on an already-failed resource fires synchronously, so no waiting is + * involved. + */ +final class BtTestUtil { + + private BtTestUtil() { + } + + static Throwable errorOf(AsyncResource r) { + final AtomicReference err = new AtomicReference(); + r.except(new SuccessCallback() { + public void onSucess(Throwable t) { + err.set(t); + } + }); + return err.get(); + } + + /** + * Asserts the resource already failed with a {@link BluetoothException} + * carrying the expected error code, and returns the exception. + */ + static BluetoothException assertFailedWith(AsyncResource r, + BluetoothError expected) { + assertTrue(r.isDone(), "resource should be done"); + Throwable t = errorOf(r); + assertNotNull(t, "resource should carry an error"); + assertTrue(t instanceof BluetoothException, + "expected BluetoothException but was " + t); + BluetoothException be = (BluetoothException) t; + assertEquals(expected, be.getError()); + return be; + } + + static byte[] bytes(int... values) { + byte[] out = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + out[i] = (byte) values[i]; + } + return out; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBlePeripheral.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBlePeripheral.java new file mode 100644 index 00000000000..351ecdc56c8 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBlePeripheral.java @@ -0,0 +1,370 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionOptions; +import com.codename1.bluetooth.le.ConnectionPriority; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.util.AsyncResource; + +import java.util.LinkedList; +import java.util.List; + +/** + * Scripted {@link BlePeripheral} implementing every {@code do*} SPI method by + * RECORDING the pending operation; nothing completes until the test drains + * the queue explicitly via {@link #completeNext(Object)} / + * {@link #failNext(BluetoothException)}. This makes every asynchronous flow + * synchronous-on-demand and therefore deterministic. + */ +public class FakeBlePeripheral extends BlePeripheral { + + /** + * The SPI entry points that can be recorded. + */ + public enum OpKind { + CONNECT, + DISCONNECT, + DISCOVER_SERVICES, + READ_CHARACTERISTIC, + WRITE_CHARACTERISTIC, + READ_DESCRIPTOR, + WRITE_DESCRIPTOR, + SET_NOTIFICATIONS, + READ_RSSI, + REQUEST_MTU, + CONNECTION_PRIORITY, + CREATE_BOND, + OPEN_L2CAP + } + + /** + * One recorded, not-yet-completed platform operation. + */ + public static final class PendingOp { + public final OpKind kind; + /** The operation's result handle; {@code null} for DISCONNECT. */ + public final AsyncResource out; + public final GattCharacteristic characteristic; + public final GattDescriptor descriptor; + public final byte[] value; + public final boolean withResponse; + public final boolean enable; + public final boolean indication; + public final int intArg; + + PendingOp(OpKind kind, AsyncResource out, + GattCharacteristic characteristic, GattDescriptor descriptor, + byte[] value, boolean withResponse, boolean enable, + boolean indication, int intArg) { + this.kind = kind; + this.out = out; + this.characteristic = characteristic; + this.descriptor = descriptor; + this.value = value; + this.withResponse = withResponse; + this.enable = enable; + this.indication = indication; + this.intArg = intArg; + } + } + + private final Object pendingLock = new Object(); + private final LinkedList pending = new LinkedList(); + private final String address; + private final String name; + + public FakeBlePeripheral(String address, String name) { + this.address = address; + this.name = name; + } + + @Override + public String getAddress() { + return address; + } + + @Override + public String getName() { + return name; + } + + // ------------------------------------------------------------------ + // test scripting + // ------------------------------------------------------------------ + + /** + * The number of recorded operations the test has not drained yet. + */ + public int pendingCount() { + synchronized (pendingLock) { + return pending.size(); + } + } + + /** + * The head of the recorded-operation queue without removing it, or + * {@code null} when empty. + */ + public PendingOp peekNext() { + synchronized (pendingLock) { + return pending.peek(); + } + } + + /** + * Removes and returns the head of the recorded-operation queue; throws + * when no operation was recorded (the test scripted a flow that never + * reached the SPI). + */ + public PendingOp takeNext() { + synchronized (pendingLock) { + PendingOp op = pending.poll(); + if (op == null) { + throw new IllegalStateException( + "No pending platform operation was recorded"); + } + return op; + } + } + + /** + * Hang-guarded wait until at least {@code minCount} operations are + * recorded -- only needed when a non-test thread (the safety-timeout + * timer) advances the queue. Throws on timeout instead of hanging. + */ + public void awaitPendingCount(int minCount, long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis; + synchronized (pendingLock) { + while (pending.size() < minCount) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + throw new IllegalStateException("Timed out waiting for " + + minCount + " pending ops, have " + + pending.size()); + } + try { + pendingLock.wait(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted", e); + } + } + } + } + + /** + * Completes the next recorded operation with the given value. A + * DISCONNECT operation instead reports the DISCONNECTED transition, as + * the platform would. + */ + @SuppressWarnings("unchecked") + public PendingOp completeNext(Object value) { + PendingOp op = takeNext(); + if (op.kind == OpKind.DISCONNECT) { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, null); + return op; + } + ((AsyncResource) op.out).complete(value); + return op; + } + + /** + * Fails the next recorded operation with the given error. A DISCONNECT + * operation instead reports a DISCONNECTED transition carrying the + * error as its reason. + */ + public PendingOp failNext(BluetoothException error) { + PendingOp op = takeNext(); + if (op.kind == OpKind.DISCONNECT) { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, error); + return op; + } + if (!op.out.isDone()) { + op.out.error(error); + } + return op; + } + + /** + * Convenience: issues {@link #connect()} from the DISCONNECTED state and + * immediately completes the recorded platform attempt, leaving the + * peripheral CONNECTED. + */ + public AsyncResource connectNow() { + AsyncResource r = connect(); + completeNext(this); + return r; + } + + /** + * Exposes the protected operation-timeout knob for the timeout test. + */ + public void setOpTimeout(int millis) { + setOperationTimeout(millis); + } + + /** + * Exposes {@code fireConnectionStateChanged} for scripting unsolicited + * transitions (link loss and the like). + */ + public void fireState(ConnectionState newState, BluetoothException reason) { + fireConnectionStateChanged(newState, reason); + } + + /** + * Exposes {@code fireNotification} for delivering scripted + * notification/indication values. + */ + public void notifyValue(GattCharacteristic c, byte[] value) { + fireNotification(c, value); + } + + /** + * Exposes {@code fireServicesInvalidated}. + */ + public void invalidateServices() { + fireServicesInvalidated(); + } + + /** + * Builds a primary {@link GattService} owned by this peripheral via the + * public gatt-package constructors. + */ + public GattService buildService(BluetoothUuid uuid) { + return new GattService(this, uuid, true, 0); + } + + /** + * Builds a {@link GattCharacteristic} and registers it on the service. + */ + public GattCharacteristic buildCharacteristic(GattService service, + BluetoothUuid uuid, int properties) { + GattCharacteristic c = new GattCharacteristic(service, uuid, + properties, 0); + service.addCharacteristic(c); + return c; + } + + private void record(PendingOp op) { + synchronized (pendingLock) { + pending.add(op); + pendingLock.notifyAll(); + } + } + + // ------------------------------------------------------------------ + // recorded SPI + // ------------------------------------------------------------------ + + @Override + protected void doConnect(ConnectionOptions options, + AsyncResource out) { + record(new PendingOp(OpKind.CONNECT, out, null, null, null, false, + false, false, 0)); + } + + @Override + protected void doDisconnect() { + record(new PendingOp(OpKind.DISCONNECT, null, null, null, null, false, + false, false, 0)); + } + + @Override + protected void doDiscoverServices(AsyncResource> out) { + record(new PendingOp(OpKind.DISCOVER_SERVICES, out, null, null, null, + false, false, false, 0)); + } + + @Override + protected void doReadCharacteristic(GattCharacteristic c, + AsyncResource out) { + record(new PendingOp(OpKind.READ_CHARACTERISTIC, out, c, null, null, + false, false, false, 0)); + } + + @Override + protected void doWriteCharacteristic(GattCharacteristic c, byte[] value, + boolean withResponse, AsyncResource out) { + record(new PendingOp(OpKind.WRITE_CHARACTERISTIC, out, c, null, value, + withResponse, false, false, 0)); + } + + @Override + protected void doReadDescriptor(GattDescriptor d, + AsyncResource out) { + record(new PendingOp(OpKind.READ_DESCRIPTOR, out, null, d, null, false, + false, false, 0)); + } + + @Override + protected void doWriteDescriptor(GattDescriptor d, byte[] value, + AsyncResource out) { + record(new PendingOp(OpKind.WRITE_DESCRIPTOR, out, null, d, value, + false, false, false, 0)); + } + + @Override + protected void doSetNotifications(GattCharacteristic c, boolean enable, + boolean indication, AsyncResource out) { + record(new PendingOp(OpKind.SET_NOTIFICATIONS, out, c, null, null, + false, enable, indication, 0)); + } + + @Override + protected void doReadRssi(AsyncResource out) { + record(new PendingOp(OpKind.READ_RSSI, out, null, null, null, false, + false, false, 0)); + } + + @Override + protected void doRequestMtu(int mtu, AsyncResource out) { + record(new PendingOp(OpKind.REQUEST_MTU, out, null, null, null, false, + false, false, mtu)); + } + + @Override + protected void doRequestConnectionPriority(ConnectionPriority priority, + AsyncResource out) { + record(new PendingOp(OpKind.CONNECTION_PRIORITY, out, null, null, null, + false, false, false, 0)); + } + + @Override + protected void doCreateBond(AsyncResource out) { + record(new PendingOp(OpKind.CREATE_BOND, out, null, null, null, false, + false, false, 0)); + } + + @Override + protected void doOpenL2cap(int psm, boolean secure, + AsyncResource out) { + record(new PendingOp(OpKind.OPEN_L2CAP, out, null, null, null, false, + false, false, psm)); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBluetooth.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBluetooth.java new file mode 100644 index 00000000000..87ce2cb93fd --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBluetooth.java @@ -0,0 +1,165 @@ +/* + * 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.bluetooth; + +import com.codename1.util.AsyncResource; + +/** + * Scripted {@link Bluetooth} port used by the unit tests. Capabilities and + * the adapter state are configurable; the LE entry point is a + * {@link FakeBluetoothLE} whose platform scan is fully test-driven. + * Installed into the test implementation via + * {@code TestCodenameOneImplementation.setBluetooth(...)}. + */ +public class FakeBluetooth extends Bluetooth { + + private boolean supported = true; + private boolean leSupported = true; + private boolean classicSupported; + private boolean peripheralModeSupported; + private boolean l2capSupported; + private AdapterState adapterState = AdapterState.POWERED_ON; + private final FakeBluetoothLE le = new FakeBluetoothLE(); + private boolean permissionGranted; + private boolean enableGranted; + + public FakeBluetooth setSupported(boolean supported) { + this.supported = supported; + return this; + } + + public FakeBluetooth setLeSupported(boolean leSupported) { + this.leSupported = leSupported; + return this; + } + + public FakeBluetooth setClassicSupported(boolean classicSupported) { + this.classicSupported = classicSupported; + return this; + } + + public FakeBluetooth setPeripheralModeSupported(boolean supported) { + this.peripheralModeSupported = supported; + return this; + } + + public FakeBluetooth setL2capSupported(boolean l2capSupported) { + this.l2capSupported = l2capSupported; + return this; + } + + /** + * Scripts the result of {@link #requestPermissions(BluetoothPermission...)}. + */ + public FakeBluetooth setPermissionGranted(boolean granted) { + this.permissionGranted = granted; + return this; + } + + /** + * Scripts the result of {@link #requestEnable()}. + */ + public FakeBluetooth setEnableGranted(boolean granted) { + this.enableGranted = granted; + return this; + } + + /** + * Sets the adapter state without notifying listeners. + */ + public FakeBluetooth setAdapterState(AdapterState state) { + this.adapterState = state; + return this; + } + + /** + * Simulates a platform adapter state transition: updates the state and + * dispatches the registered listeners on the EDT. + */ + public void changeAdapterState(AdapterState newState) { + this.adapterState = newState; + fireAdapterStateChanged(newState); + } + + @Override + public boolean isSupported() { + return supported; + } + + @Override + public boolean isLeSupported() { + return leSupported; + } + + @Override + public boolean isClassicSupported() { + return classicSupported; + } + + @Override + public boolean isPeripheralModeSupported() { + return peripheralModeSupported; + } + + @Override + public boolean isL2capSupported() { + return l2capSupported; + } + + @Override + public AdapterState getAdapterState() { + return adapterState; + } + + @Override + public com.codename1.bluetooth.le.BluetoothLE getLE() { + return le; + } + + /** + * The scripted LE stack, typed for test convenience. + */ + public FakeBluetoothLE getFakeLE() { + return le; + } + + @Override + public boolean hasPermission(BluetoothPermission permission) { + return permissionGranted; + } + + @Override + public AsyncResource requestPermissions( + BluetoothPermission... permissions) { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.valueOf(permissionGranted)); + return r; + } + + @Override + public AsyncResource requestEnable() { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.valueOf(enableGranted)); + return r; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBluetoothLE.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBluetoothLE.java new file mode 100644 index 00000000000..f1af1c2f220 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/FakeBluetoothLE.java @@ -0,0 +1,115 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.le.BluetoothLE; +import com.codename1.bluetooth.le.ScanResult; + +/** + * Scripted {@link BluetoothLE} whose platform scan is entirely test-driven: + * {@code startPlatformScan}/{@code stopPlatformScan} only record state and + * the test injects advertisement sightings via + * {@link #injectScanResult(ScanResult)} (which routes through the core + * {@code fireScanResult} demultiplexer). + */ +public class FakeBluetoothLE extends BluetoothLE { + + private boolean scanSupported = true; + private boolean platformScanActive; + private int startCount; + private int stopCount; + private RuntimeException startFailure; + + public FakeBluetoothLE setScanSupported(boolean scanSupported) { + this.scanSupported = scanSupported; + return this; + } + + /** + * Makes the next {@code startPlatformScan} throw, exercising the + * start-failure path of {@code startScan}. + */ + public FakeBluetoothLE failNextStart(RuntimeException failure) { + this.startFailure = failure; + return this; + } + + /** + * {@code true} while the single underlying platform scan is running. + */ + public boolean isPlatformScanActive() { + return platformScanActive; + } + + /** + * How many times the platform scan was started. + */ + public int getStartCount() { + return startCount; + } + + /** + * How many times the platform scan was stopped. + */ + public int getStopCount() { + return stopCount; + } + + /** + * Injects one advertisement sighting into the core demultiplexer, as a + * port would from its platform scan callback. + */ + public void injectScanResult(ScanResult result) { + fireScanResult(result); + } + + /** + * Simulates the OS aborting the platform scan. + */ + public void failScan(BluetoothException reason) { + platformScanActive = false; + fireScanFailed(reason); + } + + @Override + protected boolean isScanSupported() { + return scanSupported; + } + + @Override + protected void startPlatformScan() { + if (startFailure != null) { + RuntimeException ex = startFailure; + startFailure = null; + throw ex; + } + platformScanActive = true; + startCount++; + } + + @Override + protected void stopPlatformScan() { + platformScanActive = false; + stopCount++; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/GattConnectionTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/GattConnectionTest.java new file mode 100644 index 00000000000..5eca5b9fd7e --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/GattConnectionTest.java @@ -0,0 +1,277 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionEvent; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static com.codename1.bluetooth.BtTestUtil.assertFailedWith; +import static com.codename1.bluetooth.BtTestUtil.bytes; +import static org.junit.jupiter.api.Assertions.*; + +/** + * The {@link BlePeripheral} connection lifecycle and GATT-database caching: + * state transitions and their events, connect-coalescing, fail-fast of + * operations while disconnected, service discovery caching/invalidations, + * and the fate of in-flight and queued operations on disconnect. + */ +class GattConnectionTest extends UITestBase { + + private static final BluetoothUuid SVC = BluetoothUuid.fromShort(0x180D); + private static final BluetoothUuid CHR = BluetoothUuid.fromShort(0x2A37); + + private FakeBlePeripheral p; + private List events; + + @BeforeEach + void createPeripheral() { + p = new FakeBlePeripheral("AA:BB:CC:DD:EE:FF", "HRM"); + events = new ArrayList(); + p.addConnectionListener(events::add); + } + + private List discoveredDb(GattCharacteristic[] chrOut) { + GattService s = p.buildService(SVC); + chrOut[0] = p.buildCharacteristic(s, CHR, + GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_WRITE); + return Arrays.asList(s); + } + + @Test + void connectMovesThroughConnectingToConnectedAndFiresBothEvents() { + assertEquals(ConnectionState.DISCONNECTED, p.getConnectionState()); + AsyncResource r = p.connect(); + assertEquals(ConnectionState.CONNECTING, p.getConnectionState()); + assertFalse(r.isDone()); + FakeBlePeripheral.PendingOp op = p.peekNext(); + assertEquals(FakeBlePeripheral.OpKind.CONNECT, op.kind); + + p.completeNext(p); + assertEquals(ConnectionState.CONNECTED, p.getConnectionState()); + assertTrue(r.isDone()); + assertSame(p, r.get()); + + flushSerialCalls(); + assertEquals(2, events.size()); + assertEquals(ConnectionState.CONNECTING, events.get(0).getState()); + assertEquals(ConnectionState.CONNECTED, events.get(1).getState()); + assertSame(p, events.get(1).getPeripheral()); + assertNull(events.get(1).getReason()); + } + + @Test + void connectWhileConnectingReturnsTheSameAttemptHandle() { + AsyncResource first = p.connect(); + AsyncResource second = p.connect(); + assertSame(first, second); + assertEquals(1, p.pendingCount(), + "only one platform connect attempt may be started"); + p.completeNext(p); + assertSame(p, first.get()); + } + + @Test + void connectWhenConnectedResolvesImmediately() { + p.connectNow(); + AsyncResource again = p.connect(); + assertTrue(again.isDone()); + assertSame(p, again.get()); + assertEquals(0, p.pendingCount(), + "no new platform attempt when already connected"); + } + + @Test + void connectFailureSurfacesTheErrorAndReturnsToDisconnected() { + AsyncResource r = p.connect(); + p.failNext(new BluetoothException(BluetoothError.CONNECTION_FAILED, + "unreachable")); + assertFailedWith(r, BluetoothError.CONNECTION_FAILED); + assertEquals(ConnectionState.DISCONNECTED, p.getConnectionState()); + } + + @Test + void disconnectMovesThroughDisconnectingAndFailsNothingWhenIdle() { + p.connectNow(); + p.disconnect(); + assertEquals(ConnectionState.DISCONNECTING, p.getConnectionState()); + FakeBlePeripheral.PendingOp op = p.completeNext(null); + assertEquals(FakeBlePeripheral.OpKind.DISCONNECT, op.kind); + assertEquals(ConnectionState.DISCONNECTED, p.getConnectionState()); + + flushSerialCalls(); + assertEquals(4, events.size()); + assertEquals(ConnectionState.DISCONNECTING, events.get(2).getState()); + assertEquals(ConnectionState.DISCONNECTED, events.get(3).getState()); + + // a second disconnect while already disconnected is a no-op + p.disconnect(); + assertEquals(0, p.pendingCount()); + } + + @Test + void disconnectDuringConnectAbortsTheAttemptWithUserCanceled() { + AsyncResource r = p.connect(); + p.disconnect(); + assertFailedWith(r, BluetoothError.USER_CANCELED); + assertEquals(ConnectionState.DISCONNECTED, p.getConnectionState()); + } + + @Test + void operationsBeforeConnectFailNotConnectedWithoutTouchingTheSpi() { + GattCharacteristic[] chr = new GattCharacteristic[1]; + discoveredDb(chr); + assertFailedWith(p.discoverServices(), BluetoothError.NOT_CONNECTED); + assertFailedWith(p.readCharacteristic(chr[0]), + BluetoothError.NOT_CONNECTED); + assertFailedWith(p.writeCharacteristic(chr[0], bytes(1), true), + BluetoothError.NOT_CONNECTED); + assertFailedWith(p.readRssi(), BluetoothError.NOT_CONNECTED); + assertFailedWith(p.requestMtu(185), BluetoothError.NOT_CONNECTED); + assertFailedWith(p.subscribe(chr[0], (c, v) -> { }), + BluetoothError.NOT_CONNECTED); + assertEquals(0, p.pendingCount(), + "fail-fast paths must not reach the platform SPI"); + } + + @Test + void discoverServicesCachesTheDatabase() { + p.connectNow(); + GattCharacteristic[] chr = new GattCharacteristic[1]; + List db = discoveredDb(chr); + + assertTrue(p.getServices().isEmpty(), "no cache before discovery"); + AsyncResource> r = p.discoverServices(); + assertEquals(FakeBlePeripheral.OpKind.DISCOVER_SERVICES, + p.peekNext().kind); + p.completeNext(db); + + assertTrue(r.isDone()); + assertEquals(1, r.get().size()); + assertEquals(1, p.getServices().size()); + assertEquals(SVC, p.getServices().get(0).getUuid()); + assertNotNull(p.getService(SVC)); + assertNull(p.getService(BluetoothUuid.fromShort(0x1800))); + assertSame(chr[0], p.getCharacteristic(SVC, CHR)); + assertNull(p.getCharacteristic(SVC, BluetoothUuid.fromShort(0x2A38))); + assertNull(p.getCharacteristic(BluetoothUuid.fromShort(0x1800), CHR)); + } + + @Test + void servicesInvalidationClearsTheCache() { + p.connectNow(); + GattCharacteristic[] chr = new GattCharacteristic[1]; + p.discoverServices(); + p.completeNext(discoveredDb(chr)); + assertEquals(1, p.getServices().size()); + + p.invalidateServices(); + assertTrue(p.getServices().isEmpty()); + assertNull(p.getService(SVC)); + assertNull(p.getCharacteristic(SVC, CHR)); + } + + @Test + void appRequestedDisconnectFailsInFlightAndQueuedOpsNotConnected() { + p.connectNow(); + GattCharacteristic[] chr = new GattCharacteristic[1]; + p.discoverServices(); + p.completeNext(discoveredDb(chr)); + + AsyncResource inFlight = p.readCharacteristic(chr[0]); + AsyncResource queued = p.readRssi(); + assertEquals(1, p.pendingCount(), + "the queue serializes: only the first op reaches the SPI"); + + p.disconnect(); + // drain the abandoned in-flight read, then complete the disconnect + assertEquals(FakeBlePeripheral.OpKind.READ_CHARACTERISTIC, + p.takeNext().kind); + p.completeNext(null); + + assertFailedWith(inFlight, BluetoothError.NOT_CONNECTED); + assertFailedWith(queued, BluetoothError.NOT_CONNECTED); + } + + @Test + void linkLossFailsInFlightAndQueuedOpsWithConnectionLost() { + p.connectNow(); + GattCharacteristic[] chr = new GattCharacteristic[1]; + p.discoverServices(); + p.completeNext(discoveredDb(chr)); + + AsyncResource inFlight = p.readCharacteristic(chr[0]); + AsyncResource queued = + p.writeCharacteristic(chr[0], bytes(7), true); + + p.fireState(ConnectionState.DISCONNECTED, new BluetoothException( + BluetoothError.CONNECTION_LOST, "link supervision timeout")); + + assertFailedWith(inFlight, BluetoothError.CONNECTION_LOST); + assertFailedWith(queued, BluetoothError.CONNECTION_LOST); + assertEquals(ConnectionState.DISCONNECTED, p.getConnectionState()); + + flushSerialCalls(); + ConnectionEvent last = events.get(events.size() - 1); + assertEquals(ConnectionState.DISCONNECTED, last.getState()); + assertNotNull(last.getReason()); + assertEquals(BluetoothError.CONNECTION_LOST, + last.getReason().getError()); + } + + @Test + void reconnectAfterDisconnectWorks() { + p.connectNow(); + p.disconnect(); + p.completeNext(null); + assertEquals(ConnectionState.DISCONNECTED, p.getConnectionState()); + + AsyncResource r = p.connectNow(); + assertSame(p, r.get()); + assertEquals(ConnectionState.CONNECTED, p.getConnectionState()); + } + + @Test + void mtuIsTrackedFromSuccessfulRequests() { + p.connectNow(); + assertEquals(23, p.getMtu(), "BLE default MTU"); + AsyncResource r = p.requestMtu(247); + FakeBlePeripheral.PendingOp op = p.peekNext(); + assertEquals(FakeBlePeripheral.OpKind.REQUEST_MTU, op.kind); + assertEquals(247, op.intArg); + p.completeNext(Integer.valueOf(185)); + assertEquals(Integer.valueOf(185), r.get()); + assertEquals(185, p.getMtu()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/GattSubscribeTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/GattSubscribeTest.java new file mode 100644 index 00000000000..4dbf777f460 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/GattSubscribeTest.java @@ -0,0 +1,239 @@ +/* + * 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.bluetooth; + +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.codename1.bluetooth.BtTestUtil.bytes; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Notification subscription management: the CCCD is armed exactly once on + * the zero-to-one listener transition, additional listeners piggyback, + * values fan out to every listener on the EDT, the last unsubscribe disarms, + * indications are chosen for indicate-only characteristics, and disconnect + * clears the armed state so a re-subscribe after reconnect re-arms. + */ +class GattSubscribeTest extends UITestBase { + + private static final BluetoothUuid SVC = BluetoothUuid.fromShort(0x180D); + + private FakeBlePeripheral p; + private GattCharacteristic notifying; + private GattCharacteristic indicateOnly; + private GattCharacteristic both; + + @BeforeEach + void connectPeripheral() { + p = new FakeBlePeripheral("AA:BB:CC:DD:EE:02", "notif"); + GattService s = p.buildService(SVC); + notifying = p.buildCharacteristic(s, BluetoothUuid.fromShort(0x2A37), + GattCharacteristic.PROPERTY_NOTIFY); + indicateOnly = p.buildCharacteristic(s, + BluetoothUuid.fromShort(0x2A38), + GattCharacteristic.PROPERTY_INDICATE); + both = p.buildCharacteristic(s, BluetoothUuid.fromShort(0x2A39), + GattCharacteristic.PROPERTY_NOTIFY + | GattCharacteristic.PROPERTY_INDICATE); + p.connectNow(); + } + + @Test + void firstSubscribeArmsExactlyOnceAndSecondListenerPiggybacks() { + List got1 = new ArrayList(); + List got2 = new ArrayList(); + + AsyncResource s1 = p.subscribe(notifying, + (c, v) -> got1.add(v)); + assertEquals(1, p.pendingCount(), + "first subscribe triggers the CCCD write"); + FakeBlePeripheral.PendingOp arm = p.peekNext(); + assertEquals(FakeBlePeripheral.OpKind.SET_NOTIFICATIONS, arm.kind); + assertSame(notifying, arm.characteristic); + assertTrue(arm.enable); + assertFalse(arm.indication, + "a NOTIFY characteristic arms notifications"); + assertFalse(s1.isDone(), "not armed until the platform confirms"); + assertFalse(p.isSubscribed(notifying)); + + // a second listener while the arm is still in flight shares it + AsyncResource s2 = p.subscribe(notifying, + (c, v) -> got2.add(v)); + assertEquals(1, p.pendingCount(), + "no second CCCD write for the second listener"); + assertFalse(s2.isDone()); + + p.completeNext(Boolean.TRUE); + assertEquals(Boolean.TRUE, s1.get()); + assertEquals(Boolean.TRUE, s2.get()); + assertTrue(p.isSubscribed(notifying)); + assertTrue(notifying.isSubscribed()); + + // a third listener after arming resolves immediately, still no write + AsyncResource s3 = p.subscribe(notifying, (c, v) -> { }); + assertTrue(s3.isDone()); + assertEquals(Boolean.TRUE, s3.get()); + assertEquals(0, p.pendingCount()); + } + + @Test + void notificationsFanOutToEveryListenerOnTheEdt() { + List got1 = new ArrayList(); + List got2 = new ArrayList(); + p.subscribe(notifying, (c, v) -> got1.add(v)); + p.subscribe(notifying, (c, v) -> got2.add(v)); + p.completeNext(Boolean.TRUE); + + p.notifyValue(notifying, bytes(0x42, 0x43)); + flushSerialCalls(); + + assertEquals(1, got1.size()); + assertEquals(1, got2.size()); + assertArrayEquals(bytes(0x42, 0x43), got1.get(0)); + assertArrayEquals(bytes(0x42, 0x43), got2.get(0)); + } + + @Test + void unsubscribeOfOneListenerKeepsNotificationsArmed() { + List got1 = new ArrayList(); + List got2 = new ArrayList(); + com.codename1.bluetooth.gatt.GattNotificationListener l1 = + (c, v) -> got1.add(v); + p.subscribe(notifying, l1); + p.subscribe(notifying, (c, v) -> got2.add(v)); + p.completeNext(Boolean.TRUE); + + AsyncResource u = p.unsubscribe(notifying, l1); + assertTrue(u.isDone()); + assertTrue(p.isSubscribed(notifying), "one listener remains armed"); + assertEquals(0, p.pendingCount(), "no CCCD disarm yet"); + + p.notifyValue(notifying, bytes(0x01)); + flushSerialCalls(); + assertEquals(0, got1.size(), + "removed listener no longer receives values"); + assertEquals(1, got2.size()); + } + + @Test + void lastUnsubscribeDisarmsNotifications() { + com.codename1.bluetooth.gatt.GattNotificationListener l1 = + (c, v) -> { }; + com.codename1.bluetooth.gatt.GattNotificationListener l2 = + (c, v) -> { }; + p.subscribe(notifying, l1); + p.subscribe(notifying, l2); + p.completeNext(Boolean.TRUE); + + p.unsubscribe(notifying, l1); + assertEquals(0, p.pendingCount()); + + AsyncResource u = p.unsubscribe(notifying, l2); + assertEquals(1, p.pendingCount(), + "last unsubscribe writes the CCCD disable"); + FakeBlePeripheral.PendingOp disarm = p.peekNext(); + assertEquals(FakeBlePeripheral.OpKind.SET_NOTIFICATIONS, disarm.kind); + assertFalse(disarm.enable); + assertFalse(p.isSubscribed(notifying)); + p.completeNext(Boolean.TRUE); + assertEquals(Boolean.TRUE, u.get()); + } + + @Test + void indicationIsChosenWhenTheCharacteristicOnlySupportsIndicate() { + p.subscribe(indicateOnly, (c, v) -> { }); + FakeBlePeripheral.PendingOp arm = p.peekNext(); + assertTrue(arm.enable); + assertTrue(arm.indication, + "an INDICATE-only characteristic arms indications"); + p.completeNext(Boolean.TRUE); + } + + @Test + void notificationIsPreferredWhenBothModesAreSupported() { + p.subscribe(both, (c, v) -> { }); + assertFalse(p.peekNext().indication); + p.completeNext(Boolean.TRUE); + } + + @Test + void subscribeWithoutListenerFailsWithoutTouchingTheSpi() { + AsyncResource s = p.subscribe(notifying, null); + assertTrue(s.isDone()); + assertNotNull(BtTestUtil.errorOf(s)); + assertEquals(0, p.pendingCount()); + } + + @Test + void failedArmSurfacesToEveryWaiterAndAllowsRetry() { + AsyncResource s1 = p.subscribe(notifying, (c, v) -> { }); + AsyncResource s2 = p.subscribe(notifying, (c, v) -> { }); + p.failNext(new BluetoothException(BluetoothError.GATT_ERROR, + "CCCD write rejected", 0x03)); + + BtTestUtil.assertFailedWith(s1, BluetoothError.GATT_ERROR); + BtTestUtil.assertFailedWith(s2, BluetoothError.GATT_ERROR); + assertFalse(p.isSubscribed(notifying)); + } + + @Test + void disconnectClearsArmedStateAndResubscribeAfterReconnectRearms() { + List got = new ArrayList(); + com.codename1.bluetooth.gatt.GattNotificationListener l = + (c, v) -> got.add(v); + p.subscribe(notifying, l); + p.completeNext(Boolean.TRUE); + assertTrue(p.isSubscribed(notifying)); + + p.fireState(ConnectionState.DISCONNECTED, new BluetoothException( + BluetoothError.CONNECTION_LOST, "gone")); + assertFalse(p.isSubscribed(notifying), + "disconnect must clear the armed state"); + assertFalse(notifying.isSubscribed()); + + // reconnect and re-subscribe: a fresh CCCD write is required + p.connectNow(); + AsyncResource again = p.subscribe(notifying, l); + assertEquals(1, p.pendingCount(), + "re-subscribe after reconnect must re-arm"); + FakeBlePeripheral.PendingOp arm = p.peekNext(); + assertEquals(FakeBlePeripheral.OpKind.SET_NOTIFICATIONS, arm.kind); + assertTrue(arm.enable); + p.completeNext(Boolean.TRUE); + assertEquals(Boolean.TRUE, again.get()); + assertTrue(p.isSubscribed(notifying)); + + p.notifyValue(notifying, bytes(0x09)); + flushSerialCalls(); + assertEquals(1, got.size()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/FixtureReplayE2ETest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/FixtureReplayE2ETest.java new file mode 100644 index 00000000000..5dac9dc8c53 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/FixtureReplayE2ETest.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.io.JSONParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * End-to-end replay of a real, PII-scrambled BLE capture through the exact + * helper client stack the native Win32/Linux ports run -- + * {@link HelperBleBackend} + {@link HelperBlePeripheral} over a + * {@link MockHelperTransport} (no subprocess, no radio). The mock data is + * {@code bluetooth-fixtures/ambient-scan-2.json}, one of the scrambled + * ambient scans captured on real hardware; each fixture device is turned + * into a {@code scanResult} protocol event, so the assertions run against + * the genuine captured device shapes. A synthetic connectable peripheral is + * then driven through connect -> discover -> read to exercise the GATT path. + */ +public class FixtureReplayE2ETest { + + private static final long TIMEOUT_MS = 5000; + + private HelperBleBackend backend; + private MockHelperTransport mock; + + @AfterEach + void tearDown() { + if (backend != null) { + backend.shutdown(); + backend = null; + } + } + + private interface Cond { + boolean met(); + } + + private static void await(String what, Cond cond) { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + if (cond.met()) { + return; + } + try { + Thread.sleep(5); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + Assertions.fail("interrupted waiting for " + what); + } + } + Assertions.fail("timed out waiting for " + what); + } + + /** Polls the backend's written commands until one matches {@code cmd}. */ + private long awaitCommandId(String cmd) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + String line = mock.takeWritten(200); + if (line == null) { + continue; + } + Map parsed = new JSONParser().parseJSON( + new java.io.StringReader(line)); + if (cmd.equals(String.valueOf(parsed.get("cmd")))) { + return ((Number) parsed.get("id")).longValue(); + } + } + throw new AssertionError("timed out waiting for command " + cmd); + } + + /** Records scan callbacks without failing on the reader thread. */ + private static final class RecordingScanSink + implements BleBackend.ScanSink { + final List results = new CopyOnWriteArrayList(); + volatile com.codename1.bluetooth.BluetoothException failure; + + public void onResult(ScanResult result) { + results.add(result); + } + + public void onFailed(com.codename1.bluetooth.BluetoothException reason) { + failure = reason; + } + } + + private void newBackend() { + mock = new MockHelperTransport(); + backend = new HelperBleBackend(new MockHelperTransportFactory(mock)); + // attaching a state sink starts the reader thread (ensureStarted), + // exactly as HelperBluetooth's constructor does in the ports + backend.setAdapterStateSink(new BleBackend.AdapterStateSink() { + public void adapterStateChanged( + com.codename1.bluetooth.AdapterState newState) { + } + }); + } + + private void handshake() { + mock.feedLine("{\"event\":\"capabilities\",\"version\":1," + + "\"descriptors\":true,\"bonding\":false}"); + mock.feedLine("{\"event\":\"stateChanged\",\"state\":\"poweredOn\"}"); + await("poweredOn", new Cond() { + public boolean met() { + return backend.getAdapterState() + == com.codename1.bluetooth.AdapterState.POWERED_ON; + } + }); + } + + @SuppressWarnings("unchecked") + private static List> loadFixtureDevices() + throws Exception { + InputStream in = FixtureReplayE2ETest.class.getResourceAsStream( + "/bluetooth-fixtures/ambient-scan-2.json"); + Assertions.assertNotNull(in, "fixture resource must be on the classpath"); + try { + Map root = new JSONParser().parseJSON( + new InputStreamReader(in, "UTF-8")); + return (List>) root.get("devices"); + } finally { + in.close(); + } + } + + /** Builds a helper scanResult protocol line from a fixture device. */ + @SuppressWarnings("unchecked") + private static String scanResultLine(Map device) { + String id = String.valueOf(device.get("id")); + Object connectable = device.get("connectable"); + int rssi = -128; + List timeline = (List) device.get("rssiTimeline"); + if (timeline != null && !timeline.isEmpty()) { + Map last = + (Map) timeline.get(timeline.size() - 1); + rssi = ((Number) last.get("rssi")).intValue(); + } + StringBuilder uuids = new StringBuilder("["); + List svc = (List) device.get("serviceUuids"); + if (svc != null) { + for (int i = 0; i < svc.size(); i++) { + if (i > 0) { + uuids.append(','); + } + uuids.append('"').append(svc.get(i)).append('"'); + } + } + uuids.append(']'); + return "{\"event\":\"scanResult\",\"address\":\"" + id + + "\",\"rssi\":" + rssi + ",\"connectable\":" + + ("true".equals(String.valueOf(connectable))) + "," + + "\"serviceUuids\":" + uuids + "}"; + } + + @Test + public void everyScrambledDeviceSurfacesThroughTheHelperStack() + throws Exception { + List> devices = loadFixtureDevices(); + Assertions.assertFalse(devices.isEmpty(), "fixture has devices"); + + newBackend(); + handshake(); + + final RecordingScanSink sink = new RecordingScanSink(); + backend.startScan(sink); + awaitCommandId("scanStart"); + + final List expectedIds = new ArrayList(); + for (int i = 0; i < devices.size(); i++) { + expectedIds.add(String.valueOf(devices.get(i).get("id"))); + mock.feedLine(scanResultLine(devices.get(i))); + } + + final int expected = devices.size(); + await("all scrambled devices sighted", new Cond() { + public boolean met() { + return sink.results.size() >= expected; + } + }); + Assertions.assertNull(sink.failure, "scan must not fail"); + + List seen = new ArrayList(); + for (int i = 0; i < sink.results.size(); i++) { + seen.add(sink.results.get(i).getPeripheral().getAddress()); + } + for (int i = 0; i < expectedIds.size(); i++) { + Assertions.assertTrue(seen.contains(expectedIds.get(i)), + "device " + expectedIds.get(i) + + " must surface through the helper stack; saw " + + seen); + } + // the scrambled addresses are the synthetic SC:RA:MB:* form -- proof + // the mock data (not real MACs) flowed end to end + Assertions.assertTrue(seen.get(0).startsWith("SC:RA:MB:"), + "scrambled address preserved: " + seen.get(0)); + } + + @Test + public void connectDiscoverReadOverTheHelperStack() throws Exception { + newBackend(); + handshake(); + + // sight one connectable device (reuse a scrambled id from the trace) + final String address = "SC:RA:MB:D6:7C:F9"; + final RecordingScanSink sink = new RecordingScanSink(); + backend.startScan(sink); + awaitCommandId("scanStart"); + mock.feedLine("{\"event\":\"scanResult\",\"address\":\"" + address + + "\",\"rssi\":-61,\"connectable\":true," + + "\"serviceUuids\":[\"0000180d-0000-1000-8000-00805f9b34fb\"]}"); + await("device sighted", new Cond() { + public boolean met() { + return !sink.results.isEmpty(); + } + }); + + final BlePeripheral p = backend.getPeripheral(address); + Assertions.assertNotNull(p, "peripheral resolvable by address"); + Assertions.assertEquals(address, p.getAddress()); + + // connect + final com.codename1.util.AsyncResource connect = + p.connect(); + long connectId = awaitCommandId("connect"); + mock.feedLine("{\"event\":\"connected\",\"requestId\":" + connectId + + ",\"address\":\"" + address + "\"}"); + await("connected", new Cond() { + public boolean met() { + return connect.isDone(); + } + }); + + // discover a heart-rate service + measurement characteristic (the + // protocol reports properties as a string array) + final com.codename1.util.AsyncResource> discover = + p.discoverServices(); + long discoverId = awaitCommandId("discover"); + mock.feedLine("{\"event\":\"discovered\",\"requestId\":" + discoverId + + ",\"address\":\"" + address + "\",\"services\":[" + + "{\"uuid\":\"0000180d-0000-1000-8000-00805f9b34fb\"," + + "\"primary\":true,\"characteristics\":[" + + "{\"uuid\":\"00002a37-0000-1000-8000-00805f9b34fb\"," + + "\"properties\":[\"read\",\"notify\"]," + + "\"descriptors\":[]}]}]}"); + await("services discovered", new Cond() { + public boolean met() { + return discover.isDone(); + } + }); + + GattCharacteristic hr = p.getCharacteristic( + BluetoothUuid.fromShort(0x180D), BluetoothUuid.fromShort(0x2A37)); + Assertions.assertNotNull(hr, "heart-rate characteristic discovered"); + Assertions.assertTrue(hr.canRead()); + Assertions.assertTrue(hr.canNotify()); + + // read it -- value base64 "AEg=" for {0x00,0x48} = 72 bpm + final com.codename1.util.AsyncResource read = hr.read(); + long readId = awaitCommandId("read"); + mock.feedLine("{\"event\":\"readResult\",\"requestId\":" + readId + + ",\"value\":\"AEg=\"}"); + await("read completed", new Cond() { + public boolean met() { + return read.isDone(); + } + }); + Assertions.assertArrayEquals(new byte[] {0x00, 0x48}, read.get(null), + "characteristic value decoded through Wire base64"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/HelperBleBackendMockTransportTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/HelperBleBackendMockTransportTest.java new file mode 100644 index 00000000000..5fddc35668f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/HelperBleBackendMockTransportTest.java @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Drives the transport-agnostic {@link HelperBleBackend} end to end through a + * scripted {@link MockHelperTransport} -- exercising the reader thread, the + * request/terminal-event correlation, the capabilities handshake, scanning, + * the GATT client lifecycle, crash-mid-flight handling and shutdown, all + * without launching the real cn1-ble-helper subprocess. + */ +public class HelperBleBackendMockTransportTest { + + private static final long TIMEOUT_MS = 10000; + private static final String HR_SERVICE = + "0000180d-0000-1000-8000-00805f9b34fb"; + private static final String HR_MEASUREMENT = + "00002a37-0000-1000-8000-00805f9b34fb"; + private static final String HR_CONTROL = + "00002a39-0000-1000-8000-00805f9b34fb"; + private static final String CCCD = + "00002902-0000-1000-8000-00805f9b34fb"; + + private HelperBleBackend backend; + private MockHelperTransport mock; + + @AfterEach + void tearDown() { + if (backend != null) { + backend.shutdown(); + backend = null; + } + } + + private interface Cond { + boolean met(); + } + + private static void await(String what, Cond cond) { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + if (cond.met()) { + return; + } + try { + Thread.sleep(5); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + Assertions.fail("interrupted while waiting for " + what); + } + } + Assertions.fail("timed out waiting for " + what); + } + + private HelperBleBackend newBackend() { + mock = new MockHelperTransport(); + backend = new HelperBleBackend(new MockHelperTransportFactory(mock)); + return backend; + } + + private List attachStateSink(final HelperBleBackend b) { + final List states = + new CopyOnWriteArrayList(); + b.setAdapterStateSink(new BleBackend.AdapterStateSink() { + public void adapterStateChanged(AdapterState newState) { + states.add(newState); + } + }); + return states; + } + + /** Feeds the capabilities + poweredOn handshake and waits for it. */ + private void handshake(final HelperBleBackend b) { + mock.feedLine("{\"event\":\"capabilities\",\"version\":1," + + "\"descriptors\":true,\"bonding\":false}"); + mock.feedLine("{\"event\":\"stateChanged\",\"state\":\"poweredOn\"}"); + await("poweredOn handshake", new Cond() { + public boolean met() { + return b.getAdapterState() == AdapterState.POWERED_ON; + } + }); + } + + private static final class RecordingScanSink + implements BleBackend.ScanSink { + final List results = + new CopyOnWriteArrayList(); + volatile BluetoothException failure; + + public void onResult(ScanResult result) { + results.add(result); + } + + public void onFailed(BluetoothException reason) { + failure = reason; + } + } + + /** Boots, handshakes, scans and delivers one aa:01 sighting. */ + private RecordingScanSink bootAndSight(HelperBleBackend b) { + attachStateSink(b); + handshake(b); + RecordingScanSink sink = new RecordingScanSink(); + b.startScan(sink); + mock.feedLine("{\"event\":\"scanResult\",\"address\":\"aa:01\"," + + "\"name\":\"Heart Monitor\",\"rssi\":-42," + + "\"serviceUuids\":[\"" + HR_SERVICE + "\"]," + + "\"manufacturerData\":{\"76\":\"AQI=\"}," + + "\"serviceData\":{},\"txPower\":4}"); + await("scan sighting", () -> sink.results.size() >= 1); + return sink; + } + + /** Polls the backend's written commands until one matches {@code cmd}. */ + private Map awaitCommand(String cmd) { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + String line = mock.takeWritten(200); + if (line == null) { + continue; + } + Map parsed; + try { + parsed = Wire.parse(line); + } catch (Exception ex) { + throw new AssertionError("bad command line: " + line, ex); + } + if (cmd.equals(Wire.str(parsed, "cmd", ""))) { + return parsed; + } + } + throw new AssertionError("timed out waiting for command " + cmd); + } + + private static Throwable errorOf(AsyncResource res) { + final AtomicReference out = new AtomicReference<>(); + res.except(t -> out.set(t)); + return out.get(); + } + + private static BluetoothUuid uuid(String s) { + return BluetoothUuid.fromString(s); + } + + @Test + public void handshakeReportsAdapterStateAndCapabilities() { + HelperBleBackend b = newBackend(); + List states = attachStateSink(b); + handshake(b); + Assertions.assertTrue(states.contains(AdapterState.POWERED_ON)); + Assertions.assertTrue(b.helperSupports("descriptors")); + Assertions.assertFalse(b.helperSupports("bonding")); + Assertions.assertTrue(b.isLeSupported()); + Assertions.assertFalse(b.isPeripheralModeSupported()); + Assertions.assertFalse(b.isClassicSupported()); + Assertions.assertFalse(b.isL2capSupported()); + Assertions.assertTrue(b.getBondedPeripherals().isEmpty()); + Assertions.assertNotNull(errorOf(b.openGattServer(null))); + Assertions.assertNotNull(errorOf(b.openL2capServer(false))); + } + + @Test + public void scanDeliversResultsAndCachesCanonicalPeripherals() { + HelperBleBackend b = newBackend(); + RecordingScanSink sink = bootAndSight(b); + Assertions.assertNull(sink.failure); + ScanResult first = sink.results.get(0); + BlePeripheral p1 = first.getPeripheral(); + Assertions.assertEquals("aa:01", p1.getAddress()); + Assertions.assertEquals("Heart Monitor", p1.getName()); + Assertions.assertEquals(-42, first.getRssi()); + Assertions.assertEquals("Heart Monitor", + first.getAdvertisementData().getLocalName()); + Assertions.assertTrue(first.getAdvertisementData().getServiceUuids() + .contains(uuid(HR_SERVICE))); + Assertions.assertArrayEquals(new byte[] {1, 2}, + first.getAdvertisementData().getManufacturerData(76)); + Assertions.assertEquals(Integer.valueOf(4), + first.getAdvertisementData().getTxPowerLevel()); + Assertions.assertSame(p1, b.getPeripheral("aa:01")); + Assertions.assertNull(b.getPeripheral("zz:99")); + } + + @Test + public void gattLifecycleOverTheMockTransport() { + HelperBleBackend b = newBackend(); + bootAndSight(b); + final BlePeripheral p = b.getPeripheral("aa:01"); + + final AsyncResource connect = p.connect(); + long connectId = Wire.longVal(awaitCommand("connect"), "id", -1); + mock.feedLine("{\"event\":\"connected\",\"requestId\":" + connectId + + ",\"address\":\"aa:01\",\"name\":\"Heart Monitor\"}"); + await("connect completion", connect::isDone); + Assertions.assertNull(errorOf(connect)); + // getConnectionState() flips to CONNECTED on the EDT (via the + // connect future's completion callback), after isDone() is already + // true -- await it rather than reading it synchronously. + await("peripheral reports connected", + () -> p.getConnectionState() == ConnectionState.CONNECTED); + Assertions.assertEquals(1, b.getConnectedPeripherals(null).size()); + + final AsyncResource> discover = + p.discoverServices(); + long discoverId = Wire.longVal(awaitCommand("discover"), "id", -1); + mock.feedLine("{\"event\":\"discovered\",\"requestId\":" + discoverId + + ",\"services\":[{\"uuid\":\"" + HR_SERVICE + + "\",\"primary\":true,\"characteristics\":[" + + "{\"uuid\":\"" + HR_MEASUREMENT + + "\",\"properties\":[\"read\",\"notify\"]," + + "\"descriptors\":[{\"uuid\":\"" + CCCD + "\"}]}," + + "{\"uuid\":\"" + HR_CONTROL + + "\",\"properties\":[\"write\"],\"descriptors\":[]}]}]}"); + await("service discovery", discover::isDone); + Assertions.assertNull(errorOf(discover)); + GattService hr = p.getService(uuid(HR_SERVICE)); + Assertions.assertNotNull(hr); + GattCharacteristic measurement = + hr.getCharacteristic(uuid(HR_MEASUREMENT)); + Assertions.assertNotNull(measurement); + Assertions.assertTrue(measurement.canRead()); + Assertions.assertTrue(measurement.canNotify()); + Assertions.assertFalse(measurement.canWrite()); + Assertions.assertNotNull(measurement.getDescriptor(uuid(CCCD))); + Assertions.assertEquals(1, + b.getConnectedPeripherals(uuid(HR_SERVICE)).size()); + + final AsyncResource read = measurement.read(); + long readId = Wire.longVal(awaitCommand("read"), "id", -1); + mock.feedLine("{\"event\":\"readResult\",\"requestId\":" + readId + + ",\"value\":\"AQI=\"}"); + await("characteristic read", read::isDone); + Assertions.assertArrayEquals(new byte[] {1, 2}, read.get(null)); + + GattCharacteristic control = + hr.getCharacteristic(uuid(HR_CONTROL)); + final AsyncResource write = control.write(new byte[] {9}); + long writeId = Wire.longVal(awaitCommand("write"), "id", -1); + mock.feedLine("{\"event\":\"writeResult\",\"requestId\":" + writeId + + "}"); + await("characteristic write", write::isDone); + Assertions.assertEquals(Boolean.TRUE, write.get(null)); + + p.disconnect(); + long disconnectId = + Wire.longVal(awaitCommand("disconnect"), "id", -1); + mock.feedLine("{\"event\":\"disconnected\",\"requestId\":" + + disconnectId + ",\"address\":\"aa:01\",\"reason\":\"\"}"); + await("disconnect", () -> p.getConnectionState() + == ConnectionState.DISCONNECTED); + await("connected registry empties", + () -> b.getConnectedPeripherals(null).isEmpty()); + } + + @Test + public void helperCrashFailsInFlightOpsTypedAndKillsTheBackend() { + HelperBleBackend b = newBackend(); + List states = attachStateSink(b); + handshake(b); + // cache aa:01 via a sighting so we can connect + b.startScan(new RecordingScanSink()); + mock.feedLine("{\"event\":\"scanResult\",\"address\":\"aa:01\"," + + "\"name\":\"HR\",\"rssi\":-42}"); + await("sighting", () -> b.getPeripheral("aa:01") != null); + final BlePeripheral p = b.getPeripheral("aa:01"); + + final AsyncResource connect = p.connect(); + awaitCommand("connect"); + // the helper dies mid-flight without answering + mock.end(); + await("connect failure after crash", connect::isDone); + Throwable failure = errorOf(connect); + Assertions.assertTrue(failure instanceof BluetoothException, + "expected a typed BluetoothException, got " + failure); + Assertions.assertEquals(BluetoothError.IO_ERROR, + ((BluetoothException) failure).getError()); + // The connect future errors synchronously, but the CONNECTING -> + // DISCONNECTED transition is delivered on the EDT (as it is for a + // real app's own connect callback), so await it rather than reading + // it straight off the reader thread -- matching the adapter/state + // awaits elsewhere in this suite. + await("peripheral disconnects after crash", + () -> p.getConnectionState() == ConnectionState.DISCONNECTED); + await("adapter reports UNSUPPORTED", + () -> b.getAdapterState() == AdapterState.UNSUPPORTED); + Assertions.assertTrue(states.contains(AdapterState.UNSUPPORTED)); + // a crashed helper stays dead: follow-up ops fail typed, no restart + final AsyncResource again = p.connect(); + await("post-crash connect failure", again::isDone); + Assertions.assertTrue(errorOf(again) instanceof BluetoothException); + } + + @Test + public void shutdownFailsInFlightOpsInsteadOfHanging() { + HelperBleBackend b = newBackend(); + attachStateSink(b); + handshake(b); + b.startScan(new RecordingScanSink()); + mock.feedLine("{\"event\":\"scanResult\",\"address\":\"aa:01\"," + + "\"name\":\"HR\",\"rssi\":-42}"); + await("sighting", () -> b.getPeripheral("aa:01") != null); + final BlePeripheral p = b.getPeripheral("aa:01"); + final AsyncResource connect = p.connect(); + awaitCommand("connect"); + Assertions.assertFalse(connect.isDone()); + b.shutdown(); + await("in-flight connect fails on shutdown", connect::isDone); + Throwable failure = errorOf(connect); + Assertions.assertTrue(failure instanceof BluetoothException); + Assertions.assertEquals(BluetoothError.IO_ERROR, + ((BluetoothException) failure).getError()); + } + + @Test + public void rssiUnsupportedFallsBackToLastScanSighting() { + HelperBleBackend b = newBackend(); + bootAndSight(b); + final BlePeripheral p = b.getPeripheral("aa:01"); + final AsyncResource connect = p.connect(); + long connectId = Wire.longVal(awaitCommand("connect"), "id", -1); + mock.feedLine("{\"event\":\"connected\",\"requestId\":" + connectId + + ",\"address\":\"aa:01\"}"); + await("connect", connect::isDone); + + final AsyncResource rssi = p.readRssi(); + long rssiId = Wire.longVal(awaitCommand("readRssi"), "id", -1); + mock.feedLine("{\"event\":\"error\",\"requestId\":" + rssiId + + ",\"code\":\"notSupported\",\"message\":\"no rssi\"}"); + await("rssi fallback", rssi::isDone); + // the helper answered notSupported; the backend falls back to the + // -42 sighting recorded during the scan + Assertions.assertEquals(Integer.valueOf(-42), rssi.get(null)); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/MockHelperTransport.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/MockHelperTransport.java new file mode 100644 index 00000000000..a12e00f8306 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/MockHelperTransport.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * A scripted {@link HelperTransport} for deterministic, subprocess-free + * tests of {@link HelperBleBackend} -- the seam that lets any module drive + * the transport-agnostic backend end to end without launching the real + * {@code cn1-ble-helper}. Incoming helper lines are supplied programmatically + * via {@link #feedLine(String)} (and terminated with {@link #end()}); every + * command the backend writes is recorded and can be observed via + * {@link #writtenCommands()} or awaited with {@link #takeWritten(long)}, so a + * test can respond to exactly what the backend sent. + * + *

This is test scaffolding shipped in {@code src/main/java} so tests in + * other modules (the JavaSE simulator and the native ports) can reuse it; it + * is not part of the translated runtime.

+ */ +public final class MockHelperTransport implements HelperTransport { + + /** Identity sentinel enqueued to make {@link #readLine()} return null. */ + private static final String EOF = new String("<>"); + + private final LinkedBlockingQueue incoming = + new LinkedBlockingQueue(); + private final LinkedBlockingQueue written = + new LinkedBlockingQueue(); + private final CopyOnWriteArrayList writtenLog = + new CopyOnWriteArrayList(); + + private volatile boolean started; + private volatile boolean alive; + + public void start(List command) throws IOException { + started = true; + alive = true; + } + + public String readLine() throws IOException { + try { + String line = incoming.take(); + if (line == EOF) { + alive = false; + return null; + } + return line; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } + } + + public void writeLine(String line) throws IOException { + if (!alive) { + throw new IOException("mock helper transport is closed"); + } + writtenLog.add(line); + written.add(line); + } + + public void close() { + alive = false; + incoming.add(EOF); + } + + public boolean isAlive() { + return alive; + } + + /** True once {@link #start(List)} has been called. */ + public boolean isStarted() { + return started; + } + + // ------------------------------------------------------------------ + // scripting API (test side) + // ------------------------------------------------------------------ + + /** Queues one helper output line for the backend's reader thread. */ + public void feedLine(String line) { + incoming.add(line); + } + + /** Queues several helper output lines in order. */ + public void feedLines(String... lines) { + for (int i = 0; i < lines.length; i++) { + incoming.add(lines[i]); + } + } + + /** Signals end of stream: the helper exited / closed its stdout. */ + public void end() { + incoming.add(EOF); + } + + /** Snapshot of every command the backend has written so far. */ + public List writtenCommands() { + return new CopyOnWriteArrayList(writtenLog); + } + + /** + * Blocks up to {@code timeoutMs} for the next command the backend writes + * and returns it, or {@code null} on timeout. Each written command is + * returned once, in order, so a test can respond to each in turn. + */ + public String takeWritten(long timeoutMs) { + try { + return written.poll(timeoutMs, TimeUnit.MILLISECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/MockHelperTransportFactory.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/MockHelperTransportFactory.java new file mode 100644 index 00000000000..4e1e2088468 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/MockHelperTransportFactory.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +/** + * A {@link HelperTransportFactory} that hands {@link HelperBleBackend} a + * given {@link MockHelperTransport}, so a test can script the transport it + * created and then observe how the backend drives it. + */ +public final class MockHelperTransportFactory implements HelperTransportFactory { + + private final MockHelperTransport transport; + + public MockHelperTransportFactory(MockHelperTransport transport) { + this.transport = transport; + } + + public HelperTransport create() { + return transport; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/WireTest.java b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/WireTest.java new file mode 100644 index 00000000000..9c850f7c4ba --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/bluetooth/helper/WireTest.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2012, 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.bluetooth.helper; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * Deterministic codec tests of the cn1-ble-helper wire protocol: command + * serialization (including escaping), event-line parsing and the typed + * error / adapter-state mappings. No subprocess, no hardware. + */ +public class WireTest { + + @Test + public void commandSerializationIsStable() { + String line = Wire.obj().put("cmd", "connect").put("id", 7L) + .put("address", "aa:bb:cc").line(); + Assertions.assertEquals( + "{\"cmd\":\"connect\",\"id\":7,\"address\":\"aa:bb:cc\"}", + line); + String write = Wire.obj().put("cmd", "write").put("id", 12L) + .put("value", "AQI=").put("noResponse", true).line(); + Assertions.assertEquals("{\"cmd\":\"write\",\"id\":12," + + "\"value\":\"AQI=\",\"noResponse\":true}", write); + } + + @Test + public void escapingCoversQuotesBackslashesAndControlChars() { + Assertions.assertEquals("a\\\"b\\\\c\\nd\\re\\tf", + Wire.escape("a\"b\\c\nd\re\tf")); + Assertions.assertEquals("x\\u0001y", Wire.escape("x" + (char) 1 + "y")); + Assertions.assertEquals("", Wire.escape(null)); + // escaped output must survive a JSON parse round trip + String line = Wire.obj().put("cmd", "write") + .put("value", "we\"ird\\pay\nload").line(); + Map parsed = parse(line); + Assertions.assertEquals("we\"ird\\pay\nload", + Wire.str(parsed, "value", null)); + } + + private static Map parse(String line) { + try { + return Wire.parse(line); + } catch (Exception ex) { + throw new AssertionError("parse failed: " + line, ex); + } + } + + @Test + public void eventParsingExtractsTypedFields() { + Map ev = parse("{\"event\":\"readResult\"," + + "\"requestId\":42,\"address\":\"aa:01\"," + + "\"rssi\":-63,\"value\":\"AQI=\",\"primary\":true}"); + Assertions.assertEquals("readResult", Wire.str(ev, "event", null)); + Assertions.assertEquals(42, Wire.longVal(ev, "requestId", -1)); + Assertions.assertEquals(-63, Wire.intVal(ev, "rssi", 0)); + Assertions.assertTrue(Wire.boolVal(ev, "primary", false)); + Assertions.assertEquals(-1, Wire.longVal(ev, "missing", -1)); + Assertions.assertArrayEquals(new byte[] {1, 2}, + Wire.decodeBase64(Wire.str(ev, "value", ""))); + } + + @Test + public void nestedScanResultStructuresParse() { + Map ev = parse("{\"event\":\"scanResult\"," + + "\"address\":\"aa:01\",\"name\":\"HR\",\"rssi\":-45," + + "\"serviceUuids\":[\"0000180d-0000-1000-8000-00805f9b34fb\"]," + + "\"manufacturerData\":{\"76\":\"AQI=\"}," + + "\"serviceData\":{}}"); + List uuids = Wire.list(ev, "serviceUuids"); + Assertions.assertEquals(1, uuids.size()); + Assertions.assertEquals("0000180d-0000-1000-8000-00805f9b34fb", + String.valueOf(uuids.get(0))); + Map manufacturer = + Wire.map(ev.get("manufacturerData")); + Assertions.assertArrayEquals(new byte[] {1, 2}, + Wire.decodeBase64(String.valueOf(manufacturer.get("76")))); + Assertions.assertTrue(Wire.map(ev.get("serviceData")).isEmpty()); + Assertions.assertTrue(Wire.list(ev, "absent").isEmpty()); + } + + @Test + public void base64RoundTrips() { + byte[] data = new byte[] {0, 1, 2, (byte) 0xFF, 42}; + Assertions.assertArrayEquals(data, + Wire.decodeBase64(Wire.encodeBase64(data))); + Assertions.assertArrayEquals(new byte[0], Wire.decodeBase64("")); + Assertions.assertArrayEquals(new byte[0], Wire.decodeBase64(null)); + Assertions.assertEquals("", Wire.encodeBase64(null)); + } + + @Test + public void errorCodesMapToTypedBluetoothErrors() { + Assertions.assertEquals(BluetoothError.NOT_SUPPORTED, + HelperBleBackend.mapErrorCode("notSupported")); + Assertions.assertEquals(BluetoothError.UNAUTHORIZED, + HelperBleBackend.mapErrorCode("unauthorized")); + Assertions.assertEquals(BluetoothError.POWERED_OFF, + HelperBleBackend.mapErrorCode("poweredOff")); + Assertions.assertEquals(BluetoothError.SCAN_FAILED, + HelperBleBackend.mapErrorCode("scanFailed")); + Assertions.assertEquals(BluetoothError.CONNECTION_FAILED, + HelperBleBackend.mapErrorCode("connectFailed")); + Assertions.assertEquals(BluetoothError.CONNECTION_FAILED, + HelperBleBackend.mapErrorCode("unknownPeripheral")); + Assertions.assertEquals(BluetoothError.NOT_CONNECTED, + HelperBleBackend.mapErrorCode("notConnected")); + Assertions.assertEquals(BluetoothError.GATT_ERROR, + HelperBleBackend.mapErrorCode("unknownCharacteristic")); + Assertions.assertEquals(BluetoothError.GATT_ERROR, + HelperBleBackend.mapErrorCode("unknownDescriptor")); + Assertions.assertEquals(BluetoothError.TIMEOUT, + HelperBleBackend.mapErrorCode("timeout")); + Assertions.assertEquals(BluetoothError.IO_ERROR, + HelperBleBackend.mapErrorCode("ioError")); + Assertions.assertEquals(BluetoothError.UNKNOWN, + HelperBleBackend.mapErrorCode("badRequest")); + Assertions.assertEquals(BluetoothError.UNKNOWN, + HelperBleBackend.mapErrorCode("somethingNew")); + } + + @Test + public void adapterStatesMap() { + Assertions.assertEquals(AdapterState.POWERED_ON, + HelperBleBackend.mapAdapterState("poweredOn")); + Assertions.assertEquals(AdapterState.POWERED_OFF, + HelperBleBackend.mapAdapterState("poweredOff")); + Assertions.assertEquals(AdapterState.UNSUPPORTED, + HelperBleBackend.mapAdapterState("unsupported")); + Assertions.assertEquals(AdapterState.UNAUTHORIZED, + HelperBleBackend.mapAdapterState("unauthorized")); + Assertions.assertEquals(AdapterState.UNKNOWN, + HelperBleBackend.mapAdapterState("whatever")); + } + + @Test + public void characteristicPropertyNamesMapToBits() { + int mask = HelperBlePeripheral.propertiesMask(Arrays.asList( + (Object) "read", "notify")); + Assertions.assertEquals(GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_NOTIFY, mask); + int all = HelperBlePeripheral.propertiesMask(Arrays.asList( + (Object) "broadcast", "read", "writeWithoutResponse", + "write", "notify", "indicate", "signedWrite", + "extendedProps", "unknownFutureFlag")); + Assertions.assertEquals(GattCharacteristic.PROPERTY_BROADCAST + | GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_WRITE_WITHOUT_RESPONSE + | GattCharacteristic.PROPERTY_WRITE + | GattCharacteristic.PROPERTY_NOTIFY + | GattCharacteristic.PROPERTY_INDICATE + | GattCharacteristic.PROPERTY_SIGNED_WRITE + | GattCharacteristic.PROPERTY_EXTENDED_PROPS, all); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 168308c876c..f635a16b26f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -1,3 +1,25 @@ +/* + * 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.testing; import com.codename1.capture.VideoCaptureConstraints; @@ -123,6 +145,7 @@ public class TestCodenameOneImplementation extends CodenameOneImplementation { private boolean callDetectionSupported; private boolean inCall; private LocationManager locationManager; + private com.codename1.bluetooth.Bluetooth bluetooth; private L10NManager localizationManager; private ImageIO imageIO; private VideoIO videoIO; @@ -1219,6 +1242,7 @@ public void reset() { mediaRecorderHandler = null; mediaRecorderBuilderHandler = null; locationManager = null; + bluetooth = null; localizationManager = null; imageIO = null; inAppPurchase = null; @@ -1365,6 +1389,25 @@ public void setLocationManager(LocationManager locationManager) { this.locationManager = locationManager; } + /** + * Returns the scripted Bluetooth entry point installed via + * {@link #setBluetooth(com.codename1.bluetooth.Bluetooth)}. Defaults to + * {@code null} so {@code Bluetooth.getInstance()} falls back to the + * no-op base instance, mirroring ports without Bluetooth support. + */ + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + return bluetooth; + } + + /** + * Scripts the Bluetooth stack returned by {@link #getBluetooth()}; + * cleared back to {@code null} by {@link #reset()}. + */ + public void setBluetooth(com.codename1.bluetooth.Bluetooth bluetooth) { + this.bluetooth = bluetooth; + } + public void setLocalizationManager(L10NManager localizationManager) { this.localizationManager = localizationManager; } diff --git a/maven/core-unittests/src/test/resources/bluetooth-fixtures/ambient-scan-2.json b/maven/core-unittests/src/test/resources/bluetooth-fixtures/ambient-scan-2.json new file mode 100644 index 00000000000..ebe1c24745c --- /dev/null +++ b/maven/core-unittests/src/test/resources/bluetooth-fixtures/ambient-scan-2.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "platform": "Mac OS X 26.3.1 (cn1-ble-helper)", + "devices": [ + {"id": "SC:RA:MB:D6:7C:F9", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 103, "rssi": -127}], + "serviceUuids": [], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:D5:45:FC", + "name": "Device-A739", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 357, "rssi": -80}, {"relTimeMs": 649, "rssi": -80}, {"relTimeMs": 1890, "rssi": -79}, {"relTimeMs": 9346, "rssi": -86}, {"relTimeMs": 9956, "rssi": -80}, {"relTimeMs": 10560, "rssi": -83}, {"relTimeMs": 11489, "rssi": -79}], + "serviceUuids": [], + "manufacturerData": {"117": "ZyqWc+hzQFFVqiqd4RLJHZse7dSGx2r/"}, + "serviceData": {}}, + {"id": "SC:RA:MB:31:7D:FE", + "name": "Device-9F4E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1239, "rssi": -94}, {"relTimeMs": 4262, "rssi": -93}], + "serviceUuids": ["00001812-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb"], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:8E:72:27", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1538, "rssi": -88}], + "serviceUuids": [], + "manufacturerData": {"117": "mhQ4LGiZ/GJMmn5l0V9k0rqXgANoEwl/"}, + "serviceData": {}}, + {"id": "SC:RA:MB:7F:B8:BD", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 2496, "rssi": -76}], + "serviceUuids": [], + "manufacturerData": {"76": "4KE39IvA1g=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:E4:FC:29", + "name": "Device-C3F2", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 3338, "rssi": -90}, {"relTimeMs": 3643, "rssi": -90}, {"relTimeMs": 3948, "rssi": -95}, {"relTimeMs": 4259, "rssi": -90}], + "serviceUuids": ["00001812-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb"], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:FD:99:DC", + "name": "Dev-10B9", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 6055, "rssi": -92}, {"relTimeMs": 6055, "rssi": -92}, {"relTimeMs": 9647, "rssi": -93}], + "serviceUuids": [], + "manufacturerData": {"76": "0dBKwsJ0/g=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:6F:45:0E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 6062, "rssi": -91}], + "serviceUuids": [], + "manufacturerData": {"76": "LBDQbQ=="}, + "serviceData": {}} + ] +} diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index 04c4e92bede..8130ff4fe27 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -78,6 +78,45 @@ + + build-ble-helper + + + + maven-antrun-plugin + + + build-ble-helper + generate-resources + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -155,6 +194,15 @@ **/*.java + + ${cn1.binaries}/ble + com/codename1/impl/javase/bluetooth/native + + + **/*.md + + diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/BluetoothSimulationWindowTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/BluetoothSimulationWindowTest.java new file mode 100644 index 00000000000..3975a9daa71 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/BluetoothSimulationWindowTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2012, 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.impl.javase; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.impl.javase.bluetooth.ManualScheduler; +import com.codename1.impl.javase.bluetooth.SimulatedBluetoothStack; +import com.codename1.impl.javase.bluetooth.VirtualCharacteristic; +import com.codename1.impl.javase.bluetooth.VirtualPeripheral; +import com.codename1.impl.javase.bluetooth.VirtualService; +import com.codename1.impl.javase.simulator.SimulatorHook; +import com.codename1.impl.javase.simulator.SimulatorHookLoader; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +/** + * Headless-safe checks of the Simulate → Bluetooth + * Simulation window and its scripting hooks (style of + * {@link LocationSimulationTest}): source-scan guards instead of + * instantiating any Swing window, reflection over the hook contract, the + * shipped {@code simulator-hooks.properties} parsed through the real + * {@link SimulatorHookLoader}, and the canonical demo peripheral verified + * against a fresh deterministic stack. + */ +public class BluetoothSimulationWindowTest { + + private static final String[] HOOK_METHODS = { + "toggleAdapter", "addDemoPeripheral", "pushDemoNotification", + "disconnectAll", "clearPeripherals", "switchToSimulatorBackend", + "switchToNativeBackend", "primeReadFailure" + }; + + private static String read(File f) throws IOException { + StringBuilder sb = new StringBuilder(); + BufferedReader br = new BufferedReader(new FileReader(f)); + try { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + } + } finally { + br.close(); + } + return sb.toString(); + } + + @Test + public void windowHasNoJavaFxImports() throws Exception { + File src = new File("../../Ports/JavaSE/src/com/codename1/impl/" + + "javase/BluetoothSimulation.java"); + Assertions.assertTrue(src.exists(), + "Could not find BluetoothSimulation source file"); + Assertions.assertFalse(read(src).contains("javafx."), + "BluetoothSimulation must not use JavaFX"); + } + + @Test + public void hooksClassStaysHeadless() throws Exception { + File src = new File("../../Ports/JavaSE/src/com/codename1/impl/" + + "javase/BluetoothSimulatorHooks.java"); + Assertions.assertTrue(src.exists(), + "Could not find BluetoothSimulatorHooks source file"); + String content = read(src); + Assertions.assertFalse(content.contains("javax.swing"), + "hooks must stay Swing-free -- they run on the CN1 EDT"); + Assertions.assertFalse(content.contains("javafx."), + "BluetoothSimulatorHooks must not use JavaFX"); + } + + @Test + public void hookMethodsArePublicStaticNoArgVoid() throws Exception { + Class hooks = BluetoothSimulatorHooks.class; + for (String name : HOOK_METHODS) { + Method m = hooks.getDeclaredMethod(name, new Class[0]); + Assertions.assertTrue(Modifier.isPublic(m.getModifiers()), + name + " must be public"); + Assertions.assertTrue(Modifier.isStatic(m.getModifiers()), + name + " must be static"); + Assertions.assertEquals(void.class, m.getReturnType(), + name + " must return void"); + } + } + + @Test + public void shippedHooksFileParsesWithBluetoothNamespace() { + List all = SimulatorHookLoader.load( + BluetoothSimulationWindowTest.class.getClassLoader()); + List bluetooth = new ArrayList(); + for (SimulatorHook h : all) { + if ("bluetooth".equals(h.getNamespace())) { + bluetooth.add(h); + } + } + Assertions.assertEquals(HOOK_METHODS.length, bluetooth.size(), + "expected every declared bluetooth hook to resolve; got " + + bluetooth); + int labeled = 0; + for (SimulatorHook h : bluetooth) { + Assertions.assertEquals("Bluetooth", h.getMenuName()); + Assertions.assertEquals("bluetooth:item" + h.getIndex(), + h.getExecutorKey()); + if (h.hasMenuLabel()) { + labeled++; + } + } + Assertions.assertEquals(HOOK_METHODS.length - 1, labeled, + "exactly one hook (primeReadFailure) is API-only"); + // primeReadFailure is the last, label-less item + SimulatorHook last = bluetooth.get(bluetooth.size() - 1); + Assertions.assertFalse(last.hasMenuLabel(), + "the last bluetooth item must be API-only"); + } + + @Test + public void demoPeripheralHelperBuildsTheCanonicalDevice() { + VirtualPeripheral p = BluetoothSimulatorHooks.createDemoPeripheral(); + Assertions.assertEquals("AA:BB:CC:DD:EE:01", p.getAddress()); + Assertions.assertEquals("SimulatedSensor", p.getName()); + Assertions.assertTrue(p.getAdvertisedServiceUuids().contains( + BluetoothUuid.fromShort(0x180D))); + + VirtualService service = + p.getService(BluetoothUuid.fromShort(0x180D)); + Assertions.assertNotNull(service, "demo service 0x180D missing"); + + VirtualCharacteristic notify = service.getCharacteristic( + BluetoothUuid.fromShort(0x2A37)); + Assertions.assertNotNull(notify, "0x2A37 characteristic missing"); + Assertions.assertTrue(notify.canRead()); + Assertions.assertTrue(notify.canWrite()); + Assertions.assertTrue(notify.canNotifyOrIndicate()); + Assertions.assertNotNull(notify.getDescriptor(BluetoothUuid.CCCD), + "0x2A37 must carry a CCCD"); + + VirtualCharacteristic control = service.getCharacteristic( + BluetoothUuid.fromShort(0x2A39)); + Assertions.assertNotNull(control, "0x2A39 characteristic missing"); + Assertions.assertTrue(control.canWrite()); + Assertions.assertFalse(control.canNotifyOrIndicate()); + } + + @Test + public void demoPeripheralRegistersInAFreshStack() { + ManualScheduler scheduler = new ManualScheduler(); + SimulatedBluetoothStack stack = + new SimulatedBluetoothStack(scheduler); + stack.addPeripheral(BluetoothSimulatorHooks.createDemoPeripheral()); + scheduler.advance(10000); + Assertions.assertTrue( + stack.isPeripheralRegistered("AA:BB:CC:DD:EE:01"), + "the canonical demo device must register"); + Assertions.assertNotNull( + stack.getPeripheral("AA:BB:CC:DD:EE:01").getService( + BluetoothUuid.fromShort(0x180D))); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/AbstractVirtualStackTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/AbstractVirtualStackTest.java new file mode 100644 index 00000000000..48872231264 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/AbstractVirtualStackTest.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Shared fixture of the virtual-stack unit tests: one deterministic stack + * on a {@link ManualScheduler} (no Codename One Display, no wall-clock + * time) plus recording callbacks and the heart-rate demo peripheral. + */ +abstract class AbstractVirtualStackTest { + + static final BluetoothUuid HR_SERVICE = BluetoothUuid.fromShort(0x180D); + static final BluetoothUuid HR_MEASUREMENT = + BluetoothUuid.fromShort(0x2A37); + static final BluetoothUuid HR_CONTROL = BluetoothUuid.fromShort(0x2A39); + static final BluetoothUuid USER_DESCRIPTION = + BluetoothUuid.fromShort(0x2901); + + protected ManualScheduler scheduler; + protected SimulatedBluetoothStack stack; + + @BeforeEach + void setUpStack() { + scheduler = new ManualScheduler(); + stack = new SimulatedBluetoothStack(scheduler); + } + + /** Advances the virtual clock far enough for everything to settle. */ + protected void settle() { + scheduler.advance(10000); + } + + /** Callback recorder; assertions read it after {@link #settle()}. */ + static final class Result implements SimulatedBluetoothStack.Callback { + T value; + BluetoothError error; + String message; + int successCount; + int errorCount; + + public void onSuccess(T value) { + this.value = value; + successCount++; + } + + public void onError(BluetoothError error, String message) { + this.error = error; + this.message = message; + errorCount++; + } + + boolean succeeded() { + return successCount == 1 && errorCount == 0; + } + + boolean failed() { + return errorCount == 1 && successCount == 0; + } + + void assertSuccess() { + Assertions.assertTrue(succeeded(), "expected success but was " + + describe()); + } + + void assertFailure(BluetoothError expected) { + Assertions.assertTrue(failed(), "expected failure but was " + + describe()); + Assertions.assertEquals(expected, error); + } + + private String describe() { + return "successCount=" + successCount + " errorCount=" + + errorCount + " error=" + error + " message=" + message; + } + } + + /** A heart-rate style peripheral with one service, 2 chars, 1 desc. */ + protected VirtualPeripheral heartRatePeripheral(String address) { + return new VirtualPeripheral(address) + .setName("HR-" + address) + .setRssi(-42) + .addAdvertisedServiceUuid(HR_SERVICE) + .addManufacturerData(0x004C, new byte[] {1, 2, 3}) + .withService(new VirtualService(HR_SERVICE) + .withCharacteristic(new VirtualCharacteristic( + HR_MEASUREMENT, + GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_NOTIFY, + new byte[] {0, 72}) + .withDescriptor(new VirtualDescriptor( + USER_DESCRIPTION, + new byte[] {'h', 'r'}))) + .withCharacteristic(new VirtualCharacteristic( + HR_CONTROL, + GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_WRITE, + new byte[] {0}))); + } + + /** Registers, connects and discovers the given peripheral. */ + protected void connectAndDiscover(String address) { + Result connect = new Result(); + stack.connect(address, connect); + Result> discover = + new Result>(); + stack.discoverServices(address, discover); + settle(); + connect.assertSuccess(); + discover.assertSuccess(); + } + + /** + * An echo endpoint: pumps every received byte straight back on a + * dedicated daemon thread until EOF. + */ + static SimStreamHandler echoHandler() { + return new SimStreamHandler() { + public void onConnection(final SimStreamChannel channel) { + Thread t = new Thread(new Runnable() { + public void run() { + try { + InputStream in = channel.getInputStream(); + OutputStream out = channel.getOutputStream(); + byte[] buf = new byte[256]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + out.flush(); + } + } catch (IOException ignored) { + // closed under us -- fine + } finally { + channel.close(); + } + } + }, "sim-echo"); + t.setDaemon(true); + t.start(); + } + }; + } + + /** Reads exactly {@code len} bytes from the stream. */ + static byte[] readFully(InputStream in, int len) throws IOException { + byte[] out = new byte[len]; + new DataInputStream(in).readFully(out); + return out; + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/BluetoothSimulationHeadlessTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/BluetoothSimulationHeadlessTest.java new file mode 100644 index 00000000000..c4ce7b981e1 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/BluetoothSimulationHeadlessTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; + +/** + * Source-scan guards (in the style of + * {@code LocationSimulationTest.testLocationSimulationHasNoJavaFxImports}): + * the simulated Bluetooth stack must stay headless. No Swing/JavaFX + * anywhere in the package, and the deterministic core + * ({@code SimulatedBluetoothStack}, {@code ManualScheduler}) must never + * touch wall-clock time or sleep -- time flows only through the scheduler. + */ +public class BluetoothSimulationHeadlessTest { + + private static final String PACKAGE_DIR = + "../../Ports/JavaSE/src/com/codename1/impl/javase/bluetooth"; + + /** The pure-Java stack core that must not import Codename One UI. */ + private static final String[] LAYER_A_SOURCES = { + "SimScheduler.java", + "AutoScheduler.java", + "ManualScheduler.java", + "SimulatedBluetoothStack.java", + "VirtualPeripheral.java", + "VirtualService.java", + "VirtualCharacteristic.java", + "VirtualDescriptor.java", + "VirtualCentral.java", + "SimStreamChannel.java", + "SimStreamHandler.java", + "StackEventListener.java", + "ByteArrays.java" + }; + + private static File packageDir() { + File dir = new File(PACKAGE_DIR); + Assertions.assertTrue(dir.isDirectory(), + "Could not find the bluetooth package sources at " + + dir.getAbsolutePath()); + return dir; + } + + private static String read(File f) throws IOException { + StringBuilder sb = new StringBuilder(); + BufferedReader br = new BufferedReader(new FileReader(f)); + try { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + } + } finally { + br.close(); + } + return sb.toString(); + } + + @Test + public void packageHasNoSwingOrJavaFxImports() throws Exception { + File[] sources = packageDir().listFiles( + (dir, name) -> name.endsWith(".java")); + Assertions.assertNotNull(sources); + Assertions.assertTrue(sources.length >= 20, + "expected the full bluetooth package, found " + + sources.length + " sources"); + for (File src : sources) { + String content = read(src); + Assertions.assertFalse(content.contains("javax.swing"), + src.getName() + " must not use Swing"); + Assertions.assertFalse(content.contains("javafx."), + src.getName() + " must not use JavaFX"); + } + } + + @Test + public void deterministicCoreNeverTouchesWallClockTime() + throws Exception { + for (String name : new String[] {"SimulatedBluetoothStack.java", + "ManualScheduler.java"}) { + File src = new File(packageDir(), name); + Assertions.assertTrue(src.exists(), "missing " + name); + String content = read(src); + Assertions.assertFalse(content.contains("Thread.sleep"), + name + " must not sleep -- time flows only through the" + + " scheduler"); + Assertions.assertFalse( + content.contains("System.currentTimeMillis"), + name + " must not read wall-clock time -- time flows" + + " only through the scheduler"); + Assertions.assertFalse(content.contains("System.nanoTime"), + name + " must not read wall-clock time -- time flows" + + " only through the scheduler"); + } + } + + @Test + public void stackCoreStaysFreeOfCodenameOneUi() throws Exception { + for (String name : LAYER_A_SOURCES) { + File src = new File(packageDir(), name); + Assertions.assertTrue(src.exists(), "missing " + name); + String content = read(src); + Assertions.assertFalse( + content.contains("import com.codename1.ui"), + name + " is stack core and must not depend on" + + " com.codename1.ui"); + } + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/ConcurrentOpsTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/ConcurrentOpsTest.java new file mode 100644 index 00000000000..8079eec67d8 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/ConcurrentOpsTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Interleaved operations through the manual scheduler complete in + * deterministic issue order -- across multiple peripherals and when + * follow-up operations are issued from completion callbacks. + */ +public class ConcurrentOpsTest extends AbstractVirtualStackTest { + + private static final String A = "AA:00:00:00:00:0A"; + private static final String B = "AA:00:00:00:00:0B"; + + private SimulatedBluetoothStack.Callback record( + List order, String tag) { + return new SimulatedBluetoothStack.Callback() { + public void onSuccess(byte[] value) { + order.add(tag); + } + + public void onError(BluetoothError error, String message) { + order.add(tag + "!" + error); + } + }; + } + + @Test + public void interleavedOpsCompleteInIssueOrder() { + stack.addPeripheral(heartRatePeripheral(A)); + stack.addPeripheral(heartRatePeripheral(B)); + connectAndDiscover(A); + connectAndDiscover(B); + + List order = new ArrayList<>(); + stack.readCharacteristic(A, HR_SERVICE, HR_MEASUREMENT, + record(order, "readA1")); + stack.writeCharacteristic(B, HR_SERVICE, HR_CONTROL, new byte[] {1}, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(Boolean value) { + order.add("writeB"); + } + + public void onError(BluetoothError error, + String message) { + order.add("writeB!" + error); + } + }); + stack.readRssi(A, new SimulatedBluetoothStack.Callback() { + public void onSuccess(Integer value) { + order.add("rssiA"); + } + + public void onError(BluetoothError error, String message) { + order.add("rssiA!" + error); + } + }); + stack.readCharacteristic(B, HR_SERVICE, HR_MEASUREMENT, + record(order, "readB")); + stack.readCharacteristic(A, HR_SERVICE, HR_CONTROL, + record(order, "readA2")); + settle(); + + Assertions.assertEquals(Arrays.asList("readA1", "writeB", "rssiA", + "readB", "readA2"), order); + } + + @Test + public void twoRunsProduceIdenticalOrdering() { + List first = runScriptedSequence(); + // fresh stack, same script + scheduler = new ManualScheduler(); + stack = new SimulatedBluetoothStack(scheduler); + List second = runScriptedSequence(); + Assertions.assertEquals(first, second); + Assertions.assertFalse(first.isEmpty()); + } + + private List runScriptedSequence() { + stack.addPeripheral(heartRatePeripheral(A)); + stack.addPeripheral(heartRatePeripheral(B)); + connectAndDiscover(A); + connectAndDiscover(B); + List order = new ArrayList<>(); + stack.readCharacteristic(A, HR_SERVICE, HR_MEASUREMENT, + record(order, "1")); + stack.readCharacteristic(B, HR_SERVICE, HR_MEASUREMENT, + record(order, "2")); + stack.readCharacteristic(A, HR_SERVICE, HR_CONTROL, + record(order, "3")); + stack.readCharacteristic(B, HR_SERVICE, HR_CONTROL, + record(order, "4")); + settle(); + return order; + } + + @Test + public void chainedOpsIssuedFromCallbacksStayOrdered() { + stack.addPeripheral(heartRatePeripheral(A)); + connectAndDiscover(A); + + List order = new ArrayList<>(); + stack.readCharacteristic(A, HR_SERVICE, HR_MEASUREMENT, + new SimulatedBluetoothStack.Callback() { + public void onSuccess(byte[] value) { + order.add("first"); + stack.readCharacteristic(A, HR_SERVICE, HR_CONTROL, + record(order, "chained")); + } + + public void onError(BluetoothError error, + String message) { + order.add("first!" + error); + } + }); + stack.readRssi(A, new SimulatedBluetoothStack.Callback() { + public void onSuccess(Integer value) { + order.add("second"); + } + + public void onError(BluetoothError error, String message) { + order.add("second!" + error); + } + }); + settle(); + + Assertions.assertEquals(Arrays.asList("first", "second", "chained"), + order); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FailureScriptTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FailureScriptTest.java new file mode 100644 index 00000000000..ae1a84d063b --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FailureScriptTest.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@code failNext} scripting: the scripted error surfaces on exactly the + * next occurrence of the operation -- with the exact error code and + * message -- and the following call succeeds again. + */ +public class FailureScriptTest extends AbstractVirtualStackTest { + + private static final String ADDR = "AA:BB:CC:DD:EE:33"; + + @Test + public void connectFailsExactlyOnce() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + stack.failNext("connect", BluetoothError.CONNECTION_FAILED, "boom"); + + Result first = new Result<>(); + stack.connect(ADDR, first); + settle(); + first.assertFailure(BluetoothError.CONNECTION_FAILED); + Assertions.assertEquals("boom", first.message); + Assertions.assertFalse(stack.isConnected(ADDR)); + + Result second = new Result<>(); + stack.connect(ADDR, second); + settle(); + second.assertSuccess(); + } + + @Test + public void discoverReadWriteAndSubscribeAreScriptable() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + Result connect = new Result<>(); + stack.connect(ADDR, connect); + settle(); + connect.assertSuccess(); + + stack.failNext("discover", BluetoothError.GATT_ERROR, "no db"); + Result> discoverFail = new Result<>(); + stack.discoverServices(ADDR, discoverFail); + settle(); + discoverFail.assertFailure(BluetoothError.GATT_ERROR); + Assertions.assertEquals("no db", discoverFail.message); + + Result> discover = new Result<>(); + stack.discoverServices(ADDR, discover); + settle(); + discover.assertSuccess(); + + stack.failNext("read", BluetoothError.IO_ERROR, "read glitch"); + Result readFail = new Result<>(); + stack.readCharacteristic(ADDR, HR_SERVICE, HR_MEASUREMENT, readFail); + Result readOk = new Result<>(); + stack.readCharacteristic(ADDR, HR_SERVICE, HR_MEASUREMENT, readOk); + settle(); + readFail.assertFailure(BluetoothError.IO_ERROR); + Assertions.assertEquals("read glitch", readFail.message); + readOk.assertSuccess(); + + stack.failNext("write", BluetoothError.GATT_ERROR, "write glitch"); + Result writeFail = new Result<>(); + stack.writeCharacteristic(ADDR, HR_SERVICE, HR_CONTROL, + new byte[] {1}, writeFail); + Result writeOk = new Result<>(); + stack.writeCharacteristic(ADDR, HR_SERVICE, HR_CONTROL, + new byte[] {2}, writeOk); + settle(); + writeFail.assertFailure(BluetoothError.GATT_ERROR); + writeOk.assertSuccess(); + + stack.failNext("subscribe", BluetoothError.GATT_ERROR, "cccd"); + Result subFail = new Result<>(); + stack.setNotifications(ADDR, HR_SERVICE, HR_MEASUREMENT, true, + subFail); + Result subOk = new Result<>(); + stack.setNotifications(ADDR, HR_SERVICE, HR_MEASUREMENT, true, + subOk); + settle(); + subFail.assertFailure(BluetoothError.GATT_ERROR); + subOk.assertSuccess(); + } + + @Test + public void scanFailureIsScriptable() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + stack.failNext("scan", BluetoothError.SCAN_FAILED, "throttled"); + + List sightings = new ArrayList<>(); + BluetoothError[] failed = new BluetoothError[1]; + String[] failedMessage = new String[1]; + stack.startScanFeed(new SimulatedBluetoothStack.ScanFeedSink() { + public void onSighting(VirtualPeripheral p, long timestamp) { + sightings.add(p.getAddress()); + } + + public void onScanFailed(BluetoothError error, String message) { + failed[0] = error; + failedMessage[0] = message; + } + }); + settle(); + Assertions.assertEquals(BluetoothError.SCAN_FAILED, failed[0]); + Assertions.assertEquals("throttled", failedMessage[0]); + Assertions.assertTrue(sightings.isEmpty()); + + // the next scan runs clean + stack.startScanFeed(new SimulatedBluetoothStack.ScanFeedSink() { + public void onSighting(VirtualPeripheral p, long timestamp) { + sightings.add(p.getAddress()); + } + + public void onScanFailed(BluetoothError error, String message) { + Assertions.fail("second scan must not fail: " + error); + } + }); + settle(); + Assertions.assertEquals(1, sightings.size()); + } + + @Test + public void rssiMtuBondAndRfcommConnectAreScriptable() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + Result connect = new Result<>(); + stack.connect(ADDR, connect); + settle(); + + stack.failNext("rssi", BluetoothError.TIMEOUT, "rssi lost"); + Result rssiFail = new Result<>(); + stack.readRssi(ADDR, rssiFail); + Result rssiOk = new Result<>(); + stack.readRssi(ADDR, rssiOk); + settle(); + rssiFail.assertFailure(BluetoothError.TIMEOUT); + rssiOk.assertSuccess(); + + stack.failNext("mtu", BluetoothError.GATT_ERROR, "mtu refused"); + Result mtuFail = new Result<>(); + stack.requestMtu(ADDR, 100, mtuFail); + Result mtuOk = new Result<>(); + stack.requestMtu(ADDR, 100, mtuOk); + settle(); + mtuFail.assertFailure(BluetoothError.GATT_ERROR); + mtuOk.assertSuccess(); + + stack.failNext("bond", BluetoothError.BOND_FAILED, "pin mismatch"); + Result bondFail = new Result<>(); + stack.bond(ADDR, bondFail); + Result bondOk = new Result<>(); + stack.bond(ADDR, bondOk); + settle(); + bondFail.assertFailure(BluetoothError.BOND_FAILED); + bondOk.assertSuccess(); + + stack.addRfcommEndpoint(BluetoothUuid.SPP, echoHandler()); + stack.failNext("rfcommConnect", BluetoothError.BUSY, "channel busy"); + Result rfFail = new Result<>(); + stack.connectRfcomm(BluetoothUuid.SPP, rfFail); + Result rfOk = new Result<>(); + stack.connectRfcomm(BluetoothUuid.SPP, rfOk); + settle(); + rfFail.assertFailure(BluetoothError.BUSY); + Assertions.assertEquals("channel busy", rfFail.message); + rfOk.assertSuccess(); + rfOk.value.close(); + } + + @Test + public void queuedFailuresApplyInFifoOrder() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + stack.failNext("connect", BluetoothError.BUSY, "first"); + stack.failNext("connect", BluetoothError.TIMEOUT, "second"); + + Result first = new Result<>(); + stack.connect(ADDR, first); + Result second = new Result<>(); + stack.connect(ADDR, second); + Result third = new Result<>(); + stack.connect(ADDR, third); + settle(); + + first.assertFailure(BluetoothError.BUSY); + Assertions.assertEquals("first", first.message); + second.assertFailure(BluetoothError.TIMEOUT); + Assertions.assertEquals("second", second.message); + third.assertSuccess(); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FakeBleHelper.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FakeBleHelper.java new file mode 100644 index 00000000000..92f23ba47f9 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FakeBleHelper.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A scripted stand-in for the Rust {@code cn1-ble-helper} binary, launched + * as a real subprocess by {@link NativeBleBackendFakeHelperTest} so the + * {@link NativeBleBackend} reader/writer/crash paths are exercised + * deterministically without Bluetooth hardware. + * + *

Speaks the wire protocol from + * {@code Ports/JavaSE/native/cn1-ble-helper/PROTOCOL.md}. Runs in a bare + * JVM with only the test-classes directory on the classpath, so it must + * not reference JUnit or Codename One classes; commands are picked apart + * with regexes, which is fine because {@code com.codename1.bluetooth.helper.Wire} + * serializes them in a stable single-line form.

+ * + *

Scenario (args[0]):

+ *
    + *
  • {@code happy} - answers every command successfully
  • + *
  • {@code crash-on-connect} - exits with status 1 when a connect + * command arrives (helper-crash-mid-flight simulation)
  • + *
  • {@code hang-on-connect} - never answers connect (shutdown-with- + * in-flight-op simulation)
  • + *
  • {@code rssi-unsupported} - readRssi answers a typed notSupported + * error
  • + *
+ */ +public final class FakeBleHelper { + + static final String HR_SERVICE = "0000180d-0000-1000-8000-00805f9b34fb"; + static final String HR_MEASUREMENT = + "00002a37-0000-1000-8000-00805f9b34fb"; + static final String HR_CONTROL = "00002a39-0000-1000-8000-00805f9b34fb"; + static final String CCCD = "00002902-0000-1000-8000-00805f9b34fb"; + + private static final Pattern CMD = Pattern.compile("\"cmd\":\"(\\w+)\""); + private static final Pattern ID = Pattern.compile("\"id\":(\\d+)"); + private static final Pattern ADDRESS = + Pattern.compile("\"address\":\"([^\"]*)\""); + private static final Pattern CHARACTERISTIC = + Pattern.compile("\"characteristic\":\"([^\"]*)\""); + + private FakeBleHelper() { + } + + private static String group(Pattern p, String line) { + Matcher m = p.matcher(line); + return m.find() ? m.group(1) : ""; + } + + public static void main(String[] args) throws Exception { + String scenario = args.length > 0 ? args[0] : "happy"; + PrintStream out = new PrintStream(System.out, true, "UTF-8"); + out.println("{\"event\":\"capabilities\",\"version\":1," + + "\"helperVersion\":\"fake\",\"platform\":\"fake\"," + + "\"descriptors\":true,\"rssi\":\"lastSeen\"," + + "\"bonding\":false}"); + out.println("{\"event\":\"stateChanged\",\"state\":\"poweredOn\"}"); + BufferedReader in = new BufferedReader( + new InputStreamReader(System.in, "UTF-8")); + String line; + while ((line = in.readLine()) != null) { + String cmd = group(CMD, line); + String id = group(ID, line); + String address = group(ADDRESS, line); + if ("shutdown".equals(cmd)) { + System.exit(0); + } else if ("scanStart".equals(cmd)) { + out.println("{\"event\":\"scanStarted\",\"requestId\":" + + id + "}"); + out.println("{\"event\":\"scanResult\"," + + "\"address\":\"aa:01\"," + + "\"name\":\"Heart Monitor\",\"rssi\":-42," + + "\"txPower\":4," + + "\"serviceUuids\":[\"" + HR_SERVICE + "\"]," + + "\"manufacturerData\":{\"76\":\"AQI=\"}," + + "\"serviceData\":{}}"); + out.println("{\"event\":\"scanResult\"," + + "\"address\":\"aa:02\"," + + "\"name\":\"Thermometer\",\"rssi\":-77," + + "\"serviceUuids\":[]," + + "\"manufacturerData\":{},\"serviceData\":{}}"); + } else if ("scanStop".equals(cmd)) { + out.println("{\"event\":\"scanStopped\",\"requestId\":" + + id + "}"); + } else if ("connect".equals(cmd)) { + if ("crash-on-connect".equals(scenario)) { + System.exit(1); + } + if ("hang-on-connect".equals(scenario)) { + continue; + } + out.println("{\"event\":\"connected\",\"requestId\":" + id + + ",\"address\":\"" + address + + "\",\"name\":\"Heart Monitor\"}"); + } else if ("disconnect".equals(cmd)) { + out.println("{\"event\":\"disconnected\",\"requestId\":" + + id + ",\"address\":\"" + address + "\"}"); + } else if ("discover".equals(cmd)) { + out.println("{\"event\":\"discovered\",\"requestId\":" + id + + ",\"address\":\"" + address + + "\",\"name\":\"Heart Monitor\",\"services\":[" + + "{\"uuid\":\"" + HR_SERVICE + + "\",\"primary\":true,\"characteristics\":[" + + "{\"uuid\":\"" + HR_MEASUREMENT + + "\",\"properties\":[\"read\",\"notify\"]," + + "\"descriptors\":[{\"uuid\":\"" + CCCD + "\"}]}," + + "{\"uuid\":\"" + HR_CONTROL + + "\",\"properties\":[\"write\"]," + + "\"descriptors\":[]}]}]}"); + } else if ("read".equals(cmd)) { + out.println("{\"event\":\"readResult\",\"requestId\":" + id + + ",\"address\":\"" + address + "\",\"service\":\"" + + HR_SERVICE + "\",\"characteristic\":\"" + + group(CHARACTERISTIC, line) + + "\",\"value\":\"AQI=\"}"); + } else if ("write".equals(cmd)) { + out.println("{\"event\":\"writeResult\",\"requestId\":" + id + + ",\"address\":\"" + address + "\",\"service\":\"" + + HR_SERVICE + "\",\"characteristic\":\"" + + group(CHARACTERISTIC, line) + "\"}"); + } else if ("subscribe".equals(cmd)) { + out.println("{\"event\":\"subscribed\",\"requestId\":" + id + + ",\"address\":\"" + address + "\",\"service\":\"" + + HR_SERVICE + "\",\"characteristic\":\"" + + group(CHARACTERISTIC, line) + "\"}"); + // unsolicited notification for a characteristic nobody + // listens to: exercises the routing path headlessly (the + // core early-returns before any EDT dispatch) + out.println("{\"event\":\"notification\",\"address\":\"" + + address + "\",\"service\":\"" + HR_SERVICE + + "\",\"characteristic\":\"" + HR_CONTROL + + "\",\"value\":\"kg==\"}"); + } else if ("unsubscribe".equals(cmd)) { + out.println("{\"event\":\"unsubscribed\",\"requestId\":" + id + + ",\"address\":\"" + address + "\",\"service\":\"" + + HR_SERVICE + "\",\"characteristic\":\"" + + group(CHARACTERISTIC, line) + "\"}"); + } else if ("readDescriptor".equals(cmd)) { + out.println("{\"event\":\"descriptorReadResult\"," + + "\"requestId\":" + id + ",\"address\":\"" + address + + "\",\"service\":\"" + HR_SERVICE + + "\",\"characteristic\":\"" + + group(CHARACTERISTIC, line) + + "\",\"descriptor\":\"" + CCCD + + "\",\"value\":\"kg==\"}"); + } else if ("writeDescriptor".equals(cmd)) { + out.println("{\"event\":\"descriptorWriteResult\"," + + "\"requestId\":" + id + ",\"address\":\"" + address + + "\",\"service\":\"" + HR_SERVICE + + "\",\"characteristic\":\"" + + group(CHARACTERISTIC, line) + + "\",\"descriptor\":\"" + CCCD + "\"}"); + } else if ("readRssi".equals(cmd)) { + if ("rssi-unsupported".equals(scenario)) { + out.println("{\"event\":\"error\",\"requestId\":" + id + + ",\"command\":\"readRssi\",\"address\":\"" + + address + "\",\"code\":\"notSupported\"," + + "\"message\":\"no live RSSI\"}"); + } else { + out.println("{\"event\":\"rssiResult\",\"requestId\":" + + id + ",\"address\":\"" + address + + "\",\"rssi\":-55,\"source\":\"lastSeen\"}"); + } + } else { + out.println("{\"event\":\"error\",\"requestId\":" + id + + ",\"command\":\"" + cmd + + "\",\"code\":\"badRequest\"," + + "\"message\":\"unknown command\"}"); + } + } + System.exit(0); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FixtureRecorderTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FixtureRecorderTest.java new file mode 100644 index 00000000000..68e6957028b --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FixtureRecorderTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.CharacteristicRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.Device; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.ServiceRecord; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Drives {@link FixtureRecorder} against a real subprocess running the + * scripted {@link FakeBleHelper} (the {@link NativeBleBackendFakeHelperTest} + * pattern), asserting that the recorded fixture reflects the scripted + * sightings and GATT database, that scrambling applies, and that the + * recorded trace replays through the full deterministic stack. + */ +public class FixtureRecorderTest { + + private static final BluetoothUuid HR_SERVICE = + BluetoothUuid.fromString(FakeBleHelper.HR_SERVICE); + private static final BluetoothUuid HR_MEASUREMENT = + BluetoothUuid.fromString(FakeBleHelper.HR_MEASUREMENT); + private static final BluetoothUuid HR_CONTROL = + BluetoothUuid.fromString(FakeBleHelper.HR_CONTROL); + private static final BluetoothUuid CCCD = + BluetoothUuid.fromString(FakeBleHelper.CCCD); + + private NativeBleBackend backend; + + @AfterEach + void tearDown() { + if (backend != null) { + backend.shutdown(); + backend = null; + } + } + + private FixtureRecorder recorder(String scenario) { + backend = new NativeBleBackend( + NativeBleBackendFakeHelperTest.fakeHelperCommand(scenario)); + return new FixtureRecorder(backend); + } + + @Test + public void recordsScriptedSightingsAndGattDatabase() + throws BluetoothException { + BluetoothFixture fixture = recorder("happy").record(500, + Arrays.asList("aa:01"), false); + + Assertions.assertEquals(2, fixture.getDevices().size()); + Device monitor = fixture.getDevice("aa:01"); + Assertions.assertNotNull(monitor); + Assertions.assertEquals("Heart Monitor", monitor.name); + Assertions.assertFalse(monitor.rssiTimeline.isEmpty()); + Assertions.assertEquals(-42, monitor.rssiTimeline.get(0).rssi); + Assertions.assertEquals(Integer.valueOf(4), monitor.txPower); + Assertions.assertEquals(Collections.singletonList(HR_SERVICE), + monitor.serviceUuids); + Assertions.assertArrayEquals(new byte[] {1, 2}, + monitor.manufacturerData.get(Integer.valueOf(76))); + Assertions.assertTrue(monitor.serviceData.isEmpty()); + + Device thermometer = fixture.getDevice("aa:02"); + Assertions.assertNotNull(thermometer); + Assertions.assertEquals("Thermometer", thermometer.name); + Assertions.assertEquals(-77, thermometer.rssiTimeline.get(0).rssi); + Assertions.assertNull(thermometer.txPower); + Assertions.assertFalse(thermometer.hasGatt(), + "no GATT capture was requested for aa:02"); + + // the captured GATT database mirrors the scripted discovery + Assertions.assertTrue(monitor.hasGatt()); + Assertions.assertEquals(1, monitor.gatt.size()); + ServiceRecord hr = monitor.gatt.get(0); + Assertions.assertEquals(HR_SERVICE, hr.uuid); + Assertions.assertTrue(hr.primary); + Assertions.assertEquals(2, hr.characteristics.size()); + CharacteristicRecord measurement = hr.characteristics.get(0); + Assertions.assertEquals(HR_MEASUREMENT, measurement.uuid); + Assertions.assertEquals(GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_NOTIFY, + measurement.properties); + // the readable characteristic's value was captured (fake reads + // answer base64 "AQI=" == {1, 2}) + Assertions.assertArrayEquals(new byte[] {1, 2}, measurement.value); + Assertions.assertEquals(1, measurement.descriptors.size()); + Assertions.assertEquals(CCCD, + measurement.descriptors.get(0).uuid); + CharacteristicRecord control = hr.characteristics.get(1); + Assertions.assertEquals(HR_CONTROL, control.uuid); + Assertions.assertEquals(GattCharacteristic.PROPERTY_WRITE, + control.properties); + Assertions.assertNull(control.value, + "write-only characteristics have no captured value"); + + Assertions.assertNotNull(fixture.getPlatform()); + } + + @Test + public void recordScrambledAppliesTheScrambler() + throws BluetoothException { + FixtureRecorder recorder = recorder("happy"); + BluetoothFixture raw = recorder.record(500, + Arrays.asList("aa:01"), false); + BluetoothFixture scrambled = FixtureScrambler.scramble(raw, 42); + + Assertions.assertEquals(raw.getDevices().size(), + scrambled.getDevices().size()); + Device monitor = scrambled.getDevices().get(0); + Assertions.assertTrue(monitor.id.startsWith("SC:RA:MB:"), + monitor.id); + Assertions.assertNotEquals("Heart Monitor", monitor.name); + Assertions.assertTrue(monitor.name.startsWith("Device-")); + // company id kept, payload length preserved + Assertions.assertEquals(2, monitor.manufacturerData + .get(Integer.valueOf(76)).length); + // SIG uuids kept + Assertions.assertEquals(HR_SERVICE, monitor.serviceUuids.get(0)); + Assertions.assertEquals(HR_SERVICE, monitor.gatt.get(0).uuid); + // captured value scrambled length-preserving + Assertions.assertEquals(2, + monitor.gatt.get(0).characteristics.get(0).value.length); + Assertions.assertEquals(Collections.emptyList(), + FixtureScrambler.findLeaks(raw, scrambled)); + // recordScrambled is exactly record + scramble under the seed + Assertions.assertEquals(scrambled.toJson(), FixtureScrambler + .scramble(raw, 42).toJson()); + } + + @Test + public void recordedTraceReplaysThroughTheFullStack() + throws BluetoothException { + // record from the fake radio, scramble, then replay the fixture + // into a deterministic ManualScheduler stack and walk the GATT + // database through the core API -- the full record->replay loop + BluetoothFixture fixture = recorder("happy").recordScrambled(500, + Arrays.asList("aa:01"), false, 42); + RecordedTraceReplayTest.replayFixture(fixture); + } + + @Test + public void gattStrongestPicksTheStrongestConnectableDevice() + throws BluetoothException { + // aa:01 advertises at -42, aa:02 at -77: strongest wins + BluetoothFixture fixture = recorder("happy").record(500, null, + true); + Device monitor = fixture.getDevice("aa:01"); + Assertions.assertNotNull(monitor); + Assertions.assertTrue(monitor.hasGatt()); + Assertions.assertFalse(fixture.getDevice("aa:02").hasGatt()); + } + + @Test + public void gattFailureDegradesToScanOnly() throws BluetoothException { + // the crash-on-connect helper dies when the GATT walk starts: + // the capture must survive with a scan-only record, not hang or + // throw + BluetoothFixture fixture = recorder("crash-on-connect").record(500, + Arrays.asList("aa:01"), false); + Assertions.assertEquals(2, fixture.getDevices().size()); + Assertions.assertFalse(fixture.getDevice("aa:01").hasGatt()); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FixtureScramblerTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FixtureScramblerTest.java new file mode 100644 index 00000000000..16f7070d536 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/FixtureScramblerTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.CharacteristicRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.DescriptorRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.Device; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.RssiSample; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.ServiceRecord; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +/** + * The {@link FixtureScrambler} contract: deterministic under a seed, + * stable per identifier within a trace, structure-preserving (lengths, + * company ids, SIG UUIDs), a pure function of its input, and -- the whole + * point -- no original identity survives into the output. Plus the + * {@link BluetoothFixture} JSON round-trip. + */ +public class FixtureScramblerTest { + + private static final BluetoothUuid HR_SERVICE = + BluetoothUuid.fromShort(0x180D); + private static final BluetoothUuid HR_MEASUREMENT = + BluetoothUuid.fromShort(0x2A37); + private static final BluetoothUuid CCCD = BluetoothUuid.fromShort(0x2902); + private static final BluetoothUuid CUSTOM_SERVICE = + BluetoothUuid.fromString("5f47a3c0-1234-4e6b-9d00-000000000001"); + private static final BluetoothUuid CUSTOM_DATA = + BluetoothUuid.fromString("5f47a3c0-1234-4e6b-9d00-000000000002"); + + /** A two-device trace exercising every scrambled field. */ + private static BluetoothFixture sampleFixture() { + BluetoothFixture fixture = new BluetoothFixture() + .setPlatform("unit test"); + + Device hrm = new Device("AA:BB:CC:DD:EE:01"); + hrm.name = "Zephyr HRM Alpha"; + hrm.connectable = true; + hrm.txPower = Integer.valueOf(4); + hrm.rssiTimeline.add(new RssiSample(0, -40)); + hrm.rssiTimeline.add(new RssiSample(500, -55)); + hrm.serviceUuids.add(HR_SERVICE); + hrm.serviceUuids.add(CUSTOM_SERVICE); + hrm.manufacturerData.put(Integer.valueOf(0x004C), + new byte[] {1, 2, 3}); + hrm.serviceData.put(CUSTOM_DATA, new byte[] {9, 9}); + ServiceRecord hrService = new ServiceRecord(HR_SERVICE); + CharacteristicRecord measurement = new CharacteristicRecord( + HR_MEASUREMENT, GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_NOTIFY); + measurement.value = new byte[] {0, 72}; + measurement.descriptors.add(new DescriptorRecord(CCCD)); + hrService.characteristics.add(measurement); + hrm.gatt.add(hrService); + ServiceRecord customService = new ServiceRecord(CUSTOM_SERVICE); + CharacteristicRecord customChar = new CharacteristicRecord( + CUSTOM_DATA, GattCharacteristic.PROPERTY_WRITE); + customService.characteristics.add(customChar); + hrm.gatt.add(customService); + fixture.addDevice(hrm); + + Device beacon = new Device("AA:BB:CC:DD:EE:02"); + beacon.name = "Tag"; // short-name class + beacon.connectable = false; + beacon.rssiTimeline.add(new RssiSample(120, -88)); + beacon.manufacturerData.put(Integer.valueOf(0x0075), + new byte[] {5, 6, 7, 8}); + fixture.addDevice(beacon); + return fixture; + } + + @Test + public void sameSeedYieldsIdenticalOutput() { + BluetoothFixture fixture = sampleFixture(); + String a = FixtureScrambler.scramble(fixture, 42).toJson(); + String b = FixtureScrambler.scramble(fixture, 42).toJson(); + Assertions.assertEquals(a, b); + } + + @Test + public void differentSeedsYieldDifferentIdentities() { + BluetoothFixture fixture = sampleFixture(); + BluetoothFixture a = FixtureScrambler.scramble(fixture, 42); + BluetoothFixture b = FixtureScrambler.scramble(fixture, 43); + Assertions.assertNotEquals(a.getDevices().get(0).id, + b.getDevices().get(0).id); + Assertions.assertNotEquals(a.getDevices().get(0).name, + b.getDevices().get(0).name); + } + + @Test + public void scramblingIsAPureFunction() { + BluetoothFixture fixture = sampleFixture(); + String before = fixture.toJson(); + FixtureScrambler.scramble(fixture, 42); + Assertions.assertEquals(before, fixture.toJson(), + "scramble must not mutate its input"); + } + + @Test + public void identifiersAreStableWithinTheTrace() { + BluetoothFixture scrambled = + FixtureScrambler.scramble(sampleFixture(), 42); + Device hrm = scrambled.getDevices().get(0); + // the custom UUID occurs as an advertised uuid, a service-data + // key, a GATT service uuid -- all must map identically + BluetoothUuid advertised = hrm.serviceUuids.get(1); + Assertions.assertNotEquals(CUSTOM_SERVICE, advertised); + Assertions.assertEquals(advertised, hrm.gatt.get(1).uuid); + // ... and CUSTOM_DATA maps consistently between service data and + // the characteristic that carries it + BluetoothUuid dataKey = + hrm.serviceData.keySet().iterator().next(); + Assertions.assertNotEquals(CUSTOM_DATA, dataKey); + Assertions.assertEquals(dataKey, + hrm.gatt.get(1).characteristics.get(0).uuid); + } + + @Test + public void structureIsPreserved() { + BluetoothFixture original = sampleFixture(); + BluetoothFixture scrambled = FixtureScrambler.scramble(original, 42); + Assertions.assertEquals(2, scrambled.getDevices().size()); + Device hrm = scrambled.getDevices().get(0); + Device beacon = scrambled.getDevices().get(1); + + // ids become synthetic addresses + Assertions.assertTrue(hrm.id.startsWith("SC:RA:MB:"), hrm.id); + Assertions.assertTrue(beacon.id.startsWith("SC:RA:MB:"), beacon.id); + Assertions.assertNotEquals(hrm.id, beacon.id); + + // name length classes: long stays long-form, short stays short + Assertions.assertTrue(hrm.name.startsWith("Device-"), hrm.name); + Assertions.assertTrue(beacon.name.startsWith("Dev-"), beacon.name); + Assertions.assertFalse(beacon.name.startsWith("Device-")); + + // non-identity fields pass through untouched + Assertions.assertTrue(hrm.connectable); + Assertions.assertFalse(beacon.connectable); + Assertions.assertEquals(Integer.valueOf(4), hrm.txPower); + Assertions.assertEquals(2, hrm.rssiTimeline.size()); + Assertions.assertEquals(500, hrm.rssiTimeline.get(1).relTimeMs); + Assertions.assertEquals(-55, hrm.rssiTimeline.get(1).rssi); + + // SIG uuids kept verbatim, custom uuids remapped off-SIG + Assertions.assertEquals(HR_SERVICE, hrm.serviceUuids.get(0)); + Assertions.assertFalse(hrm.serviceUuids.get(1).isShortUuid()); + Assertions.assertEquals(HR_SERVICE, hrm.gatt.get(0).uuid); + Assertions.assertEquals(HR_MEASUREMENT, + hrm.gatt.get(0).characteristics.get(0).uuid); + Assertions.assertEquals(CCCD, hrm.gatt.get(0).characteristics + .get(0).descriptors.get(0).uuid); + + // company ids kept, payload lengths preserved, bytes replaced + Assertions.assertEquals(3, + hrm.manufacturerData.get(Integer.valueOf(0x004C)).length); + Assertions.assertEquals(4, + beacon.manufacturerData.get(Integer.valueOf(0x0075)).length); + Assertions.assertFalse(java.util.Arrays.equals(new byte[] {1, 2, 3}, + hrm.manufacturerData.get(Integer.valueOf(0x004C)))); + + // service data: payload length kept + Assertions.assertEquals(2, + hrm.serviceData.values().iterator().next().length); + + // characteristic values: same length, properties kept + CharacteristicRecord measurement = + hrm.gatt.get(0).characteristics.get(0); + Assertions.assertEquals(2, measurement.value.length); + Assertions.assertEquals(GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_NOTIFY, + measurement.properties); + Assertions.assertNull( + hrm.gatt.get(1).characteristics.get(0).value); + } + + @Test + public void noOriginalIdentitySurvives() { + BluetoothFixture original = sampleFixture(); + BluetoothFixture scrambled = FixtureScrambler.scramble(original, 42); + Assertions.assertEquals(java.util.Collections.emptyList(), + FixtureScrambler.findLeaks(original, scrambled)); + String json = scrambled.toJson(); + Assertions.assertFalse(json.contains("AA:BB:CC:DD:EE:01")); + Assertions.assertFalse(json.contains("Zephyr")); + Assertions.assertFalse(json.contains("Tag")); + Assertions.assertFalse(json.contains(CUSTOM_SERVICE.toString())); + Assertions.assertFalse(json.contains(CUSTOM_DATA.toString())); + } + + @Test + public void findLeaksCatchesAnUnscrambledFixture() { + BluetoothFixture original = sampleFixture(); + List leaks = FixtureScrambler.findLeaks(original, original); + Assertions.assertTrue(leaks.contains("AA:BB:CC:DD:EE:01")); + Assertions.assertTrue(leaks.contains("Zephyr HRM Alpha")); + Assertions.assertTrue(leaks.contains(CUSTOM_SERVICE.toString())); + } + + @Test + public void jsonRoundTripIsLossless() throws IOException { + BluetoothFixture scrambled = + FixtureScrambler.scramble(sampleFixture(), 42); + String json = scrambled.toJson(); + BluetoothFixture reparsed = BluetoothFixture.fromJson( + new ByteArrayInputStream(json.getBytes("UTF-8"))); + Assertions.assertEquals(json, reparsed.toJson()); + Assertions.assertEquals(BluetoothFixture.FORMAT_VERSION, + reparsed.getVersion()); + Assertions.assertEquals(scrambled.getPlatform(), + reparsed.getPlatform()); + } + + @Test + public void unsupportedVersionIsRejected() { + String json = "{\"version\": 99, \"devices\": []}"; + Assertions.assertThrows(IOException.class, + () -> BluetoothFixture.fromJson(json)); + } + + @Test + public void shippedFixturesParseCleanAndCarryNoRawIdentities() throws + IOException { + for (String name : RecordedTraceReplayTest.SHIPPED_FIXTURES) { + BluetoothFixture fixture = RecordedTraceReplayTest.load(name); + Assertions.assertFalse(fixture.getDevices().isEmpty(), name); + for (Device d : fixture.getDevices()) { + Assertions.assertTrue(d.id.startsWith("SC:RA:MB:"), + name + ": unscrambled device id " + d.id); + if (d.name != null) { + Assertions.assertTrue(d.name.startsWith("Device-") + || d.name.startsWith("Dev-"), + name + ": unscrambled name " + d.name); + } + Assertions.assertFalse(d.rssiTimeline.isEmpty(), + name + ": " + d.id + " has no sightings"); + } + } + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/HelperBinaryResolverTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/HelperBinaryResolverTest.java new file mode 100644 index 00000000000..b2936e047d5 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/HelperBinaryResolverTest.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * The helper-binary resolution order of {@link HelperBinaryResolver}: system + * property, bundled classpath resource, {@code PATH} lookup -- exercised + * with fake files so the tests never depend on this machine's setup. + * {@code "TestOS"} is used as the os.name whenever the bundled-resource + * step must deterministically not resolve (no resource ships for it). + */ +public class HelperBinaryResolverTest { + + @TempDir + File tempDir; + + private File fakeBinary(String name) throws IOException { + File f = new File(tempDir, name); + FileOutputStream out = new FileOutputStream(f); + out.write("#!/bin/sh\n".getBytes("UTF-8")); + out.close(); + f.setExecutable(true, true); + return f; + } + + @Test + public void systemPropertyWinsWhenTheFileExists() throws IOException { + File binary = fakeBinary("my-helper"); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary( + binary.getAbsolutePath(), "TestOS", "x86_64", null, attempted); + Assertions.assertEquals(binary.getAbsolutePath(), + resolved.getAbsolutePath()); + Assertions.assertTrue(attempted.get(0).contains( + HelperBinaryResolver.HELPER_PATH_PROPERTY)); + } + + @Test + public void missingPropertyFileFallsThroughToLaterSteps() + throws IOException { + File onPath = fakeBinary(HelperBinaryResolver.HELPER_BASENAME); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary( + new File(tempDir, "no-such-file").getAbsolutePath(), + "TestOS", "x86_64", tempDir.getAbsolutePath(), attempted); + Assertions.assertEquals(onPath.getAbsolutePath(), + resolved.getAbsolutePath()); + String trace = String.valueOf(attempted); + Assertions.assertTrue(trace.contains("no such file"), trace); + Assertions.assertTrue(trace.contains("PATH lookup"), trace); + } + + @Test + public void pathLookupScansEntriesInOrder() throws IOException { + File emptyDir = new File(tempDir, "empty"); + emptyDir.mkdirs(); + File binDir = new File(tempDir, "bin"); + binDir.mkdirs(); + File binary = new File(binDir, HelperBinaryResolver.HELPER_BASENAME); + new FileOutputStream(binary).close(); + String pathEnv = emptyDir.getAbsolutePath() + File.pathSeparator + + binDir.getAbsolutePath(); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveFromPathEnv(pathEnv, + HelperBinaryResolver.HELPER_BASENAME, attempted); + Assertions.assertEquals(binary.getAbsolutePath(), + resolved.getAbsolutePath()); + } + + @Test + public void nothingFoundReportsEveryAttemptedLocation() { + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary(null, "TestOS", + "x86_64", "", attempted); + Assertions.assertNull(resolved); + String trace = String.valueOf(attempted); + Assertions.assertTrue(trace.contains( + HelperBinaryResolver.HELPER_PATH_PROPERTY), trace); + Assertions.assertTrue(trace.contains("not set"), trace); + Assertions.assertTrue( + trace.contains("no bundled helper for os.name=TestOS"), + trace); + Assertions.assertTrue(trace.contains("os.arch=x86_64"), trace); + Assertions.assertTrue(trace.contains("PATH lookup"), trace); + } + + @Test + public void bundledResourceLocationsAreKeyedByOs() { + // macOS ships one universal Mach-O binary, so the arch is ignored + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "macos/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Mac OS X", "x86_64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "macos/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Mac OS X", "aarch64")); + Assertions.assertNull( + HelperBinaryResolver.helperResourcePath("TestOS", "x86_64")); + Assertions.assertEquals("cn1-ble-helper.exe", + HelperBinaryResolver.helperExecutableName("Windows 11")); + Assertions.assertEquals("cn1-ble-helper", + HelperBinaryResolver.helperExecutableName("Mac OS X")); + } + + @Test + public void linuxAndWindowsResourcesAreKeyedByArchitecture() { + // ELF and PE have no fat-binary format, so these are per-arch + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "linux/x64/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Linux", "amd64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "linux/arm64/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Linux", "aarch64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "windows/x64/cn1-ble-helper.exe", + HelperBinaryResolver.helperResourcePath("Windows 11", "amd64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "windows/arm64/cn1-ble-helper.exe", + HelperBinaryResolver.helperResourcePath("Windows 11", + "aarch64")); + } + + @Test + public void architectureAliasesMapOntoTheBundledDirectories() { + Assertions.assertEquals("x64", + HelperBinaryResolver.normalizeArch("amd64")); + Assertions.assertEquals("x64", + HelperBinaryResolver.normalizeArch("x86_64")); + Assertions.assertEquals("x64", + HelperBinaryResolver.normalizeArch("X64")); + Assertions.assertEquals("arm64", + HelperBinaryResolver.normalizeArch("aarch64")); + Assertions.assertEquals("arm64", + HelperBinaryResolver.normalizeArch("arm64")); + // 32-bit x86/ARM ship no binary: resolution falls through to PATH + Assertions.assertNull(HelperBinaryResolver.normalizeArch("x86")); + Assertions.assertNull(HelperBinaryResolver.normalizeArch("i386")); + Assertions.assertNull(HelperBinaryResolver.normalizeArch("arm")); + Assertions.assertNull(HelperBinaryResolver.normalizeArch(null)); + } + + @Test + public void unknownArchitectureStillFallsBackToPath() throws IOException { + File binary = fakeBinary(HelperBinaryResolver.HELPER_BASENAME); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary(null, "Linux", + "riscv64", tempDir.getAbsolutePath(), attempted); + Assertions.assertEquals(binary.getAbsolutePath(), + resolved.getAbsolutePath()); + Assertions.assertTrue(String.valueOf(attempted).contains( + "os.arch=riscv64"), String.valueOf(attempted)); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleBackendFakeHelperTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleBackendFakeHelperTest.java new file mode 100644 index 00000000000..133f6810c38 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleBackendFakeHelperTest.java @@ -0,0 +1,425 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.helper.BleBackend; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Drives {@link NativeBleBackend} against a real subprocess running the + * scripted {@link FakeBleHelper}, so the reader/writer threads, the + * request/terminal-event correlation, the crash handling and the shutdown + * grace path are all exercised deterministically -- no Bluetooth hardware, + * no OS permissions. + */ +public class NativeBleBackendFakeHelperTest { + + private static final long TIMEOUT_MS = 30000; + + private NativeBleBackend backend; + + @AfterEach + void tearDown() { + if (backend != null) { + backend.shutdown(); + backend = null; + } + } + + private interface Condition { + boolean isMet(); + } + + private static void await(String what, Condition condition) { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + if (condition.isMet()) { + return; + } + try { + Thread.sleep(10); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + Assertions.fail("interrupted while waiting for " + what); + } + } + Assertions.fail("timed out waiting for " + what); + } + + static List fakeHelperCommand(String scenario) { + String exe = File.separatorChar == '\\' ? "java.exe" : "java"; + File javaBin = new File(new File( + System.getProperty("java.home"), "bin"), exe); + String classpath; + try { + classpath = new File(FakeBleHelper.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()) + .getAbsolutePath(); + } catch (Exception ex) { + throw new IllegalStateException( + "cannot locate test-classes for FakeBleHelper", ex); + } + return Arrays.asList(javaBin.getAbsolutePath(), "-cp", classpath, + FakeBleHelper.class.getName(), scenario); + } + + private NativeBleBackend start(String scenario) { + backend = new NativeBleBackend(fakeHelperCommand(scenario)); + Assertions.assertTrue(backend.isAvailable()); + return backend; + } + + /** Records adapter transitions and boots the helper (like installBackend). */ + private List attachStateSink(final NativeBleBackend b) { + final List states = + new CopyOnWriteArrayList(); + b.setAdapterStateSink(new BleBackend.AdapterStateSink() { + public void adapterStateChanged(AdapterState newState) { + states.add(newState); + } + }); + return states; + } + + private static final class RecordingScanSink + implements BleBackend.ScanSink { + final List results = + new CopyOnWriteArrayList(); + volatile BluetoothException failure; + + public void onResult(ScanResult result) { + results.add(result); + } + + public void onFailed(BluetoothException reason) { + failure = reason; + } + } + + /** Scans until the two scripted peripherals were sighted. */ + private RecordingScanSink scanUntilSighted(final NativeBleBackend b) { + final RecordingScanSink sink = new RecordingScanSink(); + b.startScan(sink); + await("two scan sightings", new Condition() { + public boolean isMet() { + return sink.results.size() >= 2; + } + }); + return sink; + } + + private static Throwable errorOf(AsyncResource res) { + final AtomicReference out = + new AtomicReference(); + res.except(t -> out.set(t)); + return out.get(); + } + + @Test + public void handshakeReportsAdapterStateAndCapabilities() { + NativeBleBackend b = start("happy"); + final List states = attachStateSink(b); + await("poweredOn handshake", new Condition() { + public boolean isMet() { + return b.getAdapterState() == AdapterState.POWERED_ON; + } + }); + Assertions.assertTrue(states.contains(AdapterState.POWERED_ON)); + Assertions.assertTrue(b.helperSupports("descriptors")); + Assertions.assertFalse(b.helperSupports("bonding")); + // capability caps of the native backend + Assertions.assertTrue(b.isLeSupported()); + Assertions.assertFalse(b.isPeripheralModeSupported()); + Assertions.assertFalse(b.isClassicSupported()); + Assertions.assertFalse(b.isL2capSupported()); + Assertions.assertTrue(b.getBondedPeripherals().isEmpty()); + Assertions.assertNotNull( + errorOf(b.openGattServer(null))); + Assertions.assertNotNull(errorOf(b.openL2capServer(false))); + } + + @Test + public void scanDeliversResultsAndCachesCanonicalPeripherals() { + NativeBleBackend b = start("happy"); + RecordingScanSink sink = scanUntilSighted(b); + Assertions.assertNull(sink.failure); + ScanResult first = sink.results.get(0); + BlePeripheral p1 = first.getPeripheral(); + Assertions.assertEquals("aa:01", p1.getAddress()); + Assertions.assertEquals("Heart Monitor", p1.getName()); + Assertions.assertEquals(-42, first.getRssi()); + Assertions.assertEquals("Heart Monitor", + first.getAdvertisementData().getLocalName()); + Assertions.assertTrue(first.getAdvertisementData().getServiceUuids() + .contains(BluetoothUuid.fromString( + FakeBleHelper.HR_SERVICE))); + Assertions.assertArrayEquals(new byte[] {1, 2}, + first.getAdvertisementData().getManufacturerData(76)); + Assertions.assertEquals(Integer.valueOf(4), + first.getAdvertisementData().getTxPowerLevel()); + // canonical cache: the scan peripheral IS the lookup peripheral + Assertions.assertSame(p1, b.getPeripheral("aa:01")); + Assertions.assertNotNull(b.getPeripheral("aa:02")); + Assertions.assertNull(b.getPeripheral("zz:99")); + b.stopScan(); + } + + @Test + public void gattLifecycleAgainstScriptedHelper() { + NativeBleBackend b = start("happy"); + scanUntilSighted(b); + final BlePeripheral p = b.getPeripheral("aa:01"); + + final AsyncResource connect = p.connect(); + await("connect completion", new Condition() { + public boolean isMet() { + return connect.isDone(); + } + }); + Assertions.assertNull(errorOf(connect)); + Assertions.assertEquals(ConnectionState.CONNECTED, + p.getConnectionState()); + Assertions.assertEquals(1, + b.getConnectedPeripherals(null).size()); + Assertions.assertTrue(b.getConnectedPeripherals( + BluetoothUuid.fromString(FakeBleHelper.HR_SERVICE)) + .isEmpty()); // services not yet discovered + + final AsyncResource> discover = + p.discoverServices(); + await("service discovery", new Condition() { + public boolean isMet() { + return discover.isDone(); + } + }); + Assertions.assertNull(errorOf(discover)); + GattService hr = p.getService( + BluetoothUuid.fromString(FakeBleHelper.HR_SERVICE)); + Assertions.assertNotNull(hr); + GattCharacteristic measurement = hr.getCharacteristic( + BluetoothUuid.fromString(FakeBleHelper.HR_MEASUREMENT)); + Assertions.assertNotNull(measurement); + Assertions.assertTrue(measurement.canRead()); + Assertions.assertTrue(measurement.canNotify()); + Assertions.assertFalse(measurement.canWrite()); + Assertions.assertNotNull(measurement.getDescriptor( + BluetoothUuid.fromString(FakeBleHelper.CCCD))); + // with the discovered database the service filter now matches + Assertions.assertEquals(1, b.getConnectedPeripherals( + BluetoothUuid.fromString(FakeBleHelper.HR_SERVICE)).size()); + + final AsyncResource read = measurement.read(); + await("characteristic read", new Condition() { + public boolean isMet() { + return read.isDone(); + } + }); + Assertions.assertArrayEquals(new byte[] {1, 2}, read.get(null)); + + GattCharacteristic control = hr.getCharacteristic( + BluetoothUuid.fromString(FakeBleHelper.HR_CONTROL)); + final AsyncResource write = control.write( + new byte[] {9}); + await("characteristic write", new Condition() { + public boolean isMet() { + return write.isDone(); + } + }); + Assertions.assertEquals(Boolean.TRUE, write.get(null)); + + final AsyncResource descRead = measurement.getDescriptor( + BluetoothUuid.fromString(FakeBleHelper.CCCD)).read(); + await("descriptor read", new Condition() { + public boolean isMet() { + return descRead.isDone(); + } + }); + Assertions.assertArrayEquals(new byte[] {(byte) 0x92}, + descRead.get(null)); + + // arm notifications: completes when the helper answers + // "subscribed"; the fake's follow-up notification targets the + // control characteristic, which has no listeners, so the routing + // path is exercised without any EDT dispatch + com.codename1.bluetooth.gatt.GattNotificationListener listener = + (c, v) -> { }; + final AsyncResource sub = p.subscribe(measurement, + listener); + await("subscription arming", new Condition() { + public boolean isMet() { + return sub.isDone(); + } + }); + Assertions.assertEquals(Boolean.TRUE, sub.get(null)); + Assertions.assertTrue(p.isSubscribed(measurement)); + final AsyncResource unsub = p.unsubscribe(measurement, + listener); + await("subscription disarming", new Condition() { + public boolean isMet() { + return unsub.isDone(); + } + }); + Assertions.assertFalse(p.isSubscribed(measurement)); + + final AsyncResource rssi = p.readRssi(); + await("rssi read", new Condition() { + public boolean isMet() { + return rssi.isDone(); + } + }); + Assertions.assertEquals(Integer.valueOf(-55), rssi.get(null)); + + final AsyncResource mtu = p.requestMtu(185); + await("mtu request", new Condition() { + public boolean isMet() { + return mtu.isDone(); + } + }); + Assertions.assertEquals(Integer.valueOf(23), mtu.get(null)); + + final AsyncResource bond = p.createBond(); + await("bond attempt", new Condition() { + public boolean isMet() { + return bond.isDone(); + } + }); + Throwable bondFailure = errorOf(bond); + Assertions.assertTrue(bondFailure instanceof BluetoothException); + Assertions.assertEquals(BluetoothError.BOND_FAILED, + ((BluetoothException) bondFailure).getError()); + + Throwable l2cap = errorOf(p.openL2capChannel(0x80, false)); + Assertions.assertTrue(l2cap instanceof BluetoothException); + Assertions.assertEquals(BluetoothError.NOT_SUPPORTED, + ((BluetoothException) l2cap).getError()); + + p.disconnect(); + await("disconnect", new Condition() { + public boolean isMet() { + return p.getConnectionState() + == ConnectionState.DISCONNECTED; + } + }); + await("connected registry empties", new Condition() { + public boolean isMet() { + return b.getConnectedPeripherals(null).isEmpty(); + } + }); + } + + @Test + public void helperCrashFailsInFlightOpsTypedAndKillsTheBackend() { + NativeBleBackend b = start("crash-on-connect"); + final List states = attachStateSink(b); + scanUntilSighted(b); + final BlePeripheral p = b.getPeripheral("aa:01"); + + final AsyncResource connect = p.connect(); + await("connect failure after crash", new Condition() { + public boolean isMet() { + return connect.isDone(); + } + }); + Throwable failure = errorOf(connect); + Assertions.assertTrue(failure instanceof BluetoothException, + "expected a typed BluetoothException, got " + failure); + Assertions.assertEquals(BluetoothError.IO_ERROR, + ((BluetoothException) failure).getError()); + Assertions.assertEquals(ConnectionState.DISCONNECTED, + p.getConnectionState()); + await("adapter reports UNSUPPORTED", new Condition() { + public boolean isMet() { + return b.getAdapterState() == AdapterState.UNSUPPORTED; + } + }); + Assertions.assertTrue(states.contains(AdapterState.UNSUPPORTED)); + // a crashed helper stays dead: follow-up ops fail typed, no restart + final AsyncResource again = p.connect(); + await("post-crash connect failure", new Condition() { + public boolean isMet() { + return again.isDone(); + } + }); + Assertions.assertTrue(errorOf(again) instanceof BluetoothException); + } + + @Test + public void shutdownFailsInFlightOpsInsteadOfHanging() { + NativeBleBackend b = start("hang-on-connect"); + scanUntilSighted(b); + final BlePeripheral p = b.getPeripheral("aa:01"); + final AsyncResource connect = p.connect(); + Assertions.assertFalse(connect.isDone()); + b.shutdown(); + await("in-flight connect fails on shutdown", new Condition() { + public boolean isMet() { + return connect.isDone(); + } + }); + Throwable failure = errorOf(connect); + Assertions.assertTrue(failure instanceof BluetoothException); + Assertions.assertEquals(BluetoothError.IO_ERROR, + ((BluetoothException) failure).getError()); + } + + @Test + public void rssiUnsupportedFallsBackToLastScanSighting() { + NativeBleBackend b = start("rssi-unsupported"); + scanUntilSighted(b); + final BlePeripheral p = b.getPeripheral("aa:01"); + final AsyncResource connect = p.connect(); + await("connect", new Condition() { + public boolean isMet() { + return connect.isDone(); + } + }); + final AsyncResource rssi = p.readRssi(); + await("rssi fallback", new Condition() { + public boolean isMet() { + return rssi.isDone(); + } + }); + // the helper answered notSupported; the backend falls back to the + // -42 sighting recorded during the scan + Assertions.assertEquals(Integer.valueOf(-42), rssi.get(null)); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleHelperResolutionTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleHelperResolutionTest.java new file mode 100644 index 00000000000..bcd4141fe17 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleHelperResolutionTest.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * The helper-binary resolution order of {@link NativeBleBackend}: system + * property, bundled classpath resource, {@code PATH} lookup -- exercised + * with fake files so the tests never depend on this machine's setup. + * {@code "TestOS"} is used as the os.name whenever the bundled-resource + * step must deterministically not resolve (no resource ships for it). + */ +public class NativeBleHelperResolutionTest { + + @TempDir + File tempDir; + + private File fakeBinary(String name) throws IOException { + File f = new File(tempDir, name); + FileOutputStream out = new FileOutputStream(f); + out.write("#!/bin/sh\n".getBytes("UTF-8")); + out.close(); + f.setExecutable(true, true); + return f; + } + + @Test + public void systemPropertyWinsWhenTheFileExists() throws IOException { + File binary = fakeBinary("my-helper"); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary( + binary.getAbsolutePath(), "TestOS", "x86_64", null, attempted); + Assertions.assertEquals(binary.getAbsolutePath(), + resolved.getAbsolutePath()); + Assertions.assertTrue(attempted.get(0).contains( + HelperBinaryResolver.HELPER_PATH_PROPERTY)); + } + + @Test + public void missingPropertyFileFallsThroughToLaterSteps() + throws IOException { + File onPath = fakeBinary(HelperBinaryResolver.HELPER_BASENAME); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary( + new File(tempDir, "no-such-file").getAbsolutePath(), + "TestOS", "x86_64", tempDir.getAbsolutePath(), attempted); + Assertions.assertEquals(onPath.getAbsolutePath(), + resolved.getAbsolutePath()); + String trace = String.valueOf(attempted); + Assertions.assertTrue(trace.contains("no such file"), trace); + Assertions.assertTrue(trace.contains("PATH lookup"), trace); + } + + @Test + public void pathLookupScansEntriesInOrder() throws IOException { + File emptyDir = new File(tempDir, "empty"); + emptyDir.mkdirs(); + File binDir = new File(tempDir, "bin"); + binDir.mkdirs(); + File binary = new File(binDir, HelperBinaryResolver.HELPER_BASENAME); + new FileOutputStream(binary).close(); + String pathEnv = emptyDir.getAbsolutePath() + File.pathSeparator + + binDir.getAbsolutePath(); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveFromPathEnv(pathEnv, + HelperBinaryResolver.HELPER_BASENAME, attempted); + Assertions.assertEquals(binary.getAbsolutePath(), + resolved.getAbsolutePath()); + } + + @Test + public void nothingFoundReportsEveryAttemptedLocation() { + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary(null, "TestOS", + "x86_64", "", attempted); + Assertions.assertNull(resolved); + String trace = String.valueOf(attempted); + Assertions.assertTrue(trace.contains( + HelperBinaryResolver.HELPER_PATH_PROPERTY), trace); + Assertions.assertTrue(trace.contains("not set"), trace); + Assertions.assertTrue( + trace.contains("no bundled helper for os.name=TestOS"), + trace); + Assertions.assertTrue(trace.contains("os.arch=x86_64"), trace); + Assertions.assertTrue(trace.contains("PATH lookup"), trace); + } + + @Test + public void bundledResourceLocationsAreKeyedByOs() { + // macOS ships one universal Mach-O binary, so the arch is ignored + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "macos/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Mac OS X", "x86_64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "macos/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Mac OS X", "aarch64")); + Assertions.assertNull( + HelperBinaryResolver.helperResourcePath("TestOS", "x86_64")); + Assertions.assertEquals("cn1-ble-helper.exe", + HelperBinaryResolver.helperExecutableName("Windows 11")); + Assertions.assertEquals("cn1-ble-helper", + HelperBinaryResolver.helperExecutableName("Mac OS X")); + } + + @Test + public void linuxAndWindowsResourcesAreKeyedByArchitecture() { + // ELF and PE have no fat-binary format, so these are per-arch + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "linux/x64/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Linux", "amd64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "linux/arm64/cn1-ble-helper", + HelperBinaryResolver.helperResourcePath("Linux", "aarch64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "windows/x64/cn1-ble-helper.exe", + HelperBinaryResolver.helperResourcePath("Windows 11", "amd64")); + Assertions.assertEquals(HelperBinaryResolver.HELPER_RESOURCE_DIR + + "windows/arm64/cn1-ble-helper.exe", + HelperBinaryResolver.helperResourcePath("Windows 11", "aarch64")); + } + + @Test + public void architectureAliasesMapOntoTheBundledDirectories() { + Assertions.assertEquals("x64", HelperBinaryResolver.normalizeArch("amd64")); + Assertions.assertEquals("x64", + HelperBinaryResolver.normalizeArch("x86_64")); + Assertions.assertEquals("x64", HelperBinaryResolver.normalizeArch("X64")); + Assertions.assertEquals("arm64", + HelperBinaryResolver.normalizeArch("aarch64")); + Assertions.assertEquals("arm64", + HelperBinaryResolver.normalizeArch("arm64")); + // 32-bit x86/ARM ship no binary: resolution falls through to PATH + Assertions.assertNull(HelperBinaryResolver.normalizeArch("x86")); + Assertions.assertNull(HelperBinaryResolver.normalizeArch("i386")); + Assertions.assertNull(HelperBinaryResolver.normalizeArch("arm")); + Assertions.assertNull(HelperBinaryResolver.normalizeArch(null)); + } + + @Test + public void unknownArchitectureStillFallsBackToPath() throws IOException { + File binary = fakeBinary(HelperBinaryResolver.HELPER_BASENAME); + List attempted = new ArrayList<>(); + File resolved = HelperBinaryResolver.resolveHelperBinary(null, "Linux", + "riscv64", tempDir.getAbsolutePath(), attempted); + Assertions.assertEquals(binary.getAbsolutePath(), + resolved.getAbsolutePath()); + Assertions.assertTrue(String.valueOf(attempted).contains( + "os.arch=riscv64"), String.valueOf(attempted)); + } + + @Test + public void unavailableBackendDescribesTheResolutionTrace() { + // constructed against the real environment: whether or not a + // helper is present here, the description must name the property + NativeBleBackend backend = new NativeBleBackend(); + Assertions.assertTrue(backend.describeResolution().contains( + HelperBinaryResolver.HELPER_PATH_PROPERTY)); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleSmokeTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleSmokeTest.java new file mode 100644 index 00000000000..6008005a81f --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleSmokeTest.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.helper.BleBackend; +import com.codename1.bluetooth.le.ScanResult; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.io.File; +import java.util.Arrays; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Manual smoke test against the REAL cn1-ble-helper binary and this + * machine's radio. Disabled by default (hardware + OS Bluetooth permission + * required); run explicitly with: + * + *
+ * mvn test -pl javase -Plocal-dev-javase \
+ *   -Dtest=NativeBleSmokeTest -Dcn1.ble.smoke=true \
+ *   -Dcn1.bluetooth.helperPath=<path-to>/cn1-ble-helper
+ * 
+ * + * Without {@code cn1.bluetooth.helperPath} the test looks for the local + * cargo build at {@code Ports/JavaSE/native/cn1-ble-helper/target/release}. + * + *

The test never fails for environmental reasons -- an adapter that + * reports unsupported/unauthorized (headless CI, missing TCC grant on + * macOS) or zero sightings are reported, not asserted. Only a broken + * protocol handshake fails it.

+ */ +public class NativeBleSmokeTest { + + @Test + @EnabledIfSystemProperty(named = "cn1.ble.smoke", matches = "true") + public void scanTheRealRadioForThreeSeconds() throws Exception { + String helperPath = System.getProperty( + HelperBinaryResolver.HELPER_PATH_PROPERTY); + if (helperPath == null) { + helperPath = new File( + "../../Ports/JavaSE/native/cn1-ble-helper/target/" + + "release/cn1-ble-helper").getAbsolutePath(); + } + File helper = new File(helperPath); + Assumptions.assumeTrue(helper.isFile(), + "no helper binary at " + helper.getAbsolutePath() + + " -- build it with cargo or pass -D" + + HelperBinaryResolver.HELPER_PATH_PROPERTY); + + NativeBleBackend backend = new NativeBleBackend( + Arrays.asList(helper.getAbsolutePath())); + try { + backend.setAdapterStateSink(new BleBackend.AdapterStateSink() { + public void adapterStateChanged(AdapterState newState) { + System.out.println("[ble-smoke] adapter state: " + + newState); + } + }); + long deadline = System.currentTimeMillis() + 10000; + while (backend.getAdapterState() == AdapterState.UNKNOWN + && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + AdapterState state = backend.getAdapterState(); + Assertions.assertNotEquals(AdapterState.UNKNOWN, state, + "helper never completed the stateChanged handshake"); + System.out.println("[ble-smoke] adapter: " + state + + ", helper descriptors=" + + backend.helperSupports("descriptors")); + if (state != AdapterState.POWERED_ON) { + System.out.println("[ble-smoke] adapter not powered on -- " + + "skipping the scan (likely missing hardware or " + + "OS Bluetooth permission)"); + return; + } + final CopyOnWriteArrayList sightings = + new CopyOnWriteArrayList(); + backend.startScan(new BleBackend.ScanSink() { + public void onResult(ScanResult result) { + sightings.add(result); + } + + public void onFailed(BluetoothException reason) { + System.out.println("[ble-smoke] scan failed: " + + reason); + } + }); + Thread.sleep(3000); + backend.stopScan(); + java.util.HashSet unique = new java.util.HashSet<>(); + for (ScanResult r : sightings) { + unique.add(r.getPeripheral().getAddress()); + } + System.out.println("[ble-smoke] " + sightings.size() + + " sightings of " + unique.size() + + " distinct peripherals in 3s"); + } finally { + backend.shutdown(); + } + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleWireProtocolTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleWireProtocolTest.java new file mode 100644 index 00000000000..5f59ad9946b --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/NativeBleWireProtocolTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.AdapterState; +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.helper.HelperBleBackend; +import com.codename1.bluetooth.helper.HelperBlePeripheral; +import com.codename1.bluetooth.helper.Wire; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * Deterministic codec tests of the cn1-ble-helper wire protocol: command + * serialization (including escaping), event-line parsing and the typed + * error / adapter-state mappings. No subprocess, no hardware. + */ +public class NativeBleWireProtocolTest { + + @Test + public void commandSerializationIsStable() { + String line = Wire.obj().put("cmd", "connect").put("id", 7L) + .put("address", "aa:bb:cc").line(); + Assertions.assertEquals( + "{\"cmd\":\"connect\",\"id\":7,\"address\":\"aa:bb:cc\"}", + line); + String write = Wire.obj().put("cmd", "write").put("id", 12L) + .put("value", "AQI=").put("noResponse", true).line(); + Assertions.assertEquals("{\"cmd\":\"write\",\"id\":12," + + "\"value\":\"AQI=\",\"noResponse\":true}", write); + } + + @Test + public void escapingCoversQuotesBackslashesAndControlChars() { + Assertions.assertEquals("a\\\"b\\\\c\\nd\\re\\tf", + Wire.escape("a\"b\\c\nd\re\tf")); + Assertions.assertEquals("x\\u0001y", Wire.escape("x" + (char) 1 + "y")); + Assertions.assertEquals("", Wire.escape(null)); + // escaped output must survive a JSON parse round trip + String line = Wire.obj().put("cmd", "write") + .put("value", "we\"ird\\pay\nload").line(); + Map parsed = parse(line); + Assertions.assertEquals("we\"ird\\pay\nload", + Wire.str(parsed, "value", null)); + } + + private static Map parse(String line) { + try { + return Wire.parse(line); + } catch (Exception ex) { + throw new AssertionError("parse failed: " + line, ex); + } + } + + @Test + public void eventParsingExtractsTypedFields() { + Map ev = parse("{\"event\":\"readResult\"," + + "\"requestId\":42,\"address\":\"aa:01\"," + + "\"rssi\":-63,\"value\":\"AQI=\",\"primary\":true}"); + Assertions.assertEquals("readResult", Wire.str(ev, "event", null)); + Assertions.assertEquals(42, Wire.longVal(ev, "requestId", -1)); + Assertions.assertEquals(-63, Wire.intVal(ev, "rssi", 0)); + Assertions.assertTrue(Wire.boolVal(ev, "primary", false)); + Assertions.assertEquals(-1, Wire.longVal(ev, "missing", -1)); + Assertions.assertArrayEquals(new byte[] {1, 2}, + Wire.decodeBase64(Wire.str(ev, "value", ""))); + } + + @Test + public void nestedScanResultStructuresParse() { + Map ev = parse("{\"event\":\"scanResult\"," + + "\"address\":\"aa:01\",\"name\":\"HR\",\"rssi\":-45," + + "\"serviceUuids\":[\"0000180d-0000-1000-8000-00805f9b34fb\"]," + + "\"manufacturerData\":{\"76\":\"AQI=\"}," + + "\"serviceData\":{}}"); + List uuids = Wire.list(ev, "serviceUuids"); + Assertions.assertEquals(1, uuids.size()); + Assertions.assertEquals("0000180d-0000-1000-8000-00805f9b34fb", + String.valueOf(uuids.get(0))); + Map manufacturer = + Wire.map(ev.get("manufacturerData")); + Assertions.assertArrayEquals(new byte[] {1, 2}, + Wire.decodeBase64(String.valueOf(manufacturer.get("76")))); + Assertions.assertTrue(Wire.map(ev.get("serviceData")).isEmpty()); + Assertions.assertTrue(Wire.list(ev, "absent").isEmpty()); + } + + @Test + public void base64RoundTrips() { + byte[] data = new byte[] {0, 1, 2, (byte) 0xFF, 42}; + Assertions.assertArrayEquals(data, + Wire.decodeBase64(Wire.encodeBase64(data))); + Assertions.assertArrayEquals(new byte[0], Wire.decodeBase64("")); + Assertions.assertArrayEquals(new byte[0], Wire.decodeBase64(null)); + Assertions.assertEquals("", Wire.encodeBase64(null)); + } + + @Test + public void errorCodesMapToTypedBluetoothErrors() { + Assertions.assertEquals(BluetoothError.NOT_SUPPORTED, + HelperBleBackend.mapErrorCode("notSupported")); + Assertions.assertEquals(BluetoothError.UNAUTHORIZED, + HelperBleBackend.mapErrorCode("unauthorized")); + Assertions.assertEquals(BluetoothError.POWERED_OFF, + HelperBleBackend.mapErrorCode("poweredOff")); + Assertions.assertEquals(BluetoothError.SCAN_FAILED, + HelperBleBackend.mapErrorCode("scanFailed")); + Assertions.assertEquals(BluetoothError.CONNECTION_FAILED, + HelperBleBackend.mapErrorCode("connectFailed")); + Assertions.assertEquals(BluetoothError.CONNECTION_FAILED, + HelperBleBackend.mapErrorCode("unknownPeripheral")); + Assertions.assertEquals(BluetoothError.NOT_CONNECTED, + HelperBleBackend.mapErrorCode("notConnected")); + Assertions.assertEquals(BluetoothError.GATT_ERROR, + HelperBleBackend.mapErrorCode("unknownCharacteristic")); + Assertions.assertEquals(BluetoothError.GATT_ERROR, + HelperBleBackend.mapErrorCode("unknownDescriptor")); + Assertions.assertEquals(BluetoothError.TIMEOUT, + HelperBleBackend.mapErrorCode("timeout")); + Assertions.assertEquals(BluetoothError.IO_ERROR, + HelperBleBackend.mapErrorCode("ioError")); + Assertions.assertEquals(BluetoothError.UNKNOWN, + HelperBleBackend.mapErrorCode("badRequest")); + Assertions.assertEquals(BluetoothError.UNKNOWN, + HelperBleBackend.mapErrorCode("somethingNew")); + } + + @Test + public void adapterStatesMap() { + Assertions.assertEquals(AdapterState.POWERED_ON, + HelperBleBackend.mapAdapterState("poweredOn")); + Assertions.assertEquals(AdapterState.POWERED_OFF, + HelperBleBackend.mapAdapterState("poweredOff")); + Assertions.assertEquals(AdapterState.UNSUPPORTED, + HelperBleBackend.mapAdapterState("unsupported")); + Assertions.assertEquals(AdapterState.UNAUTHORIZED, + HelperBleBackend.mapAdapterState("unauthorized")); + Assertions.assertEquals(AdapterState.UNKNOWN, + HelperBleBackend.mapAdapterState("whatever")); + } + + @Test + public void characteristicPropertyNamesMapToBits() { + int mask = HelperBlePeripheral.propertiesMask(Arrays.asList( + (Object) "read", "notify")); + Assertions.assertEquals(GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_NOTIFY, mask); + int all = HelperBlePeripheral.propertiesMask(Arrays.asList( + (Object) "broadcast", "read", "writeWithoutResponse", + "write", "notify", "indicate", "signedWrite", + "extendedProps", "unknownFutureFlag")); + Assertions.assertEquals(GattCharacteristic.PROPERTY_BROADCAST + | GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_WRITE_WITHOUT_RESPONSE + | GattCharacteristic.PROPERTY_WRITE + | GattCharacteristic.PROPERTY_NOTIFY + | GattCharacteristic.PROPERTY_INDICATE + | GattCharacteristic.PROPERTY_SIGNED_WRITE + | GattCharacteristic.PROPERTY_EXTENDED_PROPS, all); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/RecordedTraceReplayTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/RecordedTraceReplayTest.java new file mode 100644 index 00000000000..fd7fa7f21b0 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/RecordedTraceReplayTest.java @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.helper.BleBackend; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.AdvertisementData; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.CharacteristicRecord; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.Device; +import com.codename1.impl.javase.bluetooth.BluetoothFixture.ServiceRecord; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Replays the SHIPPED fixture files (real traces captured from live + * hardware via {@link FixtureRecorder} and scrambled with + * {@link FixtureScrambler}) into a fully deterministic + * {@link ManualScheduler} stack, asserting that the scan feed delivers + * every recorded device with its advertisement payload -- through the + * raw stack feed and through the core-API {@link SimulatorBleBackend} + * ({@link ScanResult} level). Fixtures that carry a captured GATT + * database are additionally connected, discovered and read through the + * full stack, comparing values against the fixture. + * + *

Both shipped fixtures are scan-only ambient captures from two + * different moments: every connectable device in the recording + * environment (Apple/Samsung random-address peripherals) refused GATT + * connections, the documented common case. The GATT replay path is + * covered end-to-end by {@link FixtureRecorderTest}, which records a + * GATT-bearing fixture from the scripted fake helper and replays it the + * same way (see {@code replayFixture(...)} here, shared by both tests). + *

+ */ +public class RecordedTraceReplayTest { + + /** The committed traces under {@code /bluetooth-fixtures/}. */ + static final String[] SHIPPED_FIXTURES = { + "ambient-scan.json", "ambient-scan-2.json" + }; + + /** Loads a shipped fixture from the test classpath. */ + static BluetoothFixture load(String name) throws IOException { + InputStream in = RecordedTraceReplayTest.class.getResourceAsStream( + "/bluetooth-fixtures/" + name); + Assertions.assertNotNull(in, "missing fixture resource " + name); + try { + return BluetoothFixture.fromJson(in); + } finally { + in.close(); + } + } + + @Test + public void ambientScanReplaysEveryDevice() throws IOException { + replayShipped("ambient-scan.json"); + } + + @Test + public void secondAmbientScanReplaysEveryDevice() throws IOException { + replayShipped("ambient-scan-2.json"); + } + + @Test + public void reloadAfterResetWorks() throws IOException { + BluetoothFixture fixture = load("ambient-scan.json"); + ManualScheduler scheduler = new ManualScheduler(); + SimulatedBluetoothStack stack = + new SimulatedBluetoothStack(scheduler); + stack.loadFixture(fixture); + scheduler.advance(replayWindow(fixture)); + Assertions.assertEquals(fixture.getDevices().size(), + stack.getPeripheralAddresses().size()); + stack.reset(); + scheduler.advance(1000); + Assertions.assertTrue(stack.getPeripheralAddresses().isEmpty()); + stack.loadFixture(fixture); + scheduler.advance(replayWindow(fixture)); + Assertions.assertEquals(fixture.getDevices().size(), + stack.getPeripheralAddresses().size()); + } + + private void replayShipped(String name) throws IOException { + BluetoothFixture fixture = load(name); + Assertions.assertFalse(fixture.getDevices().isEmpty()); + replayFixture(fixture); + } + + /** + * The full replay assertion, shared with {@link FixtureRecorderTest}: + * loads the fixture into a fresh ManualScheduler stack, advances the + * virtual clock across the recorded timeline and asserts that + *
    + *
  • the raw stack scan feed and the core {@link ScanResult} feed + * deliver every fixture device exactly once,
  • + *
  • each advertisement carries the recorded name, service UUIDs, + * manufacturer data, service data and TX power,
  • + *
  • every GATT-bearing device connects, discovers the recorded + * database and serves the captured characteristic values.
  • + *
+ */ + static void replayFixture(BluetoothFixture fixture) { + ManualScheduler scheduler = new ManualScheduler(); + SimulatedBluetoothStack stack = + new SimulatedBluetoothStack(scheduler); + SimulatorBleBackend backend = new SimulatorBleBackend(stack); + + final List rawSightings = + new ArrayList(); + stack.startScanFeed(new SimulatedBluetoothStack.ScanFeedSink() { + public void onSighting(VirtualPeripheral peripheral, + long timestamp) { + rawSightings.add(peripheral); + } + + public void onScanFailed(com.codename1.bluetooth.BluetoothError + error, String message) { + Assertions.fail("scan failed: " + error + " " + message); + } + }); + final Map results = + new HashMap(); + backend.startScan(new BleBackend.ScanSink() { + public void onResult(ScanResult result) { + results.put(result.getPeripheral().getAddress(), result); + } + + public void onFailed(BluetoothException reason) { + Assertions.fail("core scan failed: " + reason); + } + }); + + stack.loadFixture(fixture); + scheduler.advance(replayWindow(fixture)); + + List devices = fixture.getDevices(); + Assertions.assertEquals(devices.size(), rawSightings.size(), + "raw feed must deliver every fixture device exactly once"); + Assertions.assertEquals(devices.size(), results.size(), + "core feed must deliver every fixture device exactly once"); + for (Device d : devices) { + assertAdvertisement(d, results.get(d.id)); + assertRssiTimelineApplied(d, stack); + if (d.hasGatt()) { + assertGattReplay(d, backend, scheduler); + } + } + } + + /** Far enough past the last recorded timestamp for all completions. */ + private static long replayWindow(BluetoothFixture fixture) { + long max = 0; + for (Device d : fixture.getDevices()) { + int size = d.rssiTimeline.size(); + for (int i = 0; i < size; i++) { + max = Math.max(max, d.rssiTimeline.get(i).relTimeMs); + } + } + return max + 10000; + } + + private static void assertAdvertisement(Device d, ScanResult result) { + Assertions.assertNotNull(result, "no sighting of " + d.id); + Assertions.assertEquals(d.id, + result.getPeripheral().getAddress()); + Assertions.assertEquals(d.connectable, result.isConnectable()); + Assertions.assertFalse(d.rssiTimeline.isEmpty()); + Assertions.assertEquals(d.rssiTimeline.get(0).rssi, + result.getRssi(), + d.id + ": the sighting carries the first recorded RSSI"); + AdvertisementData ad = result.getAdvertisementData(); + Assertions.assertNotNull(ad); + Assertions.assertEquals(d.name, ad.getLocalName(), d.id + " name"); + Assertions.assertEquals(d.serviceUuids, ad.getServiceUuids(), + d.id + " advertised service uuids"); + Assertions.assertEquals(d.manufacturerData.size(), + ad.getManufacturerIds().length, d.id + " manufacturer ids"); + for (Map.Entry e : d.manufacturerData.entrySet()) { + Assertions.assertArrayEquals(e.getValue(), + ad.getManufacturerData(e.getKey().intValue()), + d.id + " manufacturer data " + e.getKey()); + } + Assertions.assertEquals(d.serviceData.size(), + ad.getServiceDataUuids().size(), d.id + " service data"); + for (Map.Entry e + : d.serviceData.entrySet()) { + Assertions.assertArrayEquals(e.getValue(), + ad.getServiceData(e.getKey()), + d.id + " service data " + e.getKey()); + } + Assertions.assertEquals(d.txPower, ad.getTxPowerLevel(), + d.id + " tx power"); + } + + /** After the window, the peripheral holds the LAST timeline RSSI. */ + private static void assertRssiTimelineApplied(Device d, + SimulatedBluetoothStack stack) { + VirtualPeripheral p = stack.getPeripheral(d.id); + Assertions.assertNotNull(p, d.id + " must be registered"); + Assertions.assertEquals( + d.rssiTimeline.get(d.rssiTimeline.size() - 1).rssi, + p.getRssi(), d.id + " final RSSI after timeline replay"); + } + + /** Connect + discover + read through the core API. */ + private static void assertGattReplay(Device d, + SimulatorBleBackend backend, ManualScheduler scheduler) { + BlePeripheral p = backend.getPeripheral(d.id); + Assertions.assertNotNull(p, d.id + " peripheral lookup"); + AsyncResource connect = p.connect(); + scheduler.advance(1000); + Assertions.assertTrue(connect.isDone(), d.id + " connect"); + AsyncResource> discover = p.discoverServices(); + scheduler.advance(1000); + Assertions.assertTrue(discover.isDone(), d.id + " discover"); + List services = discover.get(null); + Assertions.assertNotNull(services); + Assertions.assertEquals(d.gatt.size(), services.size(), + d.id + " service count"); + for (ServiceRecord sr : d.gatt) { + GattService service = p.getService(sr.uuid); + Assertions.assertNotNull(service, + d.id + " service " + sr.uuid); + Assertions.assertEquals(sr.primary, service.isPrimary()); + Assertions.assertEquals(sr.characteristics.size(), + service.getCharacteristics().size()); + for (CharacteristicRecord cr : sr.characteristics) { + GattCharacteristic c = service.getCharacteristic(cr.uuid); + Assertions.assertNotNull(c, d.id + " characteristic " + + cr.uuid); + Assertions.assertEquals(cr.properties, c.getProperties()); + Assertions.assertEquals(cr.descriptors.size(), + c.getDescriptors().size()); + if (cr.value != null && c.canRead()) { + AsyncResource read = c.read(); + scheduler.advance(1000); + Assertions.assertTrue(read.isDone(), + d.id + " read " + cr.uuid); + Assertions.assertArrayEquals(cr.value, read.get(null), + d.id + " captured value of " + cr.uuid); + } + } + } + p.disconnect(); + scheduler.advance(1000); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualBluetoothStackTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualBluetoothStackTest.java new file mode 100644 index 00000000000..3b5b72f3eb5 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualBluetoothStackTest.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Registry, adapter gating, scan feeds and reset of the simulated stack -- + * fully deterministic on the manual scheduler. + */ +public class VirtualBluetoothStackTest extends AbstractVirtualStackTest { + + private static final class RecordingScanSink + implements SimulatedBluetoothStack.ScanFeedSink { + final List sightings = new ArrayList<>(); + final List timestamps = new ArrayList<>(); + BluetoothError failure; + + public void onSighting(VirtualPeripheral peripheral, long timestamp) { + sightings.add(peripheral.getAddress()); + timestamps.add(timestamp); + } + + public void onScanFailed(BluetoothError error, String message) { + failure = error; + } + } + + @Test + public void registerRemoveAndClearPeripherals() { + stack.addPeripheral(new VirtualPeripheral("AA:01")); + stack.addPeripheral(new VirtualPeripheral("AA:02")); + settle(); + Assertions.assertTrue(stack.isPeripheralRegistered("AA:01")); + Assertions.assertTrue(stack.isPeripheralRegistered("AA:02")); + Assertions.assertEquals(Arrays.asList("AA:01", "AA:02"), + stack.getPeripheralAddresses()); + + stack.removePeripheral("AA:01"); + settle(); + Assertions.assertFalse(stack.isPeripheralRegistered("AA:01")); + Assertions.assertTrue(stack.isPeripheralRegistered("AA:02")); + + stack.clearPeripherals(); + settle(); + Assertions.assertTrue(stack.getPeripheralAddresses().isEmpty()); + } + + @Test + public void adapterToggleGatesOperations() { + stack.addPeripheral(new VirtualPeripheral("AA:01")); + stack.setAdapterEnabled(false); + settle(); + Assertions.assertFalse(stack.isAdapterEnabled()); + + Result connect = new Result<>(); + stack.connect("AA:01", connect); + RecordingScanSink scan = new RecordingScanSink(); + stack.startScanFeed(scan); + settle(); + connect.assertFailure(BluetoothError.POWERED_OFF); + Assertions.assertEquals(BluetoothError.POWERED_OFF, scan.failure); + Assertions.assertTrue(scan.sightings.isEmpty()); + + stack.setAdapterEnabled(true); + Result connect2 = new Result<>(); + stack.connect("AA:01", connect2); + settle(); + connect2.assertSuccess(); + Assertions.assertTrue(stack.isConnected("AA:01")); + } + + @Test + public void scanFeedEmitsRegisteredLePeripheralsInOrder() { + stack.addPeripheral(new VirtualPeripheral("AA:01")); + stack.addPeripheral(new VirtualPeripheral("AA:02")); + // classic-only devices never show in an LE scan + stack.addPeripheral(new VirtualPeripheral("AA:03") + .setLe(false).setClassic(true)); + stack.addPeripheral(new VirtualPeripheral("AA:04")); + settle(); + + RecordingScanSink sink = new RecordingScanSink(); + stack.startScanFeed(sink); + settle(); + + Assertions.assertEquals(Arrays.asList("AA:01", "AA:02", "AA:04"), + sink.sightings); + Assertions.assertNull(sink.failure); + // timestamps come from the stack's monotonic counter + for (int i = 1; i < sink.timestamps.size(); i++) { + Assertions.assertTrue(sink.timestamps.get(i) + > sink.timestamps.get(i - 1)); + } + } + + @Test + public void lateAddedPeripheralIsEmittedToActiveFeed() { + stack.addPeripheral(new VirtualPeripheral("AA:01")); + settle(); + + RecordingScanSink sink = new RecordingScanSink(); + stack.startScanFeed(sink); + settle(); + Assertions.assertEquals(Arrays.asList("AA:01"), sink.sightings); + + stack.addPeripheral(new VirtualPeripheral("AA:05")); + settle(); + Assertions.assertEquals(Arrays.asList("AA:01", "AA:05"), + sink.sightings); + } + + @Test + public void stoppedFeedReceivesNothing() { + stack.addPeripheral(new VirtualPeripheral("AA:01")); + settle(); + + RecordingScanSink sink = new RecordingScanSink(); + Object token = stack.startScanFeed(sink); + stack.stopScanFeed(token); + settle(); + Assertions.assertTrue(sink.sightings.isEmpty()); + + // and late-added peripherals do not reach it either + stack.addPeripheral(new VirtualPeripheral("AA:06")); + settle(); + Assertions.assertTrue(sink.sightings.isEmpty()); + } + + @Test + public void eventLogSeesEveryOperation() { + List ops = new ArrayList<>(); + stack.addEventListener((op, detail) -> ops.add(op)); + stack.addPeripheral(new VirtualPeripheral("AA:01")); + Result connect = new Result<>(); + stack.connect("AA:01", connect); + settle(); + Assertions.assertTrue(ops.contains("registerPeripheral")); + Assertions.assertTrue(ops.contains("connect")); + } + + @Test + public void resetRestoresPristineState() { + stack.addPeripheral(heartRatePeripheral("AA:01")); + stack.setAdapterEnabled(false); + stack.setLatencyMillis(500); + stack.failNext("connect", BluetoothError.BUSY, "scripted"); + settle(); + + stack.reset(); + settle(); + + Assertions.assertTrue(stack.isAdapterEnabled()); + Assertions.assertTrue(stack.getPeripheralAddresses().isEmpty()); + Assertions.assertEquals(SimulatedBluetoothStack.DEFAULT_LATENCY_MILLIS, + stack.getLatencyMillis()); + Assertions.assertFalse(stack.isConnected("AA:01")); + + // the scripted failure is gone: a fresh connect succeeds + stack.addPeripheral(new VirtualPeripheral("AA:01")); + Result connect = new Result<>(); + stack.connect("AA:01", connect); + settle(); + connect.assertSuccess(); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualGattTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualGattTest.java new file mode 100644 index 00000000000..900363baa36 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualGattTest.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * The GATT client path against a virtual peripheral: discovery, + * characteristic/descriptor round trips, subscriptions with pushed + * notifications, link loss and the auxiliary rssi/mtu/bond operations. + */ +public class VirtualGattTest extends AbstractVirtualStackTest { + + private static final String ADDR = "AA:BB:CC:DD:EE:01"; + + private static final class RecordingSink + implements SimulatedBluetoothStack.PeripheralSink { + final List notifications = new ArrayList<>(); + final List notifiedCharacteristics = new ArrayList<>(); + BluetoothError lost; + String lostMessage; + + public void onNotification(BluetoothUuid serviceUuid, + BluetoothUuid characteristicUuid, byte[] value) { + notifiedCharacteristics.add(characteristicUuid); + notifications.add(value); + } + + public void onConnectionLost(BluetoothError error, String message) { + lost = error; + lostMessage = message; + } + } + + @Test + public void discoverySeesTheVirtualDatabase() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + Result connect = new Result<>(); + stack.connect(ADDR, connect); + Result> discover = new Result<>(); + stack.discoverServices(ADDR, discover); + settle(); + + connect.assertSuccess(); + discover.assertSuccess(); + Assertions.assertEquals(1, discover.value.size()); + VirtualService s = discover.value.get(0); + Assertions.assertEquals(HR_SERVICE, s.getUuid()); + Assertions.assertEquals(2, s.getCharacteristics().size()); + Assertions.assertEquals(1, s.getCharacteristic(HR_MEASUREMENT) + .getDescriptors().size()); + } + + @Test + public void characteristicReadWriteRoundTrip() { + VirtualPeripheral p = heartRatePeripheral(ADDR); + stack.addPeripheral(p); + connectAndDiscover(ADDR); + + Result read = new Result<>(); + stack.readCharacteristic(ADDR, HR_SERVICE, HR_MEASUREMENT, read); + settle(); + read.assertSuccess(); + Assertions.assertArrayEquals(new byte[] {0, 72}, read.value); + + Result write = new Result<>(); + stack.writeCharacteristic(ADDR, HR_SERVICE, HR_CONTROL, + new byte[] {9, 8, 7}, write); + settle(); + write.assertSuccess(); + Assertions.assertArrayEquals(new byte[] {9, 8, 7}, + p.getCharacteristic(HR_SERVICE, HR_CONTROL).getValue()); + + Result readBack = new Result<>(); + stack.readCharacteristic(ADDR, HR_SERVICE, HR_CONTROL, readBack); + settle(); + readBack.assertSuccess(); + Assertions.assertArrayEquals(new byte[] {9, 8, 7}, readBack.value); + } + + @Test + public void descriptorReadWriteRoundTrip() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + connectAndDiscover(ADDR); + + Result read = new Result<>(); + stack.readDescriptor(ADDR, HR_SERVICE, HR_MEASUREMENT, + USER_DESCRIPTION, read); + settle(); + read.assertSuccess(); + Assertions.assertArrayEquals(new byte[] {'h', 'r'}, read.value); + + Result write = new Result<>(); + stack.writeDescriptor(ADDR, HR_SERVICE, HR_MEASUREMENT, + USER_DESCRIPTION, new byte[] {'x'}, write); + Result readBack = new Result<>(); + stack.readDescriptor(ADDR, HR_SERVICE, HR_MEASUREMENT, + USER_DESCRIPTION, readBack); + settle(); + write.assertSuccess(); + readBack.assertSuccess(); + Assertions.assertArrayEquals(new byte[] {'x'}, readBack.value); + } + + @Test + public void operationsRequireConnectionAndDiscovery() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + settle(); + + Result notConnected = new Result<>(); + stack.readCharacteristic(ADDR, HR_SERVICE, HR_MEASUREMENT, + notConnected); + settle(); + notConnected.assertFailure(BluetoothError.NOT_CONNECTED); + + Result connect = new Result<>(); + stack.connect(ADDR, connect); + Result notDiscovered = new Result<>(); + stack.readCharacteristic(ADDR, HR_SERVICE, HR_MEASUREMENT, + notDiscovered); + settle(); + connect.assertSuccess(); + notDiscovered.assertFailure(BluetoothError.GATT_ERROR); + } + + @Test + public void subscriptionDeliversPushedNotifications() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + RecordingSink sink = new RecordingSink(); + stack.setPeripheralSink(ADDR, sink); + connectAndDiscover(ADDR); + + // a push before subscribing is dropped + stack.pushNotification(ADDR, HR_SERVICE, HR_MEASUREMENT, + new byte[] {1}); + settle(); + Assertions.assertTrue(sink.notifications.isEmpty()); + + Result subscribe = new Result<>(); + stack.setNotifications(ADDR, HR_SERVICE, HR_MEASUREMENT, true, + subscribe); + settle(); + subscribe.assertSuccess(); + Assertions.assertTrue(stack.isSubscribed(ADDR, HR_SERVICE, + HR_MEASUREMENT)); + + stack.pushNotification(ADDR, HR_SERVICE, HR_MEASUREMENT, + new byte[] {0, 99}); + settle(); + Assertions.assertEquals(1, sink.notifications.size()); + Assertions.assertArrayEquals(new byte[] {0, 99}, + sink.notifications.get(0)); + Assertions.assertEquals(HR_MEASUREMENT, + sink.notifiedCharacteristics.get(0)); + + // unsubscribing stops delivery + Result unsubscribe = new Result<>(); + stack.setNotifications(ADDR, HR_SERVICE, HR_MEASUREMENT, false, + unsubscribe); + settle(); + unsubscribe.assertSuccess(); + stack.pushNotification(ADDR, HR_SERVICE, HR_MEASUREMENT, + new byte[] {5}); + settle(); + Assertions.assertEquals(1, sink.notifications.size()); + } + + @Test + public void remoteDisconnectFiresConnectionLost() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + RecordingSink sink = new RecordingSink(); + stack.setPeripheralSink(ADDR, sink); + connectAndDiscover(ADDR); + + stack.disconnectFromRemote(ADDR); + settle(); + Assertions.assertEquals(BluetoothError.CONNECTION_LOST, sink.lost); + Assertions.assertFalse(stack.isConnected(ADDR)); + + // repeated remote disconnects while down do not re-fire + sink.lost = null; + stack.disconnectFromRemote(ADDR); + settle(); + Assertions.assertNull(sink.lost); + } + + @Test + public void rssiMtuAndBondOperations() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + connectAndDiscover(ADDR); + + Result rssi = new Result<>(); + stack.readRssi(ADDR, rssi); + settle(); + rssi.assertSuccess(); + Assertions.assertEquals(-42, rssi.value.intValue()); + + Result mtu = new Result<>(); + stack.requestMtu(ADDR, 185, mtu); + settle(); + mtu.assertSuccess(); + Assertions.assertEquals(185, mtu.value.intValue()); + Assertions.assertEquals(185, stack.getMtu(ADDR)); + + // the grant clamps to the BLE valid range + Result huge = new Result<>(); + stack.requestMtu(ADDR, 10000, huge); + Result tiny = new Result<>(); + stack.requestMtu(ADDR, 5, tiny); + settle(); + Assertions.assertEquals(517, huge.value.intValue()); + Assertions.assertEquals(23, tiny.value.intValue()); + + Assertions.assertFalse(stack.isBonded(ADDR)); + Result bond = new Result<>(); + stack.bond(ADDR, bond); + settle(); + bond.assertSuccess(); + Assertions.assertTrue(stack.isBonded(ADDR)); + Assertions.assertEquals(1, stack.getBondedAddresses().size()); + } + + @Test + public void appDisconnectClearsSubscriptionsAndMtu() { + stack.addPeripheral(heartRatePeripheral(ADDR)); + connectAndDiscover(ADDR); + Result subscribe = new Result<>(); + stack.setNotifications(ADDR, HR_SERVICE, HR_MEASUREMENT, true, + subscribe); + Result mtu = new Result<>(); + stack.requestMtu(ADDR, 200, mtu); + settle(); + + Result disconnect = new Result<>(); + stack.disconnect(ADDR, disconnect); + settle(); + disconnect.assertSuccess(); + Assertions.assertFalse(stack.isConnected(ADDR)); + Assertions.assertFalse(stack.isSubscribed(ADDR, HR_SERVICE, + HR_MEASUREMENT)); + Assertions.assertEquals(23, stack.getMtu(ADDR)); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualL2capStreamTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualL2capStreamTest.java new file mode 100644 index 00000000000..1e87019831e --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualL2capStreamTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.OutputStream; +import java.time.Duration; + +/** + * L2CAP over the virtual stack's piped streams: the app as a central + * against a peripheral's PSM endpoint, and the app as a peripheral + * publishing a PSM that a virtual client connects to. + */ +public class VirtualL2capStreamTest extends AbstractVirtualStackTest { + + private static final Duration GUARD = Duration.ofSeconds(30); + private static final String ADDR = "AA:BB:CC:DD:EE:22"; + private static final int PSM = 0x25; + + @Test + public void appAsCentralEchoRoundTripIsByteExact() { + stack.addPeripheral(new VirtualPeripheral(ADDR) + .withL2capEndpoint(PSM, echoHandler())); + Result open = new Result<>(); + stack.openL2capChannel(ADDR, PSM, open); + settle(); + open.assertSuccess(); + final SimStreamChannel channel = open.value; + + Assertions.assertTimeoutPreemptively(GUARD, () -> { + byte[] payload = new byte[300]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (255 - (i % 251)); + } + OutputStream out = channel.getOutputStream(); + out.write(payload); + out.flush(); + Assertions.assertArrayEquals(payload, + readFully(channel.getInputStream(), payload.length)); + }); + channel.close(); + } + + @Test + public void openWithoutEndpointFails() { + stack.addPeripheral(new VirtualPeripheral(ADDR)); + Result open = new Result<>(); + stack.openL2capChannel(ADDR, PSM, open); + settle(); + open.assertFailure(BluetoothError.IO_ERROR); + } + + @Test + public void appAsServerAcceptsVirtualClientOnPublishedPsm() { + Result publish = new Result<>(); + stack.publishAppL2capServer(publish); + settle(); + publish.assertSuccess(); + int psm = publish.value.intValue(); + Assertions.assertTrue(psm > 0); + + Result accept = new Result<>(); + stack.acceptAppL2cap(psm, accept); + settle(); + Assertions.assertEquals(0, accept.successCount, + "accept should wait until a client arrives"); + + SimStreamChannel client = stack.connectVirtualL2capClient(psm); + settle(); + accept.assertSuccess(); + final SimStreamChannel server = accept.value; + + Assertions.assertTimeoutPreemptively(GUARD, () -> { + client.getOutputStream().write(new byte[] {4, 5, 6}); + client.getOutputStream().flush(); + Assertions.assertArrayEquals(new byte[] {4, 5, 6}, + readFully(server.getInputStream(), 3)); + server.getOutputStream().write(9); + server.getOutputStream().flush(); + Assertions.assertEquals(9, client.getInputStream().read()); + }); + client.close(); + server.close(); + } + + @Test + public void serverCloseFailsPendingAcceptsAndEofPropagates() { + Result publish = new Result<>(); + stack.publishAppL2capServer(publish); + settle(); + int psm = publish.value.intValue(); + + Result accept = new Result<>(); + stack.acceptAppL2cap(psm, accept); + SimStreamChannel client = stack.connectVirtualL2capClient(psm); + settle(); + accept.assertSuccess(); + final SimStreamChannel server = accept.value; + + // server side closes; the virtual client observes EOF + server.close(); + Assertions.assertTimeoutPreemptively(GUARD, () -> + Assertions.assertEquals(-1, + client.getInputStream().read())); + + Result pending = new Result<>(); + stack.acceptAppL2cap(psm, pending); + settle(); + stack.closeAppL2capServer(psm); + settle(); + pending.assertFailure(BluetoothError.IO_ERROR); + client.close(); + } + + @Test + public void virtualClientWithoutListenerObservesImmediateEof() { + SimStreamChannel client = stack.connectVirtualL2capClient(0x99); + settle(); + Assertions.assertTimeoutPreemptively(GUARD, () -> + Assertions.assertEquals(-1, + client.getInputStream().read())); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualPeripheralModeTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualPeripheralModeTest.java new file mode 100644 index 00000000000..3cdfa1483da --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualPeripheralModeTest.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattStatus; +import com.codename1.bluetooth.le.server.GattLocalCharacteristic; +import com.codename1.bluetooth.le.server.GattLocalService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * The peripheral role: the app publishes a GATT server + advertising in + * the stack and a scripted {@link VirtualCentral} connects, reads, writes, + * subscribes and receives {@code notifyValue} pushes. + */ +public class VirtualPeripheralModeTest extends AbstractVirtualStackTest { + + private static final BluetoothUuid SVC = BluetoothUuid.fromShort(0x1815); + private static final BluetoothUuid STATIC_CHR = + BluetoothUuid.fromShort(0x2A56); + private static final BluetoothUuid DYNAMIC_CHR = + BluetoothUuid.fromShort(0x2A57); + + private static final class RecordingAppSink + implements SimulatedBluetoothStack.AppServerSink { + final List connected = new ArrayList<>(); + final List disconnected = new ArrayList<>(); + final List subscriptionEvents = new ArrayList<>(); + final List readRequests = + new ArrayList<>(); + final List writeRequests = + new ArrayList<>(); + + public void centralConnected(String centralAddress) { + connected.add(centralAddress); + } + + public void centralDisconnected(String centralAddress) { + disconnected.add(centralAddress); + } + + public void subscriptionChanged(String centralAddress, + GattLocalCharacteristic characteristic, boolean subscribed) { + subscriptionEvents.add(centralAddress + " " + + characteristic.getUuid() + " " + subscribed); + } + + public void characteristicReadRequest( + SimulatedBluetoothStack.AppReadRequest request) { + readRequests.add(request); + } + + public void characteristicWriteRequest( + SimulatedBluetoothStack.AppWriteRequest request) { + writeRequests.add(request); + } + } + + private RecordingAppSink appSink; + private GattLocalCharacteristic staticChr; + private GattLocalCharacteristic dynamicChr; + + @BeforeEach + void publishAppServer() { + appSink = new RecordingAppSink(); + staticChr = new GattLocalCharacteristic(STATIC_CHR, + GattCharacteristic.PROPERTY_READ, + GattLocalCharacteristic.PERMISSION_READ) + .setValue(new byte[] {42}); + dynamicChr = new GattLocalCharacteristic(DYNAMIC_CHR, + GattCharacteristic.PROPERTY_READ + | GattCharacteristic.PROPERTY_WRITE + | GattCharacteristic.PROPERTY_NOTIFY, + GattLocalCharacteristic.PERMISSION_READ + | GattLocalCharacteristic.PERMISSION_WRITE); + Result open = new Result<>(); + stack.openAppGattServer(appSink, open); + Result add = new Result<>(); + stack.addAppService(new GattLocalService(SVC) + .addCharacteristic(staticChr) + .addCharacteristic(dynamicChr), add); + settle(); + open.assertSuccess(); + add.assertSuccess(); + } + + @Test + public void advertisingStateIsTracked() { + Assertions.assertFalse(stack.isAdvertising()); + Result start = new Result<>(); + stack.startAppAdvertising("test-payload", start); + settle(); + start.assertSuccess(); + Assertions.assertTrue(stack.isAdvertising()); + Assertions.assertTrue(stack.isAppAdvertising(start.value)); + + stack.stopAppAdvertising(start.value); + settle(); + Assertions.assertFalse(stack.isAdvertising()); + } + + @Test + public void centralConnectionAndDisconnectionEventsFire() { + VirtualCentral central = stack.connectVirtualCentral(); + settle(); + Assertions.assertEquals(1, appSink.connected.size()); + Assertions.assertEquals(central.getAddress(), + appSink.connected.get(0)); + Assertions.assertEquals(1, + stack.getConnectedCentralAddresses().size()); + + central.disconnect(); + settle(); + Assertions.assertEquals(1, appSink.disconnected.size()); + Assertions.assertTrue( + stack.getConnectedCentralAddresses().isEmpty()); + } + + @Test + public void staticValueReadsResolveWithoutTheAppListener() { + VirtualCentral central = stack.connectVirtualCentral(); + Result read = new Result<>(); + central.readCharacteristic(SVC, STATIC_CHR, read); + settle(); + read.assertSuccess(); + Assertions.assertArrayEquals(new byte[] {42}, read.value); + Assertions.assertTrue(appSink.readRequests.isEmpty()); + } + + @Test + public void dynamicReadsRouteThroughTheAppAndRespond() { + VirtualCentral central = stack.connectVirtualCentral(); + Result read = new Result<>(); + central.readCharacteristic(SVC, DYNAMIC_CHR, read); + settle(); + Assertions.assertEquals(1, appSink.readRequests.size()); + Assertions.assertEquals(0, read.successCount); + + appSink.readRequests.get(0).respond(new byte[] {7, 7}); + settle(); + read.assertSuccess(); + Assertions.assertArrayEquals(new byte[] {7, 7}, read.value); + } + + @Test + public void rejectedReadsSurfaceAsGattErrors() { + VirtualCentral central = stack.connectVirtualCentral(); + Result read = new Result<>(); + central.readCharacteristic(SVC, DYNAMIC_CHR, read); + settle(); + appSink.readRequests.get(0).reject(GattStatus.READ_NOT_PERMITTED); + settle(); + read.assertFailure(BluetoothError.GATT_ERROR); + } + + @Test + public void writesRouteThroughTheAppAndAcknowledge() { + VirtualCentral central = stack.connectVirtualCentral(); + Result write = new Result<>(); + central.writeCharacteristic(SVC, DYNAMIC_CHR, new byte[] {1, 2}, + write); + settle(); + Assertions.assertEquals(1, appSink.writeRequests.size()); + SimulatedBluetoothStack.AppWriteRequest request = + appSink.writeRequests.get(0); + Assertions.assertArrayEquals(new byte[] {1, 2}, request.getValue()); + Assertions.assertEquals(central.getAddress(), + request.getCentralAddress()); + + request.respond(); + settle(); + write.assertSuccess(); + } + + @Test + public void subscriptionsDeliverNotifyValuePushes() { + VirtualCentral central = stack.connectVirtualCentral(); + List received = new ArrayList<>(); + Result subscribe = new Result<>(); + central.subscribe(SVC, DYNAMIC_CHR, + (svc, chr, value) -> received.add(value), subscribe); + settle(); + subscribe.assertSuccess(); + Assertions.assertEquals(1, appSink.subscriptionEvents.size()); + Assertions.assertEquals(central.getAddress() + " " + DYNAMIC_CHR + + " true", appSink.subscriptionEvents.get(0)); + + Result notify = new Result<>(); + stack.notifyAppValue(dynamicChr, new byte[] {3, 2, 1}, null, notify); + settle(); + notify.assertSuccess(); + Assertions.assertEquals(1, received.size()); + Assertions.assertArrayEquals(new byte[] {3, 2, 1}, received.get(0)); + + Result unsubscribe = new Result<>(); + central.unsubscribe(SVC, DYNAMIC_CHR, unsubscribe); + settle(); + unsubscribe.assertSuccess(); + Assertions.assertEquals(2, appSink.subscriptionEvents.size()); + + stack.notifyAppValue(dynamicChr, new byte[] {9}, null, null); + settle(); + Assertions.assertEquals(1, received.size()); + } + + @Test + public void notifyValueTargetsASingleCentralWhenAddressed() { + VirtualCentral first = stack.connectVirtualCentral(); + VirtualCentral second = stack.connectVirtualCentral(); + List firstReceived = new ArrayList<>(); + List secondReceived = new ArrayList<>(); + first.subscribe(SVC, DYNAMIC_CHR, + (svc, chr, value) -> firstReceived.add(value), null); + second.subscribe(SVC, DYNAMIC_CHR, + (svc, chr, value) -> secondReceived.add(value), null); + settle(); + + stack.notifyAppValue(dynamicChr, new byte[] {1}, + second.getAddress(), null); + settle(); + Assertions.assertTrue(firstReceived.isEmpty()); + Assertions.assertEquals(1, secondReceived.size()); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualRfcommStreamTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualRfcommStreamTest.java new file mode 100644 index 00000000000..acdcd456d05 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/VirtualRfcommStreamTest.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2012, 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.impl.javase.bluetooth; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothUuid; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.OutputStream; +import java.time.Duration; + +/** + * RFCOMM over the virtual stack's piped streams: the app as a client + * against a registered endpoint, the app as a server accepting a virtual + * client, and EOF propagation on close. Stream I/O uses real threads, so + * every blocking section runs under a preemptive timeout hang-guard. + */ +public class VirtualRfcommStreamTest extends AbstractVirtualStackTest { + + private static final Duration GUARD = Duration.ofSeconds(30); + + @Test + public void appAsClientEchoRoundTripIsByteExact() { + stack.addRfcommEndpoint(BluetoothUuid.SPP, echoHandler()); + Result connect = new Result<>(); + stack.connectRfcomm(BluetoothUuid.SPP, connect); + settle(); + connect.assertSuccess(); + final SimStreamChannel channel = connect.value; + + Assertions.assertTimeoutPreemptively(GUARD, () -> { + byte[] payload = new byte[512]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i * 31); + } + OutputStream out = channel.getOutputStream(); + out.write(payload); + out.flush(); + byte[] echoed = readFully(channel.getInputStream(), + payload.length); + Assertions.assertArrayEquals(payload, echoed); + }); + channel.close(); + } + + @Test + public void connectWithoutEndpointFails() { + Result connect = new Result<>(); + stack.connectRfcomm(BluetoothUuid.SPP, connect); + settle(); + connect.assertFailure(BluetoothError.IO_ERROR); + } + + @Test + public void appAsServerAcceptsVirtualClient() { + Result listen = new Result<>(); + stack.listenRfcomm(BluetoothUuid.SPP, listen); + Result accept = new Result<>(); + stack.acceptRfcomm(BluetoothUuid.SPP, accept); + settle(); + listen.assertSuccess(); + Assertions.assertEquals(0, accept.successCount, + "accept should wait until a client arrives"); + + SimStreamChannel client = + stack.connectVirtualRfcommClient(BluetoothUuid.SPP); + settle(); + accept.assertSuccess(); + final SimStreamChannel server = accept.value; + + Assertions.assertTimeoutPreemptively(GUARD, () -> { + // client -> server + client.getOutputStream().write(new byte[] {10, 20, 30}); + client.getOutputStream().flush(); + Assertions.assertArrayEquals(new byte[] {10, 20, 30}, + readFully(server.getInputStream(), 3)); + // server -> client + server.getOutputStream().write(new byte[] {77}); + server.getOutputStream().flush(); + Assertions.assertArrayEquals(new byte[] {77}, + readFully(client.getInputStream(), 1)); + }); + client.close(); + server.close(); + } + + @Test + public void virtualClientQueuedBeforeAcceptIsHandedOver() { + Result listen = new Result<>(); + stack.listenRfcomm(BluetoothUuid.SPP, listen); + settle(); + listen.assertSuccess(); + + SimStreamChannel client = + stack.connectVirtualRfcommClient(BluetoothUuid.SPP); + settle(); + + Result accept = new Result<>(); + stack.acceptRfcomm(BluetoothUuid.SPP, accept); + settle(); + accept.assertSuccess(); + + final SimStreamChannel server = accept.value; + Assertions.assertTimeoutPreemptively(GUARD, () -> { + client.getOutputStream().write(5); + client.getOutputStream().flush(); + Assertions.assertEquals(5, server.getInputStream().read()); + }); + client.close(); + server.close(); + } + + @Test + public void closePropagatesEofToThePeer() { + Result listen = new Result<>(); + stack.listenRfcomm(BluetoothUuid.SPP, listen); + Result accept = new Result<>(); + stack.acceptRfcomm(BluetoothUuid.SPP, accept); + settle(); + SimStreamChannel client = + stack.connectVirtualRfcommClient(BluetoothUuid.SPP); + settle(); + accept.assertSuccess(); + SimStreamChannel server = accept.value; + + client.close(); + Assertions.assertFalse(client.isOpen()); + Assertions.assertTimeoutPreemptively(GUARD, () -> { + InputStream in = server.getInputStream(); + Assertions.assertEquals(-1, in.read(), + "server must observe EOF after the client closed"); + }); + server.close(); + } + + @Test + public void closingTheServerFailsPendingAccepts() { + Result listen = new Result<>(); + stack.listenRfcomm(BluetoothUuid.SPP, listen); + Result accept = new Result<>(); + stack.acceptRfcomm(BluetoothUuid.SPP, accept); + settle(); + + stack.closeRfcommServer(BluetoothUuid.SPP); + settle(); + accept.assertFailure(BluetoothError.IO_ERROR); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java index 038e2284b26..66650d0ee15 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java @@ -259,11 +259,22 @@ private static void writeProps(Path tempDir, String content) throws Exception { * Classloader whose only "extra" root is the temp dir, so * {@code getResources("META-INF/codenameone/simulator-hooks.properties")} * sees exactly the fixture and the fixture class is resolvable via parent - * delegation. Avoids polluting the surrounding test classpath with a - * resource that other tests would also discover. + * delegation. The parent hides the hooks resource the JavaSE port itself + * ships (the Bluetooth menu's file lands on the test classpath via + * target/classes) -- these tests assert exact hook counts and must see + * only the fixture. */ private static ClassLoader classloaderFor(Path tempDir) throws Exception { URL url = tempDir.toUri().toURL(); - return new URLClassLoader(new URL[]{url}, SimulatorHookLoaderTest.class.getClassLoader()); + ClassLoader hidingParent = new ClassLoader(SimulatorHookLoaderTest.class.getClassLoader()) { + @Override + public java.util.Enumeration getResources(String name) throws java.io.IOException { + if ("META-INF/codenameone/simulator-hooks.properties".equals(name)) { + return java.util.Collections.emptyEnumeration(); + } + return super.getResources(name); + } + }; + return new URLClassLoader(new URL[]{url}, hidingParent); } } diff --git a/maven/javase/src/test/resources/bluetooth-fixtures/ambient-scan-2.json b/maven/javase/src/test/resources/bluetooth-fixtures/ambient-scan-2.json new file mode 100644 index 00000000000..ebe1c24745c --- /dev/null +++ b/maven/javase/src/test/resources/bluetooth-fixtures/ambient-scan-2.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "platform": "Mac OS X 26.3.1 (cn1-ble-helper)", + "devices": [ + {"id": "SC:RA:MB:D6:7C:F9", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 103, "rssi": -127}], + "serviceUuids": [], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:D5:45:FC", + "name": "Device-A739", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 357, "rssi": -80}, {"relTimeMs": 649, "rssi": -80}, {"relTimeMs": 1890, "rssi": -79}, {"relTimeMs": 9346, "rssi": -86}, {"relTimeMs": 9956, "rssi": -80}, {"relTimeMs": 10560, "rssi": -83}, {"relTimeMs": 11489, "rssi": -79}], + "serviceUuids": [], + "manufacturerData": {"117": "ZyqWc+hzQFFVqiqd4RLJHZse7dSGx2r/"}, + "serviceData": {}}, + {"id": "SC:RA:MB:31:7D:FE", + "name": "Device-9F4E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1239, "rssi": -94}, {"relTimeMs": 4262, "rssi": -93}], + "serviceUuids": ["00001812-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb"], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:8E:72:27", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1538, "rssi": -88}], + "serviceUuids": [], + "manufacturerData": {"117": "mhQ4LGiZ/GJMmn5l0V9k0rqXgANoEwl/"}, + "serviceData": {}}, + {"id": "SC:RA:MB:7F:B8:BD", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 2496, "rssi": -76}], + "serviceUuids": [], + "manufacturerData": {"76": "4KE39IvA1g=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:E4:FC:29", + "name": "Device-C3F2", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 3338, "rssi": -90}, {"relTimeMs": 3643, "rssi": -90}, {"relTimeMs": 3948, "rssi": -95}, {"relTimeMs": 4259, "rssi": -90}], + "serviceUuids": ["00001812-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb"], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:FD:99:DC", + "name": "Dev-10B9", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 6055, "rssi": -92}, {"relTimeMs": 6055, "rssi": -92}, {"relTimeMs": 9647, "rssi": -93}], + "serviceUuids": [], + "manufacturerData": {"76": "0dBKwsJ0/g=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:6F:45:0E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 6062, "rssi": -91}], + "serviceUuids": [], + "manufacturerData": {"76": "LBDQbQ=="}, + "serviceData": {}} + ] +} diff --git a/maven/javase/src/test/resources/bluetooth-fixtures/ambient-scan.json b/maven/javase/src/test/resources/bluetooth-fixtures/ambient-scan.json new file mode 100644 index 00000000000..6c71e5e1e42 --- /dev/null +++ b/maven/javase/src/test/resources/bluetooth-fixtures/ambient-scan.json @@ -0,0 +1,112 @@ +{ + "version": 1, + "platform": "Mac OS X 26.3.1 (cn1-ble-helper)", + "devices": [ + {"id": "SC:RA:MB:8E:72:27", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 355, "rssi": -94}], + "serviceUuids": [], + "manufacturerData": {"117": "mhQ4LGiZ/GJMmn5l0V9k0rqXgANoEwl/"}, + "serviceData": {}}, + {"id": "SC:RA:MB:7F:B8:BD", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 356, "rssi": -77}], + "serviceUuids": [], + "manufacturerData": {"76": "4KE39IvA1g=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:FD:99:DC", + "name": "Dev-10B9", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 653, "rssi": -95}, {"relTimeMs": 668, "rssi": -95}, {"relTimeMs": 918, "rssi": -95}, {"relTimeMs": 1469, "rssi": -96}, {"relTimeMs": 1469, "rssi": -96}, {"relTimeMs": 2025, "rssi": -94}, {"relTimeMs": 2025, "rssi": -94}, {"relTimeMs": 2300, "rssi": -95}, {"relTimeMs": 2851, "rssi": -99}, {"relTimeMs": 2852, "rssi": -99}, {"relTimeMs": 3670, "rssi": -97}, {"relTimeMs": 3672, "rssi": -97}, {"relTimeMs": 3940, "rssi": -97}, {"relTimeMs": 12747, "rssi": -98}], + "serviceUuids": [], + "manufacturerData": {"76": "0dBKwsJ0/g=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:1B:99:E5", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 886, "rssi": -96}], + "serviceUuids": ["00001802-0000-1000-8000-00805f9b34fb"], + "manufacturerData": {}, + "serviceData": {"00001802-0000-1000-8000-00805f9b34fb": "nLhoB5wI"}}, + {"id": "SC:RA:MB:46:D6:A2", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 954, "rssi": -85}], + "serviceUuids": [], + "manufacturerData": {"76": "N9nR0mWIYIUPTHlxSv139mqPrbU="}, + "serviceData": {}}, + {"id": "SC:RA:MB:D6:7C:F9", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1012, "rssi": -75}], + "serviceUuids": [], + "manufacturerData": {}, + "serviceData": {"0000fef3-0000-1000-8000-00805f9b34fb": "58Tiv41Qlz//OD7dbH97Pmygk6w22tx1hlDK"}}, + {"id": "SC:RA:MB:31:7D:FE", + "name": "Device-9F4E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1207, "rssi": -93}, {"relTimeMs": 1207, "rssi": -93}, {"relTimeMs": 1817, "rssi": -93}, {"relTimeMs": 2119, "rssi": -91}, {"relTimeMs": 2728, "rssi": -94}, {"relTimeMs": 4248, "rssi": -94}, {"relTimeMs": 8839, "rssi": -94}, {"relTimeMs": 9746, "rssi": -93}, {"relTimeMs": 12481, "rssi": -93}], + "serviceUuids": ["00001812-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb"], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:D5:45:FC", + "name": "Device-A739", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1240, "rssi": -79}, {"relTimeMs": 2027, "rssi": -79}, {"relTimeMs": 2313, "rssi": -79}, {"relTimeMs": 2786, "rssi": -81}, {"relTimeMs": 3399, "rssi": -79}, {"relTimeMs": 3572, "rssi": -82}, {"relTimeMs": 4182, "rssi": -83}, {"relTimeMs": 4335, "rssi": -81}, {"relTimeMs": 5137, "rssi": -83}, {"relTimeMs": 5905, "rssi": -81}, {"relTimeMs": 7293, "rssi": -81}, {"relTimeMs": 7919, "rssi": -85}, {"relTimeMs": 9148, "rssi": -79}, {"relTimeMs": 9480, "rssi": -79}, {"relTimeMs": 12437, "rssi": -79}, {"relTimeMs": 12740, "rssi": -79}], + "serviceUuids": [], + "manufacturerData": {"117": "ZyqWc+hzQFFVqiqd4RLJHZse7dSGx2r/"}, + "serviceData": {}}, + {"id": "SC:RA:MB:1E:2A:37", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1371, "rssi": -90}], + "serviceUuids": [], + "manufacturerData": {"76": "rErvmN1SaOA="}, + "serviceData": {}}, + {"id": "SC:RA:MB:91:D0:9E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1516, "rssi": -98}], + "serviceUuids": [], + "manufacturerData": {"76": "4yOWPA=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:B9:91:23", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1641, "rssi": -86}], + "serviceUuids": [], + "manufacturerData": {"76": "C5iFLOjkpLM="}, + "serviceData": {}}, + {"id": "SC:RA:MB:E4:FC:29", + "name": "Device-C3F2", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 1781, "rssi": -88}, {"relTimeMs": 1783, "rssi": -88}, {"relTimeMs": 3310, "rssi": -88}, {"relTimeMs": 3616, "rssi": -93}, {"relTimeMs": 3911, "rssi": -91}, {"relTimeMs": 5444, "rssi": -93}, {"relTimeMs": 6053, "rssi": -93}, {"relTimeMs": 10623, "rssi": -91}, {"relTimeMs": 10927, "rssi": -90}, {"relTimeMs": 11235, "rssi": -93}], + "serviceUuids": ["00001812-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb"], + "manufacturerData": {}, + "serviceData": {}}, + {"id": "SC:RA:MB:6F:45:0E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 2720, "rssi": -79}], + "serviceUuids": [], + "manufacturerData": {"76": "LBDQbQ=="}, + "serviceData": {}}, + {"id": "SC:RA:MB:26:0C:C9", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 3431, "rssi": -98}], + "serviceUuids": [], + "manufacturerData": {}, + "serviceData": {"0000fcf1-0000-1000-8000-00805f9b34fb": "V7FhVvA9TEA/eIYn+gfTGIu4xf/x"}}, + {"id": "SC:RA:MB:87:30:71", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 4500, "rssi": -95}], + "serviceUuids": [], + "manufacturerData": {"76": "PMf9n31ofxQLZVPtz9oq3jHFyOy+Sp0="}, + "serviceData": {}}, + {"id": "SC:RA:MB:8A:72:4E", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 5492, "rssi": -87}], + "serviceUuids": [], + "manufacturerData": {"76": "OyAmTdAof24="}, + "serviceData": {}}, + {"id": "SC:RA:MB:A4:26:49", + "connectable": true, + "rssiTimeline": [{"relTimeMs": 5905, "rssi": -88}], + "serviceUuids": [], + "manufacturerData": {"76": "RtiSKiUe+Gh/pFMOyeAo/esViDU="}, + "serviceData": {}} + ] +} diff --git a/scripts/bluetooth/capture-fixture.sh b/scripts/bluetooth/capture-fixture.sh new file mode 100755 index 00000000000..629a2d8e704 --- /dev/null +++ b/scripts/bluetooth/capture-fixture.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# capture-fixture.sh -- record a scrambled Bluetooth fixture from THIS +# machine's real radio via the cn1-ble-helper subprocess, for replay in +# the JavaSE simulator's virtual Bluetooth stack. +# +# Usage: +# scripts/bluetooth/capture-fixture.sh --out fixture.json \ +# [--seconds 12] [--seed 42] [--gatt
...] \ +# [--gatt-strongest] [--helper /path/to/cn1-ble-helper] +# +# All arguments are forwarded to +# com.codename1.impl.javase.bluetooth.FixtureCaptureMain (see its javadoc). +# The written JSON is ALWAYS scrambled (FixtureScrambler, deterministic by +# --seed) and verified leak-free before it is written; commit-ready +# fixtures live in maven/javase/src/test/resources/bluetooth-fixtures/. +# +# Prerequisites: +# * the javase module compiled: cd maven && mvn install -pl javase \ +# -Plocal-dev-javase -DskipTests (or any full build) +# * a cn1-ble-helper binary; by default the local cargo build at +# Ports/JavaSE/native/cn1-ble-helper/target/release/cn1-ble-helper +# is used (build it with: cargo build --release). Pass --helper to +# override, or export CN1_BLE_HELPER. +# * OS Bluetooth permission for the terminal (macOS: System Settings > +# Privacy & Security > Bluetooth). +# +# Equivalent mvn incantation (no compiled target/classes needed): +# cd maven && mvn -q -pl javase -Plocal-dev-javase compile \ +# org.codehaus.mojo:exec-maven-plugin:3.1.0:java \ +# -Dexec.mainClass=com.codename1.impl.javase.bluetooth.FixtureCaptureMain \ +# -Dexec.args="--out /tmp/fixture.json --seconds 12 --seed 42" +# +set -euo pipefail + +CN1_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CLASSES="$CN1_ROOT/maven/javase/target/classes" +HELPER="${CN1_BLE_HELPER:-$CN1_ROOT/Ports/JavaSE/native/cn1-ble-helper/target/release/cn1-ble-helper}" + +if [ ! -d "$CLASSES" ]; then + echo "error: $CLASSES not found -- build first:" >&2 + echo " cd maven && mvn install -pl javase -Plocal-dev-javase -DskipTests" >&2 + exit 1 +fi + +# the bluetooth core API: prefer the in-repo build output (always in sync +# with the sources), fall back to the newest installed jar +CORE_CP="$CN1_ROOT/maven/core/target/classes" +if [ ! -d "$CORE_CP" ]; then + CORE_CP="$(ls -t "$HOME"/.m2/repository/com/codenameone/codenameone-core/*/codenameone-core-*.jar 2>/dev/null | grep -v -- '-sources\|-javadoc' | head -1 || true)" +fi +if [ -z "$CORE_CP" ]; then + echo "error: codenameone-core not built -- build first:" >&2 + echo " cd maven && mvn install -pl core -DskipTests" >&2 + exit 1 +fi + +HELPER_ARGS=() +if [ -f "$HELPER" ]; then + HELPER_ARGS=(-Dcn1.bluetooth.helperPath="$HELPER") +fi + +exec java -cp "$CLASSES:$CORE_CP" "${HELPER_ARGS[@]}" \ + com.codename1.impl.javase.bluetooth.FixtureCaptureMain "$@" diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index 9bec3786455..c8cc57aab70 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -85,7 +85,11 @@ validate_exclusions() { header_kind() { local file="$1" local header_file="$2" - sed -n '1,40p' "$file" > "$header_file" + # Strip CR so CRLF files are judged on their header text alone: a stray + # carriage return would otherwise defeat both the leading "/*" comparison + # and the "$"-anchored copyright-line match, failing files whose header is + # in fact complete and correct. + sed -n '1,40p' "$file" | tr -d '\r' > "$header_file" if [[ "$(awk 'NF { print; exit }' "$header_file")" != '/*' ]]; then return 1 diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index 769d8e79725..c4d307153a3 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -2166,6 +2166,590 @@ return doc.documentElement.getAttribute('data-cn1-app-version'); }); + // ======================== Web Bluetooth ======================== + // Main-thread backend for the JS port's Bluetooth API + // (com.codename1.impl.html5.JSBluetooth). navigator.bluetooth is a + // Window-only API, so the worker natives route here; this side owns the + // BluetoothDevice / GATT object handles (the worker only ever sees the + // per-origin device.id string and small integer attribute handle ids + // assigned during discovery). Handlers never reject: every failure + // resolves to {ok:0, code:, message} so the worker + // can surface typed errors without string matching. + + var btDevices = {}; // deviceId -> {id, device, chars, descs, nextIid, notifyHandlers, disconnectHooked} + var btAnonDeviceSeq = 0; + var btEventCallbackId = null; + var btAvailabilityHooked = false; + + function btBluetooth() { + var nav = global.navigator || (global.window && global.window.navigator); + return nav && nav.bluetooth ? nav.bluetooth : null; + } + + function btErr(code, message) { + return { ok: 0, code: String(code || 'UNKNOWN'), message: message == null ? '' : String(message) }; + } + + // Streams host-initiated events (adapter state, disconnects, + // notifications) back to the worker through the standard + // worker-callback channel; the payload is structured-clone friendly + // (plain fields + a COPIED Uint8Array for notification values). + function btPostEvent(payload) { + var target = global.__parparWorker; + if (btEventCallbackId == null || !target || typeof target.postMessage !== 'function') { + return; + } + try { + target.postMessage({ type: 'worker-callback', callbackId: btEventCallbackId, args: [payload] }); + } catch (_e) {} + } + + // Maps DOMException names onto core BluetoothError names. context is + // 'chooser' | 'connect' | 'gatt' -- the same DOMException means + // different things per API (NotFoundError from requestDevice is the + // user dismissing the chooser; from a GATT call it's a missing + // service/characteristic). + function btMapDomError(err, context) { + var name = err && err.name ? String(err.name) : ''; + var message = String((err && err.message) || ''); + if (name === 'NotFoundError') { + return context === 'chooser' ? 'USER_CANCELED' : 'GATT_ERROR'; + } + if (name === 'SecurityError') { + return 'UNAUTHORIZED'; + } + if (name === 'NotSupportedError') { + return 'NOT_SUPPORTED'; + } + if (name === 'InvalidStateError') { + return 'NOT_CONNECTED'; + } + if (name === 'NetworkError') { + if (context === 'connect') { + return 'CONNECTION_FAILED'; + } + return /disconnect/i.test(message) ? 'NOT_CONNECTED' : 'IO_ERROR'; + } + if (name === 'AbortError') { + return 'USER_CANCELED'; + } + if (name === 'TypeError') { + return context === 'chooser' ? 'SCAN_FAILED' : 'UNKNOWN'; + } + return 'UNKNOWN'; + } + + function btIsGestureError(err) { + return !!(err && err.name === 'SecurityError' + && /user (gesture|activation)/i.test(String(err.message || ''))); + } + + function btStoreDevice(device) { + var id = device.id != null && String(device.id).length + ? String(device.id) : ('cn1-bt-' + (++btAnonDeviceSeq)); + var entry = btDevices[id]; + if (!entry) { + entry = { id: id, device: device, chars: {}, descs: {}, nextIid: 1, notifyHandlers: {}, disconnectHooked: false }; + btDevices[id] = entry; + } else { + entry.device = device; + } + return entry; + } + + function btEntry(request) { + var id = request && request.id != null ? String(request.id) : null; + return id ? btDevices[id] : null; + } + + function btDataViewToBase64(dv) { + if (!dv || !dv.byteLength) { + return ''; + } + var bytes = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength); + var binary = ''; + for (var i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + var b64 = global.btoa || (global.window && global.window.btoa); + return typeof b64 === 'function' ? b64(binary) : ''; + } + + function btToUint8(value) { + if (value == null) { + return null; + } + if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) { + return value; + } + if (typeof value.length === 'number') { + var out = new Uint8Array(value.length | 0); + for (var i = 0; i < out.length; i++) { + out[i] = value[i] & 0xff; + } + return out; + } + return null; + } + + // requestDevice options from the worker's pre-built dictionary. The + // manufacturerData dataPrefix/mask arrive as plain number arrays (the + // only Web Bluetooth option field that needs a typed-array re-wrap). + function btBuildRequestOptions(request) { + var r = request || {}; + var options = {}; + var built = []; + var filters = r.filters || []; + for (var i = 0; i < filters.length; i++) { + var f = filters[i] || {}; + var o = {}; + if (f.services && f.services.length) { + var services = []; + for (var j = 0; j < f.services.length; j++) { + services.push(String(f.services[j])); + } + o.services = services; + } + if (f.name != null) { + o.name = String(f.name); + } + if (f.namePrefix != null) { + o.namePrefix = String(f.namePrefix); + } + if (f.manufacturerData && f.manufacturerData.length) { + var mans = []; + for (var k = 0; k < f.manufacturerData.length; k++) { + var m = f.manufacturerData[k] || {}; + var entry = { companyIdentifier: m.companyIdentifier | 0 }; + var prefix = btToUint8(m.dataPrefix); + if (prefix) { + entry.dataPrefix = prefix; + } + var mask = btToUint8(m.mask); + if (mask) { + entry.mask = mask; + } + mans.push(entry); + } + o.manufacturerData = mans; + } + var hasCriteria = false; + for (var key in o) { + if (Object.prototype.hasOwnProperty.call(o, key)) { + hasCriteria = true; + break; + } + } + if (hasCriteria) { + built.push(o); + } + } + if (built.length) { + options.filters = built; + } else { + options.acceptAllDevices = true; + } + if (r.optionalServices && r.optionalServices.length) { + var opt = []; + for (var s = 0; s < r.optionalServices.length; s++) { + opt.push(String(r.optionalServices[s])); + } + options.optionalServices = opt; + } + return options; + } + + // ---- user-gesture relay ------------------------------------------- + // requestDevice must run inside a user gesture. When the forwarded + // click's transient activation already expired by the time the worker + // round-trip lands here, the attempt is parked and re-fired from the + // next REAL user gesture (mirrors the pending-handler idea of + // __cn1_register_save_blob__ / __cn1_fire_save_blob__, but hooks actual + // DOM gestures because a chooser -- unlike a download click -- can only + // open inside genuine user activation). A parked attempt that sees no + // gesture within 30s resolves as a typed USER_CANCELED, never a hang. + var btPendingGestureJobs = []; + var btGestureRelayInstalled = false; + + function btInstallGestureRelay() { + if (btGestureRelayInstalled) { + return; + } + var doc = global.document || (global.window && global.window.document); + if (!doc || typeof doc.addEventListener !== 'function') { + return; + } + btGestureRelayInstalled = true; + var run = function() { + if (!btPendingGestureJobs.length) { + return; + } + var jobs = btPendingGestureJobs; + btPendingGestureJobs = []; + for (var i = 0; i < jobs.length; i++) { + try { jobs[i](); } catch (_e) {} + } + }; + var types = ['pointerup', 'touchend', 'mouseup', 'keyup']; + for (var i = 0; i < types.length; i++) { + doc.addEventListener(types[i], run, true); + } + } + + hostBridge.register('__cn1_bt_support__', function() { + var bt = btBluetooth(); + return { ok: 1, supported: bt && typeof bt.requestDevice === 'function' ? 1 : 0 }; + }); + + hostBridge.register('__cn1_bt_adapter_state__', function() { + var bt = btBluetooth(); + if (!bt) { + return { ok: 1, state: 'UNSUPPORTED' }; + } + if (typeof bt.getAvailability !== 'function') { + return { ok: 1, state: 'UNKNOWN' }; + } + return bt.getAvailability().then(function(available) { + return { ok: 1, state: available ? 'POWERED_ON' : 'POWERED_OFF' }; + }, function() { + return { ok: 1, state: 'UNKNOWN' }; + }); + }); + + hostBridge.register('__cn1_bt_set_event_callback__', function(cb) { + // hostBridge.invoke passes args raw, so the worker's callback arrives + // as a {__cn1WorkerCallback: id} token (or as an already-materialised + // proxy carrying __cn1WorkerCallbackId). + if (cb && typeof cb.__cn1WorkerCallback === 'number') { + btEventCallbackId = cb.__cn1WorkerCallback; + } else if (typeof cb === 'function' && typeof cb.__cn1WorkerCallbackId === 'number') { + btEventCallbackId = cb.__cn1WorkerCallbackId; + } else { + return btErr('UNKNOWN', 'No callback token supplied'); + } + if (!btAvailabilityHooked) { + var bt = btBluetooth(); + if (bt && typeof bt.addEventListener === 'function') { + btAvailabilityHooked = true; + try { + bt.addEventListener('availabilitychanged', function(evt) { + btPostEvent({ kind: 'adapter', detail: (evt && evt.value) ? 'POWERED_ON' : 'POWERED_OFF' }); + }); + } catch (_e) { + btAvailabilityHooked = false; + } + } + } + return { ok: 1 }; + }); + + hostBridge.register('__cn1_bt_request_device__', function(request) { + var bt = btBluetooth(); + if (!bt || typeof bt.requestDevice !== 'function') { + return btErr('NOT_SUPPORTED', 'Web Bluetooth is not available in this browser/context (requires Chromium + HTTPS)'); + } + var options = btBuildRequestOptions(request); + return new Promise(function(resolve) { + var settled = false; + var deferred = false; + function finish(value) { + if (!settled) { + settled = true; + resolve(value); + } + } + function attempt() { + if (settled) { + return; + } + var p; + try { + p = bt.requestDevice(options); + } catch (e) { + onFailure(e); + return; + } + p.then(function(device) { + var entry = btStoreDevice(device); + finish({ ok: 1, id: entry.id, name: device.name == null ? null : String(device.name) }); + }, onFailure); + } + function onFailure(err) { + if (!deferred && btIsGestureError(err)) { + // transient activation of the forwarded click already expired: + // park the attempt for the next real gesture, bounded by 30s + deferred = true; + btPendingGestureJobs.push(attempt); + btInstallGestureRelay(); + setTimeout(function() { + var idx = btPendingGestureJobs.indexOf(attempt); + if (idx >= 0) { + btPendingGestureJobs.splice(idx, 1); + } + finish(btErr('USER_CANCELED', 'requestDevice needs a user gesture and none arrived within 30s')); + }, 30000); + return; + } + finish(btErr(btMapDomError(err, 'chooser'), err && err.message)); + } + attempt(); + }); + }); + + hostBridge.register('__cn1_bt_connect__', function(request) { + var entry = btEntry(request); + if (!entry) { + return btErr('UNKNOWN', 'Unknown Bluetooth device handle -- run the chooser first'); + } + var device = entry.device; + if (!device.gatt) { + return btErr('NOT_SUPPORTED', 'The selected device exposes no GATT server'); + } + if (!entry.disconnectHooked) { + entry.disconnectHooked = true; + try { + device.addEventListener('gattserverdisconnected', function() { + btPostEvent({ kind: 'disconnect', deviceId: entry.id }); + }); + } catch (_e) { + entry.disconnectHooked = false; + } + } + return device.gatt.connect().then(function() { + return { ok: 1 }; + }, function(err) { + return btErr(btMapDomError(err, 'connect'), err && err.message); + }); + }); + + hostBridge.register('__cn1_bt_disconnect__', function(request) { + var entry = btEntry(request); + if (!entry) { + return btErr('UNKNOWN', 'Unknown Bluetooth device handle'); + } + try { + if (entry.device.gatt && entry.device.gatt.connected) { + entry.device.gatt.disconnect(); + } + } catch (_e) {} + return { ok: 1 }; + }); + + // One-shot full GATT database dump: services -> characteristics -> + // descriptors in a single host call (bridge round-trips are expensive; + // the worker gets the whole tree as one JSON payload). Also (re)builds + // the per-device attribute handle tables the read/write/subscribe + // handlers resolve against. + hostBridge.register('__cn1_bt_discover__', function(request) { + var entry = btEntry(request); + if (!entry) { + return btErr('UNKNOWN', 'Unknown Bluetooth device handle'); + } + var gatt = entry.device.gatt; + if (!gatt || !gatt.connected) { + return btErr('NOT_CONNECTED', 'GATT server is not connected'); + } + entry.chars = {}; + entry.descs = {}; + entry.nextIid = 1; + entry.notifyHandlers = {}; + + function collectCharacteristic(ch, cRecs) { + var iid = entry.nextIid++; + entry.chars[iid] = ch; + var props = ch.properties || {}; + var mask = 0; + if (props.broadcast) { mask |= 0x01; } + if (props.read) { mask |= 0x02; } + if (props.writeWithoutResponse) { mask |= 0x04; } + if (props.write) { mask |= 0x08; } + if (props.notify) { mask |= 0x10; } + if (props.indicate) { mask |= 0x20; } + if (props.authenticatedSignedWrites) { mask |= 0x40; } + var cRec = { uuid: String(ch.uuid), iid: iid, properties: mask, descriptors: [] }; + cRecs.push(cRec); + // getDescriptors rejects with NotFoundError when there are none + return ch.getDescriptors().then(function(ds) { return ds; }, function() { return []; }) + .then(function(ds) { + for (var i = 0; i < ds.length; i++) { + var dIid = entry.nextIid++; + entry.descs[dIid] = ds[i]; + cRec.descriptors.push({ uuid: String(ds[i].uuid), iid: dIid }); + } + return null; + }); + } + + function collectService(svc, out) { + var sRec = { + uuid: String(svc.uuid), + primary: svc.isPrimary === false ? 0 : 1, + iid: entry.nextIid++, + characteristics: [] + }; + out.push(sRec); + return svc.getCharacteristics().then(function(chars) { return chars; }, function() { return []; }) + .then(function(chars) { + var chain = Promise.resolve(); + for (var i = 0; i < chars.length; i++) { + (function(ch) { + chain = chain.then(function() { + return collectCharacteristic(ch, sRec.characteristics); + }); + })(chars[i]); + } + return chain; + }); + } + + return gatt.getPrimaryServices().then(function(services) { + var out = []; + var chain = Promise.resolve(); + for (var i = 0; i < services.length; i++) { + (function(svc) { + chain = chain.then(function() { + return collectService(svc, out); + }); + })(services[i]); + } + return chain.then(function() { + return { ok: 1, services: out }; + }); + }, function(err) { + return btErr(btMapDomError(err, 'gatt'), err && err.message); + }).then(null, function(err) { + return btErr(btMapDomError(err, 'gatt'), err && err.message); + }); + }); + + function btCharacteristic(request) { + var entry = btEntry(request); + return entry ? entry.chars[request.iid | 0] : null; + } + + hostBridge.register('__cn1_bt_read_char__', function(request) { + var ch = btCharacteristic(request); + if (!ch) { + return btErr('UNKNOWN', 'Unknown characteristic handle -- re-run discoverServices()'); + } + return ch.readValue().then(function(dv) { + return { ok: 1, value: btDataViewToBase64(dv) }; + }, function(err) { + return btErr(btMapDomError(err, 'gatt'), err && err.message); + }); + }); + + hostBridge.register('__cn1_bt_write_char__', function(request) { + var ch = btCharacteristic(request); + if (!ch) { + return btErr('UNKNOWN', 'Unknown characteristic handle -- re-run discoverServices()'); + } + var data = btToUint8(request && request.value) || new Uint8Array(0); + var p; + try { + if (request && request.withResponse) { + p = typeof ch.writeValueWithResponse === 'function' + ? ch.writeValueWithResponse(data) : ch.writeValue(data); + } else { + p = typeof ch.writeValueWithoutResponse === 'function' + ? ch.writeValueWithoutResponse(data) : ch.writeValue(data); + } + } catch (e) { + return btErr(btMapDomError(e, 'gatt'), e && e.message); + } + return p.then(function() { + return { ok: 1 }; + }, function(err) { + return btErr(btMapDomError(err, 'gatt'), err && err.message); + }); + }); + + hostBridge.register('__cn1_bt_read_desc__', function(request) { + var entry = btEntry(request); + var d = entry ? entry.descs[request.iid | 0] : null; + if (!d) { + return btErr('UNKNOWN', 'Unknown descriptor handle -- re-run discoverServices()'); + } + return d.readValue().then(function(dv) { + return { ok: 1, value: btDataViewToBase64(dv) }; + }, function(err) { + return btErr(btMapDomError(err, 'gatt'), err && err.message); + }); + }); + + hostBridge.register('__cn1_bt_write_desc__', function(request) { + var entry = btEntry(request); + var d = entry ? entry.descs[request.iid | 0] : null; + if (!d) { + return btErr('UNKNOWN', 'Unknown descriptor handle -- re-run discoverServices()'); + } + var data = btToUint8(request && request.value) || new Uint8Array(0); + return d.writeValue(data).then(function() { + return { ok: 1 }; + }, function(err) { + return btErr(btMapDomError(err, 'gatt'), err && err.message); + }); + }); + + hostBridge.register('__cn1_bt_set_notify__', function(request) { + var entry = btEntry(request); + var iid = request ? (request.iid | 0) : 0; + var ch = entry ? entry.chars[iid] : null; + if (!ch) { + return btErr('UNKNOWN', 'Unknown characteristic handle -- re-run discoverServices()'); + } + if (request && request.enable) { + if (!entry.notifyHandlers[iid]) { + entry.notifyHandlers[iid] = function() { + var dv = ch.value; + if (!dv) { + return; + } + var copy; + try { + // COPY the DataView's bytes before posting -- the underlying + // buffer is reused by the UA for the next notification + copy = new Uint8Array(dv.buffer.slice(dv.byteOffset, dv.byteOffset + dv.byteLength)); + } catch (_e) { + return; + } + btPostEvent({ kind: 'notify', deviceId: entry.id, detail: String(iid), bytes: copy }); + }; + } + try { + ch.addEventListener('characteristicvaluechanged', entry.notifyHandlers[iid]); + } catch (_e) {} + return ch.startNotifications().then(function() { + return { ok: 1 }; + }, function(err) { + try { + ch.removeEventListener('characteristicvaluechanged', entry.notifyHandlers[iid]); + } catch (_e) {} + return btErr(btMapDomError(err, 'gatt'), err && err.message); + }); + } + var handler = entry.notifyHandlers[iid]; + var stop; + try { + stop = typeof ch.stopNotifications === 'function' ? ch.stopNotifications() : Promise.resolve(); + } catch (e) { + stop = Promise.resolve(); + } + return stop.then(function() { + if (handler) { + try { ch.removeEventListener('characteristicvaluechanged', handler); } catch (_e) {} + } + return { ok: 1 }; + }, function() { + // disarm is best-effort: the subscription bookkeeping on the Java + // side already dropped the listeners + if (handler) { + try { ch.removeEventListener('characteristicvaluechanged', handler); } catch (_e) {} + } + return { ok: 1 }; + }); + }); + // Create a DOM element on the MAIN thread and return its host-ref. The worker // cannot create DOM nodes (no document, and jQuery isn't loaded there), so the // jQuery/`createElement`-based @JSBody helpers (showButton_, FileChooser's