From f997f4260c5f18c5c77ce1d793453c50221cd186 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:06:38 +0300 Subject: [PATCH 01/94] Nearby devices: the portable com.codename1.nearby API, its SPI and a simulation The framework can talk to a heart-rate strap, commission a Matter light and drive a watch, but it cannot answer "how far away is that thing, and in which direction", cannot use the OS's own companion-device association flow, and cannot move a payload to the phone next to it without hand-rolling sockets. A sweep for UWB, NearbyInteraction, MultipeerConnectivity, CompanionDeviceManager and Nearby Connections across this repo returns exactly one hit, and it is a doc comment in WiFiDirect saying the whole area is out of scope. This is the portable half of fixing that: the model, the three facades, the bridge each port will implement, the wire format between them, and a real simulated implementation for the desktop and JavaScript ports. No native code and nothing in the builders, so no behaviour changes on any device -- the base CodenameOneImplementation returns no bridge and every entry point degrades to NOT_SUPPORTED. The shape follows com.codename1.home: entry points that never return null, an inert fallback rather than a null check at every call site, and a flat primitives-and-strings SPI so an Objective-C port never constructs a Java object. Four decisions worth defending. Three packages, not one. The build server decides what native machinery an app gets by scanning bytecode for package prefixes and has no way to express an exclusion, so the package boundary has to be the permission boundary. Ranging costs a framework and two privacy strings on iOS and a Jetpack dependency on Android; transport costs Play Services and the whole Bluetooth and Wi-Fi permission set. An app that only wants to know how far away its keyring tag is must not pay for the second. A session is prepared, then started. Both platforms require the two devices to swap a token over some channel they already share before any radio ranging can begin, so there is no honest one-call form. Preparing yields the local token; starting takes the peer's. A session ranges exactly one peer, which is a hard limit of Apple's NINearbyPeerConfiguration rather than a simplification. The canonical measurement is azimuth and elevation in degrees. Android reports those directly; iOS reports a unit vector, and the port derives the angles from it with atan2(x, -z) so both platforms answer the same question. There is no zero-argument getDistance, because metres read as feet is the accident HealthQuantity and TraitValue already exist to prevent. Nothing in the API requires a cast, since a failed cast does not throw under ParparVM. The transport does not cross ecosystems, and says so. Nearby Connections is Android-to-Android and MultipeerConnectivity is Apple-to-Apple. The package documentation states that plainly and names the two things that do work across the divide -- BLE L2CAP channels and Bonjour plus sockets -- because an API that looked portable and silently never found the peer would be worse than an honest limitation. The desktop, simulator and JavaScript ports get LocalNearbyBridge rather than a stub, reporting LOCAL_ONLY. Almost none of a ranging feature is about radios, and a port that answered NOT_SUPPORTED would make all of it testable only on a pair of phones. It follows the two rules a mock would not: it never completes inline, and its peers really move -- a bounded random walk seeded from the bridge's own session counter, so the Nth session of a fresh bridge walks the same path every run whatever ran before it. It will not drop a peer or suspend a session behind your back; those are controls the simulator panel drives, because a simulation that fired them unpredictably would make every test using it flaky. PendingMap moves from com.codename1.impl.home to com.codename1.impl.async and is shared rather than copied, the way EdtResult and OneShot already were. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/home/SmartHome.java | 2 +- .../impl/CodenameOneImplementation.java | 16 + .../impl/{home => async}/PendingMap.java | 3 +- .../impl/nearby/LocalNearbyBridge.java | 881 ++++++++++++++++++ .../codename1/impl/nearby/NearbyRequests.java | 128 +++ .../com/codename1/impl/nearby/NearbyWire.java | 375 ++++++++ .../impl/nearby/SyntheticNearby.java | 68 ++ .../codename1/nearby/NearbyAvailability.java | 56 ++ .../src/com/codename1/nearby/NearbyError.java | 87 ++ .../com/codename1/nearby/NearbyException.java | 52 ++ .../codename1/nearby/NearbyPermission.java | 51 + .../nearby/companion/AssociationRequest.java | 126 +++ .../nearby/companion/CompanionDevice.java | 119 +++ .../nearby/companion/CompanionDevices.java | 383 ++++++++ .../nearby/companion/CompanionProfile.java | 49 + .../nearby/companion/DeviceFilter.java | 144 +++ .../nearby/companion/PresenceListener.java | 47 + .../nearby/companion/package-info.java | 36 + .../com/codename1/nearby/package-info.java | 60 ++ .../com/codename1/nearby/ranging/Ranging.java | 414 ++++++++ .../nearby/ranging/RangingAdapter.java | 55 ++ .../nearby/ranging/RangingCapabilities.java | 116 +++ .../nearby/ranging/RangingListener.java | 67 ++ .../nearby/ranging/RangingRemovalReason.java | 38 + .../codename1/nearby/ranging/RangingRole.java | 42 + .../nearby/ranging/RangingSession.java | 432 +++++++++ .../nearby/ranging/RangingToken.java | 252 +++++ .../codename1/nearby/ranging/RangingUnit.java | 75 ++ .../nearby/ranging/RangingUpdate.java | 181 ++++ .../nearby/ranging/package-info.java | 50 + .../codename1/nearby/spi/NearbyBridge.java | 391 ++++++++ .../codename1/nearby/spi/package-info.java | 33 + .../nearby/transport/ConnectionRequest.java | 111 +++ .../codename1/nearby/transport/Endpoint.java | 85 ++ .../nearby/transport/NearbyTransport.java | 674 ++++++++++++++ .../codename1/nearby/transport/Payload.java | 136 +++ .../nearby/transport/PayloadStatus.java | 39 + .../transport/PayloadTransferUpdate.java | 80 ++ .../nearby/transport/TransportAdapter.java | 55 ++ .../nearby/transport/TransportListener.java | 95 ++ .../nearby/transport/TransportStrategy.java | 43 + .../nearby/transport/package-info.java | 42 + CodenameOne/src/com/codename1/ui/Display.java | 12 + .../com/codename1/impl/javase/JavaSEPort.java | 29 + .../impl/html5/HTML5Implementation.java | 29 + .../impl/linux/LinuxImplementation.java | 29 + .../impl/windows/WindowsImplementation.java | 29 + .../com/codename1/nearby/LocalNearbyTest.java | 729 +++++++++++++++ .../com/codename1/nearby/NearbyAwait.java | 111 +++ .../nearby/NearbyDegradationTest.java | 162 ++++ .../com/codename1/nearby/NearbyWireTest.java | 177 ++++ .../codename1/nearby/RangingTokenTest.java | 147 +++ 52 files changed, 7640 insertions(+), 3 deletions(-) rename CodenameOne/src/com/codename1/impl/{home => async}/PendingMap.java (98%) create mode 100644 CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java create mode 100644 CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java create mode 100644 CodenameOne/src/com/codename1/impl/nearby/NearbyWire.java create mode 100644 CodenameOne/src/com/codename1/impl/nearby/SyntheticNearby.java create mode 100644 CodenameOne/src/com/codename1/nearby/NearbyAvailability.java create mode 100644 CodenameOne/src/com/codename1/nearby/NearbyError.java create mode 100644 CodenameOne/src/com/codename1/nearby/NearbyException.java create mode 100644 CodenameOne/src/com/codename1/nearby/NearbyPermission.java create mode 100644 CodenameOne/src/com/codename1/nearby/companion/AssociationRequest.java create mode 100644 CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java create mode 100644 CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java create mode 100644 CodenameOne/src/com/codename1/nearby/companion/CompanionProfile.java create mode 100644 CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java create mode 100644 CodenameOne/src/com/codename1/nearby/companion/PresenceListener.java create mode 100644 CodenameOne/src/com/codename1/nearby/companion/package-info.java create mode 100644 CodenameOne/src/com/codename1/nearby/package-info.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/Ranging.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingListener.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingRemovalReason.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingRole.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java create mode 100644 CodenameOne/src/com/codename1/nearby/ranging/package-info.java create mode 100644 CodenameOne/src/com/codename1/nearby/spi/NearbyBridge.java create mode 100644 CodenameOne/src/com/codename1/nearby/spi/package-info.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/ConnectionRequest.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/Endpoint.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/Payload.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/PayloadStatus.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/TransportListener.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/TransportStrategy.java create mode 100644 CodenameOne/src/com/codename1/nearby/transport/package-info.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/nearby/NearbyAwait.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/nearby/NearbyWireTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java diff --git a/CodenameOne/src/com/codename1/home/SmartHome.java b/CodenameOne/src/com/codename1/home/SmartHome.java index 485946b04ce..76fdc6e1e3f 100644 --- a/CodenameOne/src/com/codename1/home/SmartHome.java +++ b/CodenameOne/src/com/codename1/home/SmartHome.java @@ -29,7 +29,7 @@ import com.codename1.impl.async.EdtResult; import com.codename1.impl.home.CommissioningGateway; import com.codename1.impl.home.HomeWire; -import com.codename1.impl.home.PendingMap; +import com.codename1.impl.async.PendingMap; import com.codename1.impl.home.SubscriptionState; import com.codename1.ui.Display; import com.codename1.util.AsyncResource; diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 1db52ef4163..1453207d5ad 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -6029,6 +6029,22 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { return null; } + /// Returns the bridge the `com.codename1.nearby` API uses to reach the platform's short-range + /// stacks -- precision ranging, companion-device association and the nearby transport. Ports + /// that implement any of the three override this; the base implementation returns null, which + /// makes every `com.codename1.nearby` entry point report itself unsupported and fail fast, so + /// application code needs no platform-specific branch. + /// + /// A port may implement one cluster and not the others: the bridge answers `isRangingSupported`, + /// `isCompanionSupported` and `isTransportSupported` independently. + /// + /// #### Returns + /// + /// the nearby bridge, or null when unsupported + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + return null; + } + /// Returns the platform bridge used by the `com.codename1.surfaces` API to render external /// surfaces (home-screen widgets and live activities). Ports supporting surfaces override /// this; the base implementation returns null which renders the whole API an inert no-op. diff --git a/CodenameOne/src/com/codename1/impl/home/PendingMap.java b/CodenameOne/src/com/codename1/impl/async/PendingMap.java similarity index 98% rename from CodenameOne/src/com/codename1/impl/home/PendingMap.java rename to CodenameOne/src/com/codename1/impl/async/PendingMap.java index a8cc5ec9aee..e3af4b4feef 100644 --- a/CodenameOne/src/com/codename1/impl/home/PendingMap.java +++ b/CodenameOne/src/com/codename1/impl/async/PendingMap.java @@ -20,9 +20,8 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.impl.home; +package com.codename1.impl.async; -import com.codename1.impl.async.EdtResult; import java.util.ArrayList; import java.util.HashMap; diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java new file mode 100644 index 00000000000..0cba8c225cd --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -0,0 +1,881 @@ +/* + * 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.impl.nearby; + +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.companion.CompanionDevice; +import com.codename1.nearby.companion.CompanionDevices; +import com.codename1.nearby.companion.DeviceFilter; +import com.codename1.nearby.ranging.Ranging; +import com.codename1.nearby.ranging.RangingRemovalReason; +import com.codename1.nearby.ranging.RangingSession; +import com.codename1.nearby.ranging.RangingToken; +import com.codename1.nearby.spi.NearbyBridge; +import com.codename1.nearby.transport.NearbyTransport; +import com.codename1.nearby.transport.PayloadStatus; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A working `com.codename1.nearby` implementation with no radio behind it, +/// used by the simulator, the desktop ports and the JavaScript port. +/// +/// #### Why this is a simulation and not a stub +/// +/// Almost none of the code in a ranging feature is about radios. Laying out +/// the screen, animating an arrow toward a peer, deciding what to show while +/// the direction drops out, handling the peer walking away and coming back, +/// getting the association flow right -- all of that is ordinary application +/// code, and a desktop port answering `NOT_SUPPORTED` would make every line +/// of it testable only on a phone with a second phone next to it. In +/// practice that means testable rarely. +/// +/// So this reports [NearbyAvailability#LOCAL_ONLY] rather than +/// `NOT_SUPPORTED`: everything works, and nothing outside this process can +/// see it. +/// +/// #### Two rules a mock would not follow +/// +/// **It never completes inline.** Every answer goes through [#answer], which +/// posts it a few milliseconds later. It could answer synchronously and +/// deliberately does not: code written against a transport that answers +/// instantly races the moment it meets one that does not, and that asymmetry +/// has already shipped in this codebase once -- it is why +/// `com.codename1.impl.async.EdtResult` exists. +/// +/// **Peers move, and they move smoothly.** A mock that returned a constant +/// 1.5 m would let an app ship with a distance label that flickers +/// unreadably against real hardware, or with an arrow that snaps. The drift +/// here is a bounded random walk seeded from the session handle, so it is +/// lifelike and still reproducible run to run -- a test that asserts on the +/// tenth update gets the same tenth update every time. +/// +/// **What it will not do behind your back** is drop a peer or suspend a +/// session at random. Those are real events an app must handle, but a +/// simulation that fired them unpredictably would make every test using it +/// flaky. They are controls instead: see [#dropPeer], [#suspendSession] and +/// [#resumeSession], which the simulator's Nearby panel drives. +public class LocalNearbyBridge implements NearbyBridge { + + /// How long an operation takes to answer, in milliseconds. Small, and + /// deliberately not zero. See the class note. + private static final int LATENCY_MILLIS = 4; + + /// How often a running ranging session produces a measurement. Roughly + /// what both real platforms deliver at their default update rate. + private static final int TICK_MILLIS = 120; + + private static final double MIN_DISTANCE = 0.08; + private static final double MAX_DISTANCE = 14.0; + + private final Map sessions = + new LinkedHashMap(); + private final Map associations = + new LinkedHashMap(); + private final Map observed = + new LinkedHashMap(); + private final List candidates = new ArrayList(); + private final List endpoints = new ArrayList(); + private final List connected = new ArrayList(); + + private int sessionSequence; + private boolean advertising; + private boolean discovering; + private boolean echoPayloads = true; + private int nextAssociationId = 1; + + // ------------------------------------------------------------------ + // Simulation controls + // ------------------------------------------------------------------ + + /// Adds a device the association chooser may offer. + /// + /// #### Parameters + /// + /// - `name`: the name to show + /// - `address`: the address to report + /// - `serviceUuid`: the BLE service it advertises, matched against + /// `DeviceFilter.KIND_BLE_SERVICE`, may be null + public void addCandidate(String name, String address, String serviceUuid) { + candidates.add(new Candidate(name, address, serviceUuid)); + } + + /// Adds an endpoint that discovery will find. + /// + /// #### Parameters + /// + /// - `id`: the endpoint id + /// - `name`: the name it advertises + public void addEndpoint(String id, String name) { + endpoints.add(new SimEndpoint(id, name)); + } + + /// Whether a sent payload is echoed back from the endpoint it went to. + /// + /// On by default, because a single process has no real peer and an app + /// developing its receive path otherwise has nothing to receive. Turn it + /// off in a test that counts deliveries. + /// + /// #### Parameters + /// + /// - `echo`: whether to echo + public void setEchoPayloads(boolean echo) { + this.echoPayloads = echo; + } + + /// Makes a running session report that its peer walked away. The session + /// stays alive; the peer starts being reported again on the next tick, + /// which is what real hardware does when someone steps back into range. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session to disturb + public void dropPeer(int sessionHandle) { + SimSession s = sessions.get(Integer.valueOf(sessionHandle)); + if (s != null && s.running) { + RangingSession.deliverPeerRemoved(sessionHandle, + RangingRemovalReason.TIMEOUT.ordinal()); + } + } + + /// Suspends a running session, as the platform does when an app without + /// the background entitlement leaves the foreground. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session to suspend + public void suspendSession(int sessionHandle) { + SimSession s = sessions.get(Integer.valueOf(sessionHandle)); + if (s != null && s.running && !s.suspended) { + s.suspended = true; + RangingSession.deliverSuspended(sessionHandle); + } + } + + /// Resumes a suspended session. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session to resume + public void resumeSession(int sessionHandle) { + SimSession s = sessions.get(Integer.valueOf(sessionHandle)); + if (s != null && s.suspended) { + s.suspended = false; + RangingSession.deliverResumed(sessionHandle); + tick(s); + } + } + + /// The handles of every session this bridge currently holds, so the + /// simulator panel can list them. + /// + /// #### Returns + /// + /// the handles, never null + public int[] getSessionHandles() { + int[] out = new int[sessions.size()]; + int i = 0; + for (Integer k : sessions.keySet()) { + out[i++] = k.intValue(); + } + return out; + } + + /// The last distance a session reported, in metres, or -1 when it has + /// not started. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session to inspect + /// + /// #### Returns + /// + /// the distance in metres, or -1 + public double getSimulatedDistance(int sessionHandle) { + SimSession s = sessions.get(Integer.valueOf(sessionHandle)); + return s == null || !s.running ? -1 : s.distance; + } + + /// Moves a session's peer to an exact distance, so a test or the + /// simulator panel can drive the value rather than watch it wander. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session to move + /// - `meters`: where to put the peer + public void setSimulatedDistance(int sessionHandle, double meters) { + SimSession s = sessions.get(Integer.valueOf(sessionHandle)); + if (s != null) { + s.distance = clamp(meters, MIN_DISTANCE, MAX_DISTANCE); + } + } + + // ------------------------------------------------------------------ + // Shared + // ------------------------------------------------------------------ + + public boolean isRangingSupported() { + return true; + } + + public boolean isCompanionSupported() { + return true; + } + + public boolean isTransportSupported() { + return true; + } + + public int getRangingAvailability() { + return NearbyAvailability.LOCAL_ONLY.ordinal(); + } + + public int getCompanionAvailability() { + return NearbyAvailability.LOCAL_ONLY.ordinal(); + } + + public int getTransportAvailability() { + return NearbyAvailability.LOCAL_ONLY.ordinal(); + } + + public void requestPermissions(final int requestId, int permissionBits) { + // Nothing to ask a desktop for, but the answer still has to arrive + // asynchronously: an app whose permission callback runs inline here + // and out-of-line on a device is an app with a startup race. + answer(new PermissionAnswer(requestId)); + } + + // ------------------------------------------------------------------ + // Ranging + // ------------------------------------------------------------------ + + public int getRangingCapabilities() { + return CAPABILITY_DISTANCE | CAPABILITY_DIRECTION + | CAPABILITY_ELEVATION | CAPABILITY_ACCESSORY; + } + + public void prepareRangingSession(final int requestId, + final int sessionHandle, final boolean controller) { + final SimSession s = new SimSession(sessionHandle, controller, + ++sessionSequence); + sessions.put(Integer.valueOf(sessionHandle), s); + answer(new SessionPrepared(requestId, sessionHandle, controller, s)); + } + + public void startRanging(final int requestId, final int sessionHandle, + final byte[] peerToken) { + final SimSession s = sessions.get(Integer.valueOf(sessionHandle)); + if (s == null) { + failRanging(requestId, NearbyError.SESSION_INVALIDATED, + "no such session"); + return; + } + int platform; + try { + platform = RangingToken.fromByteArray(peerToken).getPlatform(); + } catch (IllegalArgumentException e) { + failRanging(requestId, NearbyError.INVALID_TOKEN, e.getMessage()); + return; + } + if (platform != RangingToken.PLATFORM_SIMULATED) { + // Worth rejecting rather than pretending: an app that got its + // token exchange backwards should find out here, on the desktop, + // rather than on a device where the failure looks like hardware. + failRanging(requestId, NearbyError.INVALID_TOKEN, + "this token was minted by another platform"); + return; + } + answer(new Runnable() { + public void run() { + s.running = true; + Ranging.deliverSessionStarted(requestId, sessionHandle); + tick(s); + } + }); + } + + public void startAccessoryRanging(final int requestId, + final int sessionHandle, byte[] accessoryData) { + final SimSession s = sessions.get(Integer.valueOf(sessionHandle)); + if (s == null) { + failRanging(requestId, NearbyError.SESSION_INVALIDATED, + "no such session"); + return; + } + answer(new Runnable() { + public void run() { + s.running = true; + // A real accessory handshake sends configuration back; the + // shape of the exchange is what an app has to get right, so + // the simulation produces a non-empty blob rather than an + // empty one an app could forget to forward. + Ranging.deliverAccessoryConfiguration(requestId, sessionHandle, + new byte[] {'C', 'N', '1', 'A', 'C', 'C'}); + tick(s); + } + }); + } + + public void stopRangingSession(int sessionHandle) { + SimSession s = sessions.remove(Integer.valueOf(sessionHandle)); + if (s != null) { + s.running = false; + } + } + + // ------------------------------------------------------------------ + // Companion + // ------------------------------------------------------------------ + + public void associate(final int requestId, final int profile, + boolean singleDevice, final String[] filters) { + answer(new Runnable() { + public void run() { + Candidate c = firstMatch(filters); + if (c == null) { + // No candidate is the simulated equivalent of the user + // finding nothing they recognise and closing the sheet. + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.USER_CANCELED.ordinal(), + "no simulated device matched the filters"); + return; + } + String id = "sim-assoc-" + (nextAssociationId++); + CompanionDevice d = new CompanionDevice(id, c.name, c.address, + NearbyWire.profileFor(profile), true); + associations.put(id, d); + CompanionDevices.deliverAssociated(requestId, + NearbyWire.encodeCompanionDevice(d)); + } + }); + } + + public String[] getAssociations() { + String[] out = new String[associations.size()]; + int i = 0; + for (CompanionDevice d : associations.values()) { + out[i++] = NearbyWire.encodeCompanionDevice(d); + } + return out; + } + + public void disassociate(final int requestId, final String associationId) { + answer(new Runnable() { + public void run() { + observed.remove(associationId); + if (associations.remove(associationId) == null) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + "no such association"); + } else { + CompanionDevices.deliverDisassociated(requestId); + } + } + }); + } + + public boolean startObservingPresence(String associationId) { + if (!associations.containsKey(associationId)) { + return false; + } + observed.put(associationId, Boolean.TRUE); + return true; + } + + public void stopObservingPresence(String associationId) { + observed.remove(associationId); + } + + /// Reports an observed association as appearing or disappearing, which + /// on a device is the platform waking the app. + /// + /// #### Parameters + /// + /// - `associationId`: the association to move + /// - `present`: whether it is now in range + public void setPresent(String associationId, boolean present) { + CompanionDevice d = associations.get(associationId); + if (d == null || !Boolean.TRUE.equals(observed.get(associationId))) { + return; + } + CompanionDevice moved = new CompanionDevice(d.getId(), + d.getDisplayName(), d.getAddress(), d.getProfile(), present); + associations.put(associationId, moved); + CompanionDevices.deliverPresenceChanged( + NearbyWire.encodeCompanionDevice(moved), present); + } + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + public int getMaxPayloadSize() { + // What Nearby Connections allows for a BYTES payload. Matching the + // tighter of the two real limits means an app that fits here fits + // everywhere. + return 32 * 1024; + } + + public void startAdvertising(final int requestId, String serviceId, + String localName, int strategy) { + advertising = true; + answerOk(requestId); + } + + public void stopAdvertising() { + advertising = false; + } + + public void startDiscovery(final int requestId, final String serviceId, + int strategy) { + discovering = true; + answer(new Runnable() { + public void run() { + NearbyTransport.deliverRequestOk(requestId); + for (SimEndpoint e : endpoints) { + e.serviceId = serviceId; + NearbyTransport.deliverEndpointFound(e.encode(), true); + } + } + }); + } + + public void stopDiscovery() { + discovering = false; + } + + public void requestConnection(final int requestId, final String endpointId, + String localName) { + final SimEndpoint e = findEndpoint(endpointId); + if (e == null) { + answer(new TransportFailure(requestId, + NearbyError.PEER_UNAVAILABLE, "no such endpoint")); + return; + } + answer(new Runnable() { + public void run() { + NearbyTransport.deliverRequestOk(requestId); + // The simulated peer always accepts, one hop later, so the + // app sees the two-step shape the real platforms have. + answer(new Runnable() { + public void run() { + connected.add(endpointId); + NearbyTransport.deliverConnectionResult(e.encode(), + true, 0, null); + } + }); + } + }); + } + + public void acceptConnection(final int requestId, String endpointId) { + if (!connected.contains(endpointId)) { + connected.add(endpointId); + } + answerOk(requestId); + } + + public void rejectConnection(String endpointId) { + connected.remove(endpointId); + } + + public void sendPayload(final int requestId, final String[] endpointIds, + final int payloadId, final int payloadType, final byte[] bytes, + final String path) { + answer(new Runnable() { + public void run() { + NearbyTransport.deliverRequestOk(requestId); + for (int i = 0; i < endpointIds.length; i++) { + final SimEndpoint e = findEndpoint(endpointIds[i]); + if (e == null || !connected.contains(endpointIds[i])) { + continue; + } + long total = payloadType == PAYLOAD_BYTES && bytes != null + ? bytes.length : -1; + NearbyTransport.deliverPayloadProgress(e.encode(), + payloadId, total < 0 ? 0 : total, total, + PayloadStatus.SUCCESS.ordinal()); + if (echoPayloads) { + NearbyTransport.deliverPayloadReceived(e.encode(), + payloadId, payloadType, bytes, path); + } + } + } + }); + } + + public void cancelPayload(int payloadId) { + } + + public void disconnect(String endpointId) { + if (connected.remove(endpointId)) { + SimEndpoint e = findEndpoint(endpointId); + if (e != null) { + NearbyTransport.deliverDisconnected(e.encode()); + } + } + } + + public void stopAllTransport() { + advertising = false; + discovering = false; + List doomed = new ArrayList(connected); + connected.clear(); + for (String id : doomed) { + SimEndpoint e = findEndpoint(id); + if (e != null) { + NearbyTransport.deliverDisconnected(e.encode()); + } + } + } + + /// Whether [#startAdvertising] is in effect, for the simulator panel. + public boolean isAdvertising() { + return advertising; + } + + /// Whether [#startDiscovery] is in effect, for the simulator panel. + public boolean isDiscovering() { + return discovering; + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private void tick(final SimSession s) { + if (!s.running || s.suspended + || !sessions.containsKey(Integer.valueOf(s.handle))) { + return; + } + s.advance(); + boolean hasDirection = s.distance < 9.0; + RangingSession.deliverUpdate(s.handle, true, s.distance, + hasDirection, s.azimuth, hasDirection, s.elevation, + hasDirection ? s.vector() : null); + if (!Display.isInitialized()) { + // No event loop, so [#later] would run the next tick inline and + // this method would recurse until the stack ran out. One + // measurement per trigger is the honest behaviour for a unit + // test; drive more with [#resumeSession]. + return; + } + later(TICK_MILLIS, new Runnable() { + public void run() { + tick(s); + } + }); + } + + private SimEndpoint findEndpoint(String id) { + for (SimEndpoint e : endpoints) { + if (e.id.equals(id)) { + return e; + } + } + return null; + } + + private Candidate firstMatch(String[] filters) { + if (candidates.isEmpty()) { + return null; + } + if (filters == null || filters.length == 0) { + return candidates.get(0); + } + for (int i = 0; i < filters.length; i++) { + String[] f = NearbyWire.split(filters[i]); + int kind = NearbyWire.integer(f, 0, -1); + String value = NearbyWire.field(f, 1); + for (Candidate c : candidates) { + if (c.matches(kind, value)) { + return c; + } + } + } + return null; + } + + private void failRanging(int requestId, NearbyError error, + String message) { + answer(new RangingFailure(requestId, error, message)); + } + + private void answerOk(int requestId) { + answer(new TransportOk(requestId)); + } + + private void answer(Runnable delivery) { + later(LATENCY_MILLIS, delivery); + } + + private void later(int millis, Runnable delivery) { + if (Display.isInitialized()) { + Display.getInstance().setTimeout(millis, delivery); + return; + } + // No Display, so this is a unit test driving the bridge directly. + // Inline is the only option and is safe there: the EDT contract the + // delay protects is about a running application. + delivery.run(); + } + + private static double clamp(double v, double lo, double hi) { + return v < lo ? lo : (v > hi ? hi : v); + } + + // ------------------------------------------------------------------ + // model records + // ------------------------------------------------------------------ + + /// The deliveries that carry nothing but their arguments. + /// + /// Named static classes rather than anonymous ones: an anonymous class + /// holds a reference to the bridge whether or not it uses one, and these + /// sit on a timer queue where that reference keeps the whole simulated + /// world alive for as long as the delivery is pending. + private static final class PermissionAnswer implements Runnable { + private final int requestId; + + private PermissionAnswer(int requestId) { + this.requestId = requestId; + } + + public void run() { + Ranging.deliverPermissionResult(requestId, true); + } + } + + private static final class SessionPrepared implements Runnable { + private final int requestId; + private final int sessionHandle; + private final boolean controller; + private final SimSession session; + + private SessionPrepared(int requestId, int sessionHandle, + boolean controller, SimSession session) { + this.requestId = requestId; + this.sessionHandle = sessionHandle; + this.controller = controller; + this.session = session; + } + + public void run() { + Ranging.deliverSessionPrepared(requestId, sessionHandle, + controller, RangingToken.PLATFORM_SIMULATED, + session.localTokenPayload()); + } + } + + private static final class RangingFailure implements Runnable { + private final int requestId; + private final NearbyError error; + private final String message; + + private RangingFailure(int requestId, NearbyError error, + String message) { + this.requestId = requestId; + this.error = error; + this.message = message; + } + + public void run() { + Ranging.deliverRequestFailed(requestId, error.ordinal(), message); + } + } + + private static final class TransportOk implements Runnable { + private final int requestId; + + private TransportOk(int requestId) { + this.requestId = requestId; + } + + public void run() { + NearbyTransport.deliverRequestOk(requestId); + } + } + + private static final class TransportFailure implements Runnable { + private final int requestId; + private final NearbyError error; + private final String message; + + private TransportFailure(int requestId, NearbyError error, + String message) { + this.requestId = requestId; + this.error = error; + this.message = message; + } + + public void run() { + NearbyTransport.deliverRequestFailed(requestId, error.ordinal(), + message); + } + } + + private static final class SimSession { + private final int handle; + private final boolean controller; + private boolean running; + private boolean suspended; + private double distance = 2.5; + private double azimuth; + private double elevation; + private long seed; + + private SimSession(int handle, boolean controller, int sequence) { + this.handle = handle; + this.controller = controller; + // Seeded from this bridge's own session counter, NOT from the + // handle. Handles come from a process-wide counter that keeps + // climbing, so seeding on one would make the first session of a + // fresh bridge walk differently depending on what ran before it + // -- which is exactly the order-dependence a reproducible + // simulation exists to avoid. Counting per bridge means the Nth + // session of a new LocalNearbyBridge always walks the same path. + this.seed = 0x5DEECE66DL ^ (sequence * 2654435761L); + } + + private byte[] localTokenPayload() { + String s = "sim-peer-" + handle + (controller ? "-c" : "-e"); + byte[] out = new byte[s.length()]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) s.charAt(i); + } + return out; + } + + /// One step of a bounded random walk, reflecting off the ends so the + /// peer never sticks to a boundary the way a clamp would make it. + private void advance() { + distance = reflect(distance + next() * 0.22, + MIN_DISTANCE, MAX_DISTANCE); + azimuth = wrap(azimuth + next() * 7.0); + elevation = reflect(elevation + next() * 3.0, -40.0, 40.0); + } + + private float[] vector() { + double az = azimuth * Math.PI / 180.0; + double el = elevation * Math.PI / 180.0; + double cosEl = Math.cos(el); + // x right, y up, z toward the viewer: the same frame iOS uses, + // so an app reading the vector sees the same thing on both. + return new float[] { + (float) (cosEl * Math.sin(az)), + (float) Math.sin(el), + (float) (-cosEl * Math.cos(az)) + }; + } + + /// A value in -1..1 from a linear congruential generator. Not a good + /// source of randomness and not trying to be: it is deterministic, + /// dependency-free and identical on every platform, which is what a + /// reproducible simulation needs. + private double next() { + seed = (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1); + return ((int) (seed >>> 20) % 2001 - 1000) / 1000.0; + } + + private static double reflect(double v, double lo, double hi) { + if (v < lo) { + return lo + (lo - v); + } + if (v > hi) { + return hi - (v - hi); + } + return v; + } + + /// Folds an angle into -180..180. + /// + /// By remainder rather than by subtracting in a loop: a loop counted + /// on a double is both slower for a large input and a correctness + /// smell, because the step never lands exactly on the bound. + private static double wrap(double deg) { + double d = deg % 360.0; + if (d > 180.0) { + return d - 360.0; + } + if (d < -180.0) { + return d + 360.0; + } + return d; + } + } + + /// A device the chooser may offer. + /// + /// Deliberately carries no profile of its own: on both platforms the + /// association is created under the profile the *request* asked for, not + /// one the device advertises, so a profile here would be a field that + /// looked authoritative and decided nothing. + private static final class Candidate { + private final String name; + private final String address; + private final String serviceUuid; + + private Candidate(String name, String address, String serviceUuid) { + this.name = name; + this.address = address; + this.serviceUuid = serviceUuid; + } + + private boolean matches(int kind, String value) { + if (kind == DeviceFilter.KIND_BLE_SERVICE) { + return serviceUuid != null + && serviceUuid.equalsIgnoreCase(value); + } + if (kind == DeviceFilter.KIND_ADDRESS) { + return address != null && address.equalsIgnoreCase(value); + } + if (kind == DeviceFilter.KIND_NAME_PATTERN) { + // Substring rather than a regular expression: the simulation + // must not be more capable than the weakest real backend, + // which is what AccessorySetupKit gives on iOS. + return name != null && value != null + && name.toLowerCase().indexOf(value.toLowerCase()) >= 0; + } + return false; + } + } + + private static final class SimEndpoint { + private final String id; + private final String name; + private String serviceId = ""; + + private SimEndpoint(String id, String name) { + this.id = id; + this.name = name; + } + + private String encode() { + return NearbyWire.join(new String[] {id, name, serviceId}); + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java b/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java new file mode 100644 index 00000000000..e6ca758be88 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java @@ -0,0 +1,128 @@ +/* + * 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.impl.nearby; + +import com.codename1.nearby.spi.NearbyBridge; +import com.codename1.ui.Display; + +import java.util.concurrent.atomic.AtomicInteger; + +/// The bits every `com.codename1.nearby` facade needs and none of them owns: +/// the bridge lookup, one request-id counter for the whole family, and the +/// EDT hop that unsolicited native events take. +/// +/// @hidden not part of the public API. +public final class NearbyRequests { + + private static final AtomicInteger NEXT_ID = new AtomicInteger(1); + + private static NearbyBridge testBridge; + + private NearbyRequests() { + } + + /// The active port's bridge, or `null` where no port implements one. + /// + /// Guarded on `Display.isInitialized()` rather than on the instance being + /// non-null: `Display.getInstance()` hands back its singleton long before + /// `Display.init` has given it an implementation, and asking that for a + /// bridge throws. A unit test and an app that touches a facade from a + /// static initializer both reach this that way. + /// + /// #### Returns + /// + /// the bridge, or null + public static synchronized NearbyBridge bridge() { + if (testBridge != null) { + return testBridge; + } + if (!Display.isInitialized()) { + return null; + } + return Display.getInstance().getNearbyBridge(); + } + + /// Installs a bridge and clears every facade's static state, so one test + /// cannot see the sessions, listeners or in-flight requests of the test + /// that ran before it. + /// + /// The facades are static -- there is no instance for a test to throw + /// away -- which makes shared state order-dependent: a listener a + /// previous test forgot to remove fires during this one, and the failure + /// looks like a bug in whichever test happened to run second. This is the + /// same arrangement `com.codename1.home.SmartHome` uses, for the same + /// reason. + /// + /// Passing `null` gives a bridgeless framework without waiting for + /// `Display` to be absent, which is what the degradation tests need. + /// + /// @hidden not part of the public API; test-only. + /// + /// #### Parameters + /// + /// - `bridge`: the bridge to install, or null for none + public static void resetForTest(NearbyBridge bridge) { + synchronized (NearbyRequests.class) { + testBridge = bridge; + } + com.codename1.nearby.ranging.Ranging.resetForTest(); + com.codename1.nearby.ranging.RangingSession.resetForTest(); + com.codename1.nearby.companion.CompanionDevices.resetForTest(); + com.codename1.nearby.transport.NearbyTransport.resetForTest(); + } + + /// The next request id. + /// + /// Ids come from one counter shared by ranging, companion and transport + /// so that an id lives in exactly one `PendingMap` and an answer can + /// never be matched against the wrong operation. + /// + /// #### Returns + /// + /// a request id no other in-flight operation is using + public static int nextId() { + return NEXT_ID.getAndIncrement(); + } + + /// Runs something on the EDT, immediately when already there. + /// + /// Ports call the `deliver...` entry points from whatever thread the + /// native callback arrived on, so this is what makes the public + /// contract -- every callback on the EDT -- true. + /// + /// #### Parameters + /// + /// - `r`: what to run + public static void onEdt(Runnable r) { + if (!Display.isInitialized()) { + r.run(); + return; + } + Display d = Display.getInstance(); + if (d.isEdt()) { + r.run(); + } else { + d.callSerially(r); + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/nearby/NearbyWire.java b/CodenameOne/src/com/codename1/impl/nearby/NearbyWire.java new file mode 100644 index 00000000000..cfefe5883df --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/nearby/NearbyWire.java @@ -0,0 +1,375 @@ +/* + * 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.impl.nearby; + +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.NearbyException; +import com.codename1.nearby.companion.CompanionDevice; +import com.codename1.nearby.companion.CompanionProfile; +import com.codename1.nearby.companion.DeviceFilter; +import com.codename1.nearby.transport.Endpoint; + +import java.util.ArrayList; +import java.util.List; + +/// The encoding `com.codename1.nearby.spi.NearbyBridge` speaks, and the only +/// place that knows it. +/// +/// Tab-delimited fields, one record per array entry, for the reason +/// `com.codename1.impl.home.HomeWire` gives: every port has to implement the +/// encoder by hand, several of them in Objective-C, and a wire a human can +/// read in a log repays the bytes it costs. +/// +/// #### Every decoder here is total +/// +/// A malformed record decodes to `null` and is skipped by the caller, never +/// thrown over. Records arrive from native code in batches, and a parser +/// that threw would discard the good rows alongside the bad one. The single +/// exception is [#decodeError], whose whole purpose is to produce an +/// exception. +/// +/// @hidden not part of the public API. +public final class NearbyWire { + + /// The field separator. + public static final char SEPARATOR = '\t'; + + private NearbyWire() { + } + + // ------------------------------------------------------------------ + // primitives + // ------------------------------------------------------------------ + + /// Splits one record into its fields, preserving trailing empty ones -- + /// unlike `String.split`, whose dropping of them would shift every + /// index for a record ending in an absent address. + /// + /// #### Parameters + /// + /// - `line`: the record, or null + /// + /// #### Returns + /// + /// the fields, never null + public static String[] split(String line) { + if (line == null) { + return new String[0]; + } + List out = new ArrayList(); + int start = 0; + for (int i = 0; i < line.length(); i++) { + if (line.charAt(i) == SEPARATOR) { + out.add(line.substring(start, i)); + start = i + 1; + } + } + out.add(line.substring(start)); + String[] result = new String[out.size()]; + for (int i = 0; i < result.length; i++) { + result[i] = out.get(i); + } + return result; + } + + /// One field, or the empty string when the record is shorter than that. + /// + /// #### Parameters + /// + /// - `fields`: the split record + /// - `index`: the field wanted + /// + /// #### Returns + /// + /// the field, never null + public static String field(String[] fields, int index) { + if (fields == null || index < 0 || index >= fields.length) { + return ""; + } + return fields[index] == null ? "" : fields[index]; + } + + /// One field as a flag, where `"1"` is true and anything else is false. + /// + /// #### Parameters + /// + /// - `fields`: the split record + /// - `index`: the field wanted + /// + /// #### Returns + /// + /// the flag + public static boolean flag(String[] fields, int index) { + return "1".equals(field(fields, index)); + } + + /// One field as an int, falling back when it is absent or not a number. + /// + /// #### Parameters + /// + /// - `fields`: the split record + /// - `index`: the field wanted + /// - `fallback`: what to answer when the field is unusable + /// + /// #### Returns + /// + /// the parsed value or the fallback + public static int integer(String[] fields, int index, int fallback) { + String v = field(fields, index); + if (v.length() == 0) { + return fallback; + } + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + return fallback; + } + } + + /// One field as a long, falling back when it is absent or not a number. + /// + /// #### Parameters + /// + /// - `fields`: the split record + /// - `index`: the field wanted + /// - `fallback`: what to answer when the field is unusable + /// + /// #### Returns + /// + /// the parsed value or the fallback + public static long integer64(String[] fields, int index, long fallback) { + String v = field(fields, index); + if (v.length() == 0) { + return fallback; + } + try { + return Long.parseLong(v); + } catch (NumberFormatException e) { + return fallback; + } + } + + /// Joins fields into a record, sanitizing each. + /// + /// #### Parameters + /// + /// - `fields`: the fields + /// + /// #### Returns + /// + /// the record, never null + public static String join(String[] fields) { + if (fields == null) { + return ""; + } + StringBuilder b = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) { + b.append(SEPARATOR); + } + b.append(sanitize(fields[i])); + } + return b.toString(); + } + + /// Makes a field safe to put in a record: null becomes empty, and tabs, + /// carriage returns and newlines become spaces. + /// + /// #### Parameters + /// + /// - `value`: the field, or null + /// + /// #### Returns + /// + /// the safe field, never null + public static String sanitize(String value) { + if (value == null) { + return ""; + } + StringBuilder b = null; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == SEPARATOR || c == '\n' || c == '\r') { + if (b == null) { + b = new StringBuilder(value.substring(0, i)); + } + b.append(' '); + } else if (b != null) { + b.append(c); + } + } + return b == null ? value : b.toString(); + } + + /// The flag form of a boolean. + /// + /// #### Parameters + /// + /// - `value`: the flag + /// + /// #### Returns + /// + /// `"1"` or `"0"` + public static String flag(boolean value) { + return value ? "1" : "0"; + } + + // ------------------------------------------------------------------ + // records + // ------------------------------------------------------------------ + + /// Encodes a device filter as `kind SEP value`. + /// + /// #### Parameters + /// + /// - `filter`: the filter + /// + /// #### Returns + /// + /// the record + public static String encodeFilter(DeviceFilter filter) { + return join(new String[] { + Integer.toString(filter.getKind()), filter.getValue() + }); + } + + /// Encodes a companion device as + /// `id SEP name SEP address SEP profileOrdinal SEP presentFlag`. + /// + /// #### Parameters + /// + /// - `device`: the device + /// + /// #### Returns + /// + /// the record + public static String encodeCompanionDevice(CompanionDevice device) { + return join(new String[] { + device.getId(), + device.getDisplayName(), + device.getAddress() == null ? "" : device.getAddress(), + Integer.toString(device.getProfile().ordinal()), + flag(device.isPresent()) + }); + } + + /// Decodes a companion device. + /// + /// An empty address field decodes to `null` rather than to the empty + /// string, because "the platform withholds the address" is what the + /// public getter documents and an empty string would be handed straight + /// to `BluetoothLE.getPeripheral`. + /// + /// #### Parameters + /// + /// - `line`: the record + /// + /// #### Returns + /// + /// the device, or null when the record has no id + public static CompanionDevice decodeCompanionDevice(String line) { + String[] f = split(line); + String id = field(f, 0); + if (id.length() == 0) { + return null; + } + String address = field(f, 2); + return new CompanionDevice(id, field(f, 1), + address.length() == 0 ? null : address, + profileFor(integer(f, 3, 0)), flag(f, 4)); + } + + /// Decodes an endpoint from `id SEP name SEP serviceId`. + /// + /// #### Parameters + /// + /// - `line`: the record + /// + /// #### Returns + /// + /// the endpoint, or null when the record has no id + public static Endpoint decodeEndpoint(String line) { + String[] f = split(line); + String id = field(f, 0); + if (id.length() == 0) { + return null; + } + return new Endpoint(id, field(f, 1), field(f, 2)); + } + + /// Encodes an endpoint, the inverse of [#decodeEndpoint]. + /// + /// #### Parameters + /// + /// - `endpoint`: the endpoint + /// + /// #### Returns + /// + /// the record + public static String encodeEndpoint(Endpoint endpoint) { + return join(new String[] { + endpoint.getId(), endpoint.getName(), endpoint.getServiceId() + }); + } + + /// Turns an error ordinal and message into an exception. Unlike every + /// other decoder here this is expected to produce a failure, so an + /// unrecognised ordinal becomes [NearbyError#UNKNOWN] rather than being + /// skipped. + /// + /// #### Parameters + /// + /// - `errorOrdinal`: the ordinal of a [NearbyError] constant + /// - `message`: the detail, may be null + /// + /// #### Returns + /// + /// the exception, never null + public static NearbyException decodeError(int errorOrdinal, + String message) { + NearbyError[] all = NearbyError.values(); + NearbyError e = errorOrdinal >= 0 && errorOrdinal < all.length + ? all[errorOrdinal] : NearbyError.UNKNOWN; + return new NearbyException(e, message == null || message.length() == 0 + ? e.name() : message); + } + + /// The profile for an ordinal, falling back to + /// [CompanionProfile#GENERIC] for one this build does not know -- a port + /// from a newer build must not cost us the whole record. + /// + /// #### Parameters + /// + /// - `ordinal`: the ordinal + /// + /// #### Returns + /// + /// the profile, never null + public static CompanionProfile profileFor(int ordinal) { + CompanionProfile[] all = CompanionProfile.values(); + if (ordinal < 0 || ordinal >= all.length) { + return CompanionProfile.GENERIC; + } + return all[ordinal]; + } +} diff --git a/CodenameOne/src/com/codename1/impl/nearby/SyntheticNearby.java b/CodenameOne/src/com/codename1/impl/nearby/SyntheticNearby.java new file mode 100644 index 00000000000..25975a6f0b1 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/nearby/SyntheticNearby.java @@ -0,0 +1,68 @@ +/* + * 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.impl.nearby; + +/// The cast of devices a [LocalNearbyBridge] starts with, so the simulator +/// and the desktop ports have something to find without every app writing +/// its own fixture. +/// +/// The line-up is deliberately awkward rather than tidy, for the reason +/// `com.codename1.impl.home.SyntheticHome` is: a fixture where every device +/// has a name, an address and a service is a fixture that never exercises +/// the branches an app needs for the ones that do not. So there is a device +/// with no advertised service, one whose name collides on a prefix with +/// another, and an endpoint whose name is long enough to overflow a label. +/// +/// @hidden not part of the public API. +public final class SyntheticNearby { + + /// The BLE heart-rate service, the one most likely to appear in an + /// example filter. + public static final String HEART_RATE_SERVICE = "180D"; + + private SyntheticNearby() { + } + + /// Fills a bridge with the default cast. + /// + /// #### Parameters + /// + /// - `bridge`: the bridge to populate + public static void populate(LocalNearbyBridge bridge) { + // Association candidates. Order matters: the first is what a filter + // -free request returns. + bridge.addCandidate("Simulated Watch", "00:11:22:33:44:01", null); + bridge.addCandidate("Simulated Heart Rate Strap", "00:11:22:33:44:02", + HEART_RATE_SERVICE); + bridge.addCandidate("Simulated Heart Rate Strap Mk II", + "00:11:22:33:44:03", HEART_RATE_SERVICE); + // No service, so a service filter must not match it and a name + // filter must. + bridge.addCandidate("Simulated Tag", "00:11:22:33:44:04", null); + + // Transport endpoints. + bridge.addEndpoint("sim-endpoint-1", "Simulated Phone"); + bridge.addEndpoint("sim-endpoint-2", + "Simulated Phone With A Deliberately Long Advertised Name"); + } +} diff --git a/CodenameOne/src/com/codename1/nearby/NearbyAvailability.java b/CodenameOne/src/com/codename1/nearby/NearbyAvailability.java new file mode 100644 index 00000000000..c47e0665f63 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/NearbyAvailability.java @@ -0,0 +1,56 @@ +/* + * 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.nearby; + +/// How much of a `com.codename1.nearby` feature is really usable right now. +/// +/// This is deliberately finer than a boolean, because the three interesting +/// cases behave differently: an app on a desktop simulator should show its +/// full UI against simulated peers, an app on an iPhone SE should hide the +/// ranging feature outright, and an app whose user switched the radio off +/// should ask them to switch it back on rather than hide anything. +public enum NearbyAvailability { + /// The real platform feature is present and usable. + AVAILABLE, + + /// A simulated implementation is active. Everything works, but nothing + /// outside this process can see it -- this is what the desktop ports, + /// the simulator and the JavaScript port report so that ranging and + /// association UI is developable without hardware. Never returned by a + /// device port. + LOCAL_ONLY, + + /// The platform supports the feature but a required permission has not + /// been granted. Recoverable: call the entry point's + /// `requestPermissions` method. + UNAUTHORIZED, + + /// The platform supports the feature but the radio it needs is off or + /// temporarily unavailable. Recoverable without any action from the app + /// beyond asking the user to enable it. + TEMPORARILY_UNAVAILABLE, + + /// This port, OS version or device cannot do it at all. Hide the + /// feature. + NOT_SUPPORTED +} diff --git a/CodenameOne/src/com/codename1/nearby/NearbyError.java b/CodenameOne/src/com/codename1/nearby/NearbyError.java new file mode 100644 index 00000000000..36c824a360b --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/NearbyError.java @@ -0,0 +1,87 @@ +/* + * 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.nearby; + +/// Typed error codes carried by every [NearbyException] thrown through the +/// failure path of the `com.codename1.nearby` APIs. Callers branch on these +/// via [NearbyException#getError()] rather than string-matching a message. +public enum NearbyError { + /// The requested feature is not available on this port, this OS version + /// or this hardware. The capability queries -- `isSupported()` on each + /// entry point, plus the finer-grained + /// [com.codename1.nearby.ranging.RangingCapabilities] -- let + /// cross-platform code branch before ever seeing this code, and the + /// inert fallback bridges fail every operation with it. + NOT_SUPPORTED, + + /// A required runtime permission or OS authorization is missing. On + /// Android that is `UWB_RANGING`, the Bluetooth runtime grants or + /// location; on iOS it is the Nearby Interaction or local network + /// authorization the user declined. See the `requestPermissions` method + /// on the relevant entry point. + UNAUTHORIZED, + + /// The radio this feature needs is switched off or otherwise + /// unavailable right now -- UWB disabled in settings, Bluetooth powered + /// off, Wi-Fi off. Unlike [#NOT_SUPPORTED] this is recoverable: the + /// same call may succeed once the user turns the radio on. + RADIO_UNAVAILABLE, + + /// The peer, accessory or endpoint could not be reached, or moved out of + /// range before the operation completed. + PEER_UNAVAILABLE, + + /// A ranging or transport session could not be started -- an invalid + /// configuration, too many concurrent sessions, or a platform-level + /// refusal. + SESSION_FAILED, + + /// A running session was invalidated by the platform and cannot be + /// resumed. Start a new one. + SESSION_INVALIDATED, + + /// The supplied token, accessory configuration or endpoint identifier + /// could not be decoded, or came from a different platform. Tokens are + /// opaque and are not portable between iOS and Android. + INVALID_TOKEN, + + /// The platform never delivered a completion callback within the safety + /// timeout, or a discovery/connection attempt timed out. + TIMEOUT, + + /// A conflicting operation is already in progress -- for example a + /// second association flow while the system chooser is open. + BUSY, + + /// The user dismissed a system dialog (the device chooser, the + /// association prompt, a permission request) or the operation was + /// cancelled through `AsyncResource.cancel()`. + USER_CANCELED, + + /// Transport-level I/O failure while moving a payload. Blocking stream + /// payloads throw plain `java.io.IOException` instead. + IO_ERROR, + + /// Unclassified failure; the exception message carries the details. + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/nearby/NearbyException.java b/CodenameOne/src/com/codename1/nearby/NearbyException.java new file mode 100644 index 00000000000..76cb474caf2 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/NearbyException.java @@ -0,0 +1,52 @@ +/* + * 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.nearby; + +/// Thrown through the failure path of every `AsyncResource` returned by the +/// `com.codename1.nearby` APIs, and passed to the failure callbacks of the +/// ranging and transport listeners. [#getError()] returns a typed +/// [NearbyError] so callers react without string-matching the message. +public class NearbyException extends Exception { + + private final NearbyError error; + + public NearbyException(NearbyError error) { + super(error == null ? "UNKNOWN" : error.name()); + this.error = error == null ? NearbyError.UNKNOWN : error; + } + + public NearbyException(NearbyError error, String message) { + super(message); + this.error = error == null ? NearbyError.UNKNOWN : error; + } + + public NearbyException(NearbyError error, String message, Throwable cause) { + super(message, cause); + this.error = error == null ? NearbyError.UNKNOWN : error; + } + + /// Typed error code describing the failure. Never `null`. + public NearbyError getError() { + return error; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/NearbyPermission.java b/CodenameOne/src/com/codename1/nearby/NearbyPermission.java new file mode 100644 index 00000000000..a4a3c8c42bf --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/NearbyPermission.java @@ -0,0 +1,51 @@ +/* + * 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.nearby; + +/// The runtime permissions the `com.codename1.nearby` APIs may need, named +/// by what the app is trying to do rather than by any one platform's +/// permission string. +/// +/// Each entry point takes these as varargs in its `requestPermissions` +/// method and maps them to whatever the running platform actually asks for: +/// on Android a set of manifest permissions, on iOS an authorization prompt +/// raised by the first call that needs it. A port that needs no permission +/// for a given constant reports it granted rather than failing. +public enum NearbyPermission { + /// Precision ranging. Android `UWB_RANGING`; on iOS the Nearby + /// Interaction authorization prompted by the first session. + RANGING, + + /// Discovering nearby devices to advertise to or range against. Android + /// `BLUETOOTH_SCAN` plus `NEARBY_WIFI_DEVICES` (or location below API + /// 33); on iOS the local network authorization. + DISCOVERY, + + /// Advertising this device so others can find it. Android + /// `BLUETOOTH_ADVERTISE`; no iOS equivalent. + ADVERTISE, + + /// Connecting to a discovered device and moving payloads. Android + /// `BLUETOOTH_CONNECT`; no iOS equivalent. + CONNECT +} diff --git a/CodenameOne/src/com/codename1/nearby/companion/AssociationRequest.java b/CodenameOne/src/com/codename1/nearby/companion/AssociationRequest.java new file mode 100644 index 00000000000..e2515ccefc7 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/AssociationRequest.java @@ -0,0 +1,126 @@ +/* + * 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.nearby.companion; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// What to show the user in the system device chooser, built with +/// [AssociationRequest.Builder]. +public final class AssociationRequest { + + private final CompanionProfile profile; + private final boolean singleDevice; + private final List filters; + + private AssociationRequest(CompanionProfile profile, boolean singleDevice, + List filters) { + this.profile = profile; + this.singleDevice = singleDevice; + this.filters = Collections.unmodifiableList(filters); + } + + /// The profile requested. Never null. + public CompanionProfile getProfile() { + return profile; + } + + /// Whether to associate immediately when exactly one device matches, + /// rather than showing a one-item list. + public boolean isSingleDevice() { + return singleDevice; + } + + /// The filters, OR-combined. Never null and possibly empty, in which + /// case every visible device is offered. + public List getFilters() { + return filters; + } + + /// Assembles an [AssociationRequest]. + public static final class Builder { + + private CompanionProfile profile = CompanionProfile.GENERIC; + private boolean singleDevice; + private final List filters = new ArrayList(); + + /// Sets the profile. Defaults to [CompanionProfile#GENERIC], which + /// is what most accessories should ask for. + /// + /// #### Parameters + /// + /// - `profile`: the profile to request + /// + /// #### Returns + /// + /// this builder + public Builder profile(CompanionProfile profile) { + this.profile = profile == null ? CompanionProfile.GENERIC : profile; + return this; + } + + /// Asks the platform to skip the chooser when exactly one device + /// matches the filters. The user still consents -- they are shown + /// one device and confirm it -- so this is a shortcut, not a way to + /// associate silently. + /// + /// #### Parameters + /// + /// - `singleDevice`: whether to take the shortcut + /// + /// #### Returns + /// + /// this builder + public Builder singleDevice(boolean singleDevice) { + this.singleDevice = singleDevice; + return this; + } + + /// Adds a filter. Filters are OR-combined. + /// + /// #### Parameters + /// + /// - `filter`: the filter to add + /// + /// #### Returns + /// + /// this builder + public Builder addFilter(DeviceFilter filter) { + if (filter != null) { + filters.add(filter); + } + return this; + } + + /// Builds the request. + /// + /// #### Returns + /// + /// the immutable request + public AssociationRequest build() { + return new AssociationRequest(profile, singleDevice, + new ArrayList(filters)); + } + } +} diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java new file mode 100644 index 00000000000..02fecac15c9 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.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.codename1.nearby.companion; + +/// A device this app is associated with. +/// +/// An association outlives the app: it is stored by the OS, survives +/// restarts, and is what makes background presence notifications and +/// permission-free scanning possible. It ends when the app calls +/// [CompanionDevices#disassociate], when the user revokes it in system +/// settings, or when the app is uninstalled. +public final class CompanionDevice { + + private final String id; + private final String displayName; + private final String address; + private final CompanionProfile profile; + private final boolean present; + + /// Ports construct these; application code reads them from + /// [CompanionDevices]. + /// + /// #### Parameters + /// + /// - `id`: the platform's association id + /// - `displayName`: the name to show a user, never null + /// - `address`: the device address, or null when the platform withholds + /// it + /// - `profile`: the profile the association was made under + /// - `present`: whether the device is in range right now + public CompanionDevice(String id, String displayName, String address, + CompanionProfile profile, boolean present) { + this.id = id; + this.displayName = displayName == null ? "" : displayName; + this.address = address; + this.profile = profile == null ? CompanionProfile.GENERIC : profile; + this.present = present; + } + + /// The association id, stable across app restarts. This is what + /// [CompanionDevices#disassociate] and + /// [CompanionDevices#startObservingPresence] take, and what to persist. + public String getId() { + return id; + } + + /// The name to show a user. Never null, occasionally empty where the + /// device advertises none. + public String getDisplayName() { + return displayName; + } + + /// The device address, or `null` where the platform does not hand it + /// out. Where it is present it matches + /// `com.codename1.bluetooth.BluetoothDevice#getAddress()`, so it can be + /// passed to `BluetoothLE.getPeripheral(String)` to open a GATT + /// connection to the associated device. + /// + /// Android returns the MAC address for a Bluetooth association. iOS + /// returns the per-app accessory identifier. + public String getAddress() { + return address; + } + + /// The profile this association was made under. + public CompanionProfile getProfile() { + return profile; + } + + /// Whether the device was in range when this record was produced. + /// + /// This is a snapshot, not a live value -- re-read it from + /// [CompanionDevices#getAssociations()], or watch + /// [PresenceListener] for changes. Platforms that do not track presence + /// report `false`. + public boolean isPresent() { + return present; + } + + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CompanionDevice)) { + return false; + } + CompanionDevice d = (CompanionDevice) o; + return id == null ? d.id == null : id.equals(d.id); + } + + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } + + public String toString() { + return "CompanionDevice[" + id + ", " + displayName + + ", profile=" + profile + ", present=" + present + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java new file mode 100644 index 00000000000..985e5875dd4 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -0,0 +1,383 @@ +/* + * 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.nearby.companion; + +import com.codename1.impl.async.EdtResult; +import com.codename1.impl.async.PendingMap; +import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.impl.nearby.NearbyWire; +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.NearbyException; +import com.codename1.nearby.spi.NearbyBridge; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Companion-device association: the OS-managed relationship between this +/// app and one particular accessory. +/// +/// Associating is not pairing. It is the app telling the operating system +/// "this is my device", through a chooser the OS draws and the user picks +/// from, and getting back privileges that an ordinary Bluetooth scan does +/// not carry: +/// +/// - **The OS watches for the device instead of the app.** +/// [#startObservingPresence] asks the platform to wake the app when the +/// accessory comes into range, which replaces a scan the app would +/// otherwise run -- and pay for in battery -- forever. +/// - **Scanning stops needing location permission.** On Android, finding +/// your own associated device is not the same question as finding out +/// where the user is, and the platform treats it accordingly. +/// - **The user sees one honest prompt** naming one device, instead of a +/// blanket "this app wants to find nearby devices". +/// +/// ```java +/// AssociationRequest req = new AssociationRequest.Builder() +/// .addFilter(DeviceFilter.bleService("180D")) +/// .build(); +/// CompanionDevices.associate(req).onResult((device, err) -> { +/// if (err == null) { +/// Preferences.set("sensor", device.getId()); +/// CompanionDevices.startObservingPresence(device.getId()); +/// } +/// }); +/// ``` +/// +/// #### Platform support +/// +/// - **Android** -- `CompanionDeviceManager`, with presence observation. +/// - **iOS** -- AccessorySetupKit, on iOS 18 and later. The picker returns +/// an accessory the app may then talk to over +/// `com.codename1.bluetooth` without holding the blanket Bluetooth +/// authorization. Earlier iOS versions report [#isSupported()] false; +/// there the app scans with `com.codename1.bluetooth` as before. +/// - **Simulator, desktop and JavaScript** -- a simulated association store +/// reporting [NearbyAvailability#LOCAL_ONLY]. +/// - **Every other port** -- unsupported, and every call fails fast. +public final class CompanionDevices { + + private static final PendingMap PENDING_ASSOCIATE = + new PendingMap(); + private static final PendingMap PENDING_DISASSOCIATE = + new PendingMap(); + private static final List LISTENERS = + new ArrayList(); + + private CompanionDevices() { + } + + /// `true` when this port can associate companion devices. + public static boolean isSupported() { + NearbyBridge b = NearbyRequests.bridge(); + return b != null && b.isCompanionSupported(); + } + + /// How usable association is right now. + /// + /// #### Returns + /// + /// the current availability, never null + public static NearbyAvailability getAvailability() { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isCompanionSupported()) { + return NearbyAvailability.NOT_SUPPORTED; + } + NearbyAvailability[] all = NearbyAvailability.values(); + int o = b.getCompanionAvailability(); + return o >= 0 && o < all.length ? all[o] + : NearbyAvailability.NOT_SUPPORTED; + } + + /// Shows the system device chooser and associates whatever the user + /// picks. + /// + /// This always involves the user -- there is no way to associate + /// silently on either platform, by design. + /// + /// #### Parameters + /// + /// - `request`: what to offer the user + /// + /// #### Returns + /// + /// resolves with the associated device, or fails with + /// [NearbyError#USER_CANCELED] when the user dismissed the chooser + public static AsyncResource associate( + AssociationRequest request) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isCompanionSupported()) { + EdtResult out = new EdtResult(); + out.error(new NearbyException(NearbyError.NOT_SUPPORTED, + "this platform does not support companion devices")); + return out; + } + if (request == null) { + request = new AssociationRequest.Builder().build(); + } + List filters = request.getFilters(); + String[] encoded = new String[filters.size()]; + for (int i = 0; i < encoded.length; i++) { + encoded[i] = NearbyWire.encodeFilter(filters.get(i)); + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING_ASSOCIATE.open(id); + b.associate(id, request.getProfile().ordinal(), + request.isSingleDevice(), encoded); + return out; + } + + /// Every association this app currently holds. + /// + /// Associations survive restarts, so this is what an app calls on + /// startup to find the accessory it was using last time rather than + /// asking the user again. + /// + /// #### Returns + /// + /// the associations, never null and possibly empty + public static List getAssociations() { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isCompanionSupported()) { + return Collections.emptyList(); + } + String[] rows = b.getAssociations(); + if (rows == null || rows.length == 0) { + return Collections.emptyList(); + } + List out = + new ArrayList(rows.length); + for (int i = 0; i < rows.length; i++) { + CompanionDevice d = NearbyWire.decodeCompanionDevice(rows[i]); + if (d != null) { + out.add(d); + } + } + return Collections.unmodifiableList(out); + } + + /// Drops an association and the privileges that came with it. + /// + /// #### Parameters + /// + /// - `associationId`: the id from [CompanionDevice#getId()] + /// + /// #### Returns + /// + /// resolves `true` once the association is gone + public static AsyncResource disassociate(String associationId) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isCompanionSupported()) { + EdtResult out = new EdtResult(); + out.error(new NearbyException(NearbyError.NOT_SUPPORTED, + "this platform does not support companion devices")); + return out; + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING_DISASSOCIATE.open(id); + b.disassociate(id, associationId); + return out; + } + + /// Asks the platform to watch for the device and tell this app when it + /// comes and goes, delivering to every registered [PresenceListener]. + /// + /// #### Parameters + /// + /// - `associationId`: the id from [CompanionDevice#getId()] + /// + /// #### Returns + /// + /// `true` when the platform accepted the request. `false` where + /// presence observation is unsupported -- the association itself is + /// unaffected, so an app can carry on scanning for the device itself. + public static boolean startObservingPresence(String associationId) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isCompanionSupported() || associationId == null) { + return false; + } + return b.startObservingPresence(associationId); + } + + /// Stops watching an association. Idempotent. + /// + /// #### Parameters + /// + /// - `associationId`: the id from [CompanionDevice#getId()] + public static void stopObservingPresence(String associationId) { + NearbyBridge b = NearbyRequests.bridge(); + if (b != null && associationId != null) { + b.stopObservingPresence(associationId); + } + } + + /// Registers a presence listener. Callbacks arrive on the EDT. + /// + /// Register from the app's `init()`: presence is exactly the event that + /// can arrive during a cold start, because the platform launched the app + /// to deliver it. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addPresenceListener(PresenceListener l) { + if (l == null) { + return; + } + synchronized (LISTENERS) { + LISTENERS.add(l); + } + } + + /// Removes a listener added by [#addPresenceListener]. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removePresenceListener(PresenceListener l) { + synchronized (LISTENERS) { + LISTENERS.remove(l); + } + } + + + /// Clears every in-flight request, so one test cannot see the requests of + /// the test that ran before it. Reached through + /// `com.codename1.impl.nearby.NearbyRequests#resetForTest`. + /// + /// In-flight requests are failed rather than dropped: a resource that + /// never settles is worse than one that fails, and a test holding one + /// would hang rather than report. + /// + /// @hidden not part of the public API; test-only. + public static void resetForTest() { + NearbyException reset = new NearbyException(NearbyError.UNKNOWN, + "the nearby framework was reset"); + PENDING_ASSOCIATE.failAll(reset); + PENDING_DISASSOCIATE.failAll(reset); + synchronized (LISTENERS) { + LISTENERS.clear(); + } + } + + // ------------------------------------------------------------------ + // Port entry points + // ------------------------------------------------------------------ + + /// Answers [#associate]. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `encodedDevice`: the device, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + public static void deliverAssociated(int requestId, String encodedDevice) { + EdtResult r = PENDING_ASSOCIATE.take(requestId); + if (r == null) { + return; + } + CompanionDevice d = NearbyWire.decodeCompanionDevice(encodedDevice); + if (d == null) { + r.error(new NearbyException(NearbyError.UNKNOWN, + "the port reported an association with no id")); + } else { + r.complete(d); + } + } + + /// Answers [#disassociate]. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + public static void deliverDisassociated(int requestId) { + EdtResult r = PENDING_DISASSOCIATE.take(requestId); + if (r != null) { + r.complete(Boolean.TRUE); + } + } + + /// Fails whichever companion request carries this id. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `errorOrdinal`: the ordinal of a `com.codename1.nearby.NearbyError` + /// constant + /// - `message`: a human-readable detail, may be null + public static void deliverRequestFailed(int requestId, int errorOrdinal, + String message) { + NearbyException ex = NearbyWire.decodeError(errorOrdinal, message); + EdtResult a = PENDING_ASSOCIATE.take(requestId); + if (a != null) { + a.error(ex); + return; + } + EdtResult d = PENDING_DISASSOCIATE.take(requestId); + if (d != null) { + d.error(ex); + } + } + + /// Reports that an associated device came into or went out of range. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `encodedDevice`: the device, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + /// - `present`: true when it appeared, false when it disappeared + public static void deliverPresenceChanged(String encodedDevice, + final boolean present) { + final CompanionDevice d = + NearbyWire.decodeCompanionDevice(encodedDevice); + if (d == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + PresenceListener[] ls; + synchronized (LISTENERS) { + ls = LISTENERS.toArray( + new PresenceListener[LISTENERS.size()]); + } + for (int i = 0; i < ls.length; i++) { + if (present) { + ls[i].deviceAppeared(d); + } else { + ls[i].deviceDisappeared(d); + } + } + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionProfile.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionProfile.java new file mode 100644 index 00000000000..ceef3a02e7c --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionProfile.java @@ -0,0 +1,49 @@ +/* + * 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.nearby.companion; + +/// What kind of thing is being associated. +/// +/// The profile is a request for elevated privileges as much as a +/// description: Android grants a watch profile the right to run in the +/// background and stream notifications, and shows the user a correspondingly +/// stronger consent dialog. Ask for [#GENERIC] unless the device really is +/// one of the specific kinds, because the specific profiles cost the user a +/// scarier prompt. +public enum CompanionProfile { + /// No elevated privileges. The right answer for a sensor, a tag, a + /// fitness accessory -- anything that is not one of the categories the + /// platform treats specially. + GENERIC, + + /// A watch. On Android this is `DEVICE_PROFILE_WATCH`, which carries + /// background and notification privileges. + WATCH, + + /// A head-mounted display. Android `DEVICE_PROFILE_GLASSES`. + GLASSES, + + /// A nearby computer, for cross-device flows. Android + /// `DEVICE_PROFILE_COMPUTER`. + COMPUTER +} diff --git a/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java b/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java new file mode 100644 index 00000000000..f2d999839da --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java @@ -0,0 +1,144 @@ +/* + * 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.nearby.companion; + +/// Narrows what the system device chooser offers the user. +/// +/// A request with no filter shows everything the radios can see, which is a +/// long and confusing list; a request with one good filter usually shows +/// exactly the accessory the user is holding. Filters within one +/// [AssociationRequest] are OR-combined -- a device matching any of them is +/// offered. +/// +/// ```java +/// AssociationRequest req = new AssociationRequest.Builder() +/// .addFilter(DeviceFilter.bleService("180D")) // heart rate +/// .addFilter(DeviceFilter.namePattern("Acme.*")) +/// .build(); +/// ``` +public final class DeviceFilter { + + /// Filter kind: match a BLE service UUID being advertised. + public static final int KIND_BLE_SERVICE = 0; + + /// Filter kind: match the advertised device name against a regular + /// expression. + public static final int KIND_NAME_PATTERN = 1; + + /// Filter kind: match one exact device address. + public static final int KIND_ADDRESS = 2; + + /// Filter kind: match a Wi-Fi SSID. + public static final int KIND_WIFI_SSID = 3; + + private final int kind; + private final String value; + + private DeviceFilter(int kind, String value) { + this.kind = kind; + this.value = value; + } + + /// Offers only devices advertising the given BLE service. + /// + /// #### Parameters + /// + /// - `serviceUuid`: the service UUID, in either the 16-bit short form + /// (`"180D"`) or the full 128-bit form + /// + /// #### Returns + /// + /// the filter + public static DeviceFilter bleService(String serviceUuid) { + return new DeviceFilter(KIND_BLE_SERVICE, require(serviceUuid)); + } + + /// Offers only devices whose advertised name matches a regular + /// expression. + /// + /// The pattern is passed through to the platform, which on Android is + /// `java.util.regex` and on iOS is a substring match on the accessory + /// name -- so keep patterns simple if the app runs on both. + /// + /// #### Parameters + /// + /// - `pattern`: the pattern to match the name against + /// + /// #### Returns + /// + /// the filter + public static DeviceFilter namePattern(String pattern) { + return new DeviceFilter(KIND_NAME_PATTERN, require(pattern)); + } + + /// Offers only the device at one exact address -- the reconnect case, + /// where the app already knows which device it wants. + /// + /// #### Parameters + /// + /// - `address`: the device address, as + /// `com.codename1.bluetooth.BluetoothDevice#getAddress()` reports it + /// + /// #### Returns + /// + /// the filter + public static DeviceFilter address(String address) { + return new DeviceFilter(KIND_ADDRESS, require(address)); + } + + /// Offers only the Wi-Fi network with the given SSID. Android only; + /// ignored on platforms that associate Bluetooth accessories alone. + /// + /// #### Parameters + /// + /// - `ssid`: the network name + /// + /// #### Returns + /// + /// the filter + public static DeviceFilter wifiSsid(String ssid) { + return new DeviceFilter(KIND_WIFI_SSID, require(ssid)); + } + + /// Which of the `KIND_` constants this filter is. + public int getKind() { + return kind; + } + + /// The UUID, pattern, address or SSID, depending on [#getKind()]. + public String getValue() { + return value; + } + + public String toString() { + return "DeviceFilter[kind=" + kind + ", value=" + value + "]"; + } + + private static String require(String v) { + if (v == null || v.length() == 0) { + throw new IllegalArgumentException( + "a device filter needs a non-empty value"); + } + return v; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/companion/PresenceListener.java b/CodenameOne/src/com/codename1/nearby/companion/PresenceListener.java new file mode 100644 index 00000000000..cf1c3a30e1f --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/PresenceListener.java @@ -0,0 +1,47 @@ +/* + * 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.nearby.companion; + +/// Told when an associated device comes into or goes out of range. Both +/// methods are called on the EDT. +/// +/// Presence is the reason companion association is worth using for an +/// accessory the app talks to regularly: the OS watches for the device and +/// wakes the app, instead of the app burning battery on a scan it runs +/// itself. +public interface PresenceListener { + + /// The associated device came into range. + /// + /// #### Parameters + /// + /// - `device`: the device that appeared + void deviceAppeared(CompanionDevice device); + + /// The associated device went out of range. + /// + /// #### Parameters + /// + /// - `device`: the device that disappeared + void deviceDisappeared(CompanionDevice device); +} diff --git a/CodenameOne/src/com/codename1/nearby/companion/package-info.java b/CodenameOne/src/com/codename1/nearby/companion/package-info.java new file mode 100644 index 00000000000..be69cc8a897 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/package-info.java @@ -0,0 +1,36 @@ +/* + * 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. + */ +/// Companion-device association: telling the operating system which +/// accessory is yours, and getting privileges back for it. +/// +/// Start at [CompanionDevices], which explains what association buys that an +/// ordinary Bluetooth scan does not -- OS-run presence watching, scanning +/// without location permission, and one honest consent prompt naming one +/// device. +/// +/// An association is a durable relationship: it survives app restarts and +/// reboots, and ends only when the app drops it, the user revokes it in +/// system settings, or the app is uninstalled. Persist +/// [CompanionDevice#getId()] and look the device up again on the next +/// launch instead of asking the user to pick it twice. +package com.codename1.nearby.companion; diff --git a/CodenameOne/src/com/codename1/nearby/package-info.java b/CodenameOne/src/com/codename1/nearby/package-info.java new file mode 100644 index 00000000000..c61cd0c941b --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/package-info.java @@ -0,0 +1,60 @@ +/* + * 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. + */ +/// Nearby devices: how far away one is, which one is yours, and how to send +/// it something. +/// +/// The three questions are answered by three sub-packages, and they are +/// separate packages rather than one because **referencing a package is the +/// only opt-in there is**. The build server decides what native machinery an +/// app gets by scanning bytecode for these prefixes, and it has no way to +/// express an exclusion -- so an app that only wants to know how far away +/// its keyring tag is must not pay for the Play Services dependency and the +/// Wi-Fi permissions that device-to-device transport costs. +/// +/// - [com.codename1.nearby.ranging] -- ultra-wideband precision ranging. +/// Distance to within about ten centimetres, and direction on hardware +/// that has the antennas for it. +/// - [com.codename1.nearby.companion] -- the OS-managed association between +/// this app and one particular accessory, which buys background presence +/// notifications and scanning that does not need location permission. +/// - [com.codename1.nearby.transport] -- moving bytes and files to a device +/// in the same room, with no access point and no internet. Same-ecosystem +/// only; the package documentation says why and what to use instead. +/// +/// This package itself holds only what all three share: [NearbyError], +/// [NearbyException], [NearbyAvailability] and [NearbyPermission]. +/// Referencing it alone costs nothing. +/// +/// #### How this relates to what was already here +/// +/// Ranging is not a replacement for `com.codename1.bluetooth` -- it needs +/// it. Both platforms require the two devices to swap a token over some +/// channel they already share before any radio ranging can start, and a GATT +/// characteristic is the usual channel. The two APIs are designed to be used +/// together. +/// +/// Nor does any of this replace RSSI-based proximity: an app that only needs +/// "near or far" can read the signal strength of a +/// `com.codename1.bluetooth.le` advertisement on every device ever made, +/// where UWB needs hardware from 2019 onward. +package com.codename1.nearby; diff --git a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java new file mode 100644 index 00000000000..117a27990e1 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -0,0 +1,414 @@ +/* + * 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.nearby.ranging; + +import com.codename1.impl.async.EdtResult; +import com.codename1.impl.async.PendingMap; +import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.NearbyException; +import com.codename1.nearby.NearbyPermission; +import com.codename1.nearby.spi.NearbyBridge; +import com.codename1.util.AsyncResource; + +/// Precision ranging: how far away another device is, and in which +/// direction. +/// +/// This is ultra-wideband ranging -- Apple's Nearby Interaction on iOS and +/// Jetpack UWB on Android -- which measures distance by timing a radio +/// round trip rather than by guessing from signal strength. Where an RSSI +/// estimate off a Bluetooth advertisement is worth a few metres on a good +/// day, UWB is worth about ten centimetres, and on hardware with multiple +/// antennas it also reports which way the peer is. +/// +/// #### The shape of a session +/// +/// Both platforms need the two devices to exchange a token over some +/// channel they already share before any radio ranging can begin, so the +/// API is in two steps and there is no way to collapse them: +/// +/// ```java +/// if (!Ranging.isSupported()) { +/// return; // no UWB radio on this device +/// } +/// Ranging.prepareSession(RangingRole.CONTROLLER).onResult((session, err) -> { +/// if (err != null) { +/// return; +/// } +/// // 1. publish our token however the two apps already talk -- +/// // a GATT characteristic from com.codename1.bluetooth is typical +/// characteristic.writeValue(session.getLocalToken().toByteArray()); +/// +/// // 2. when theirs arrives, start ranging +/// session.addRangingListener(new RangingAdapter() { +/// public void updated(RangingUpdate u) { +/// if (u.hasDistance()) { +/// label.setText(Math.round(u.getDistance(RangingUnit.CENTIMETERS)) + " cm"); +/// } +/// } +/// }); +/// session.start(RangingToken.fromByteArray(theirToken)); +/// }); +/// ``` +/// +/// A session ranges exactly one peer. That is a hard limit of Apple's +/// `NINearbyPeerConfiguration` rather than a simplification, so an app that +/// tracks several peers prepares several sessions -- which is also what the +/// Android port does under the hood. +/// +/// #### Threading +/// +/// Every callback here -- `AsyncResource` results and every +/// [RangingListener] method -- is delivered on the EDT. +/// +/// #### Platform support +/// +/// - **iOS** -- Nearby Interaction on devices with a U1 or newer chip +/// (iPhone 11 and later). Peer and accessory ranging, direction where the +/// hardware provides it. Not available on tvOS, watchOS or Mac Catalyst. +/// - **Android** -- Jetpack UWB on devices that report the UWB hardware +/// feature. Peer ranging natively; an accessory is ranged by building a +/// token with [RangingToken#forUwbAddress]. +/// - **Simulator, desktop and JavaScript** -- a simulated implementation +/// with peers that really move, so ranging UI is developable without +/// hardware. Reports [NearbyAvailability#LOCAL_ONLY]. +/// - **Every other port** -- [#isSupported()] is `false` and every call +/// fails with [NearbyError#NOT_SUPPORTED]. +public final class Ranging { + + private static final PendingMap PENDING_PERMISSIONS = + new PendingMap(); + private static final PendingMap PENDING_SESSIONS = + new PendingMap(); + private static final PendingMap PENDING_ACCESSORY = + new PendingMap(); + + private Ranging() { + } + + /// `true` when this port and this device can range at all. + /// + /// This answers for the hardware, not for whether a peer is nearby. It + /// is the query to hide a feature on; use [#getAvailability()] to tell + /// a user why a supported feature is not working right now. + public static boolean isSupported() { + NearbyBridge b = NearbyRequests.bridge(); + return b != null && b.isRangingSupported(); + } + + /// How usable ranging is at this moment, which is a different question + /// from [#isSupported()]: a phone with a U1 chip whose owner denied the + /// permission is supported and unavailable. + /// + /// #### Returns + /// + /// the current availability, never null + public static NearbyAvailability getAvailability() { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isRangingSupported()) { + return NearbyAvailability.NOT_SUPPORTED; + } + return fromOrdinal(b.getRangingAvailability()); + } + + /// What this device can actually measure. Never null: where ranging is + /// absent this is [RangingCapabilities#UNSUPPORTED], whose every query + /// is `false`. + /// + /// #### Returns + /// + /// the capabilities of the local device + public static RangingCapabilities getCapabilities() { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isRangingSupported()) { + return RangingCapabilities.UNSUPPORTED; + } + int bits = b.getRangingCapabilities(); + return new RangingCapabilities( + (bits & NearbyBridge.CAPABILITY_DISTANCE) != 0, + (bits & NearbyBridge.CAPABILITY_DIRECTION) != 0, + (bits & NearbyBridge.CAPABILITY_ELEVATION) != 0, + (bits & NearbyBridge.CAPABILITY_CAMERA_ASSISTANCE) != 0, + (bits & NearbyBridge.CAPABILITY_ACCESSORY) != 0, + (bits & NearbyBridge.CAPABILITY_BACKGROUND) != 0); + } + + /// Asks for the runtime permissions ranging needs. + /// + /// Safe to call on every platform: a port with nothing to ask for + /// resolves `true` without showing anything. + /// + /// #### Parameters + /// + /// - `permissions`: what the app intends to do + /// + /// #### Returns + /// + /// resolves `true` when every requested permission is granted + public static AsyncResource requestPermissions( + NearbyPermission... permissions) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null) { + return failedBoolean(); + } + int bits = 0; + if (permissions != null) { + for (int i = 0; i < permissions.length; i++) { + bits |= permissionBit(permissions[i]); + } + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING_PERMISSIONS.open(id); + b.requestPermissions(id, bits); + return out; + } + + /// Allocates a ranging session and, with it, the local token to publish + /// to the peer. The session is not ranging yet -- call + /// [RangingSession#start] once the peer's token arrives. + /// + /// #### Parameters + /// + /// - `role`: which end of the session this device is. Ignored on + /// platforms that negotiate roles themselves, but pick one anyway: + /// Android needs exactly one controller. + /// + /// #### Returns + /// + /// resolves with the prepared session, or fails with a + /// [NearbyException] + public static AsyncResource prepareSession( + RangingRole role) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isRangingSupported()) { + EdtResult out = new EdtResult(); + out.error(new NearbyException(NearbyError.NOT_SUPPORTED, + "this platform does not support precision ranging")); + return out; + } + int id = NearbyRequests.nextId(); + int handle = RangingSession.nextHandle(); + EdtResult out = PENDING_SESSIONS.open(id); + b.prepareRangingSession(id, handle, role != RangingRole.CONTROLEE); + return out; + } + + // ------------------------------------------------------------------ + // Port entry points + // ------------------------------------------------------------------ + + /// Answers [#requestPermissions]. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `granted`: whether every requested permission was granted + public static void deliverPermissionResult(int requestId, + boolean granted) { + EdtResult r = PENDING_PERMISSIONS.take(requestId); + if (r != null) { + r.complete(Boolean.valueOf(granted)); + } + } + + /// Answers [#prepareSession]. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `sessionHandle`: the handle passed to + /// `NearbyBridge#prepareRangingSession` + /// - `role`: true when this session is the controller + /// - `tokenPlatform`: one of the `RangingToken.PLATFORM_` constants + /// - `tokenPayload`: the native token bytes + public static void deliverSessionPrepared(int requestId, + int sessionHandle, boolean role, int tokenPlatform, + byte[] tokenPayload) { + EdtResult r = PENDING_SESSIONS.take(requestId); + if (r == null) { + // Nobody is waiting: the caller cancelled, or a port answered + // twice. Release the radio rather than leaking the session. + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.stopRangingSession(sessionHandle); + } + return; + } + RangingSession session = RangingSession.create(sessionHandle, + role ? RangingRole.CONTROLLER : RangingRole.CONTROLEE, + RangingToken.forPayload(tokenPlatform, tokenPayload)); + r.complete(session); + } + + /// Answers [RangingSession#start]. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `sessionHandle`: the session that started + public static void deliverSessionStarted(int requestId, + int sessionHandle) { + EdtResult r = PENDING_SESSIONS.take(requestId); + if (r != null) { + RangingSession s = RangingSession.lookup(sessionHandle); + if (s == null) { + r.error(new NearbyException(NearbyError.SESSION_INVALIDATED, + "the session was closed before it started")); + } else { + s.markRunning(); + r.complete(s); + } + } + } + + /// Answers [RangingSession#startAccessory]. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `sessionHandle`: the session that started + /// - `shareableConfiguration`: the bytes to send back to the accessory, + /// empty where the platform needs no handshake + public static void deliverAccessoryConfiguration(int requestId, + int sessionHandle, byte[] shareableConfiguration) { + EdtResult r = PENDING_ACCESSORY.take(requestId); + if (r != null) { + RangingSession s = RangingSession.lookup(sessionHandle); + if (s != null) { + s.markRunning(); + } + r.complete(shareableConfiguration == null + ? new byte[0] : shareableConfiguration); + } + } + + /// Fails whichever ranging request carries this id. + /// + /// The id is looked up in each of the pending maps in turn; because ids + /// come from one counter it can be in at most one of them. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `errorOrdinal`: the ordinal of a + /// `com.codename1.nearby.NearbyError` constant + /// - `message`: a human-readable detail, may be null + public static void deliverRequestFailed(int requestId, int errorOrdinal, + String message) { + NearbyException ex = toException(errorOrdinal, message); + EdtResult s = PENDING_SESSIONS.take(requestId); + if (s != null) { + s.error(ex); + return; + } + EdtResult a = PENDING_ACCESSORY.take(requestId); + if (a != null) { + a.error(ex); + return; + } + EdtResult p = PENDING_PERMISSIONS.take(requestId); + if (p != null) { + p.error(ex); + } + } + + + /// Clears every in-flight request, so one test cannot see the requests of + /// the test that ran before it. Reached through + /// `com.codename1.impl.nearby.NearbyRequests#resetForTest`. + /// + /// In-flight requests are failed rather than dropped: a resource that + /// never settles is worse than one that fails, and a test holding one + /// would hang rather than report. + /// + /// @hidden not part of the public API; test-only. + public static void resetForTest() { + NearbyException reset = new NearbyException(NearbyError.UNKNOWN, + "the nearby framework was reset"); + PENDING_PERMISSIONS.failAll(reset); + PENDING_SESSIONS.failAll(reset); + PENDING_ACCESSORY.failAll(reset); + } + + // ------------------------------------------------------------------ + // Internals shared with RangingSession + // ------------------------------------------------------------------ + + static PendingMap pendingSessions() { + return PENDING_SESSIONS; + } + + static PendingMap pendingAccessory() { + return PENDING_ACCESSORY; + } + + static NearbyException toException(int errorOrdinal, String message) { + NearbyError[] all = NearbyError.values(); + NearbyError e = errorOrdinal >= 0 && errorOrdinal < all.length + ? all[errorOrdinal] : NearbyError.UNKNOWN; + return new NearbyException(e, + message == null ? e.name() : message); + } + + private static NearbyAvailability fromOrdinal(int ordinal) { + NearbyAvailability[] all = NearbyAvailability.values(); + if (ordinal < 0 || ordinal >= all.length) { + return NearbyAvailability.NOT_SUPPORTED; + } + return all[ordinal]; + } + + private static int permissionBit(NearbyPermission p) { + if (p == NearbyPermission.RANGING) { + return NearbyBridge.PERMISSION_RANGING; + } + if (p == NearbyPermission.DISCOVERY) { + return NearbyBridge.PERMISSION_DISCOVERY; + } + if (p == NearbyPermission.ADVERTISE) { + return NearbyBridge.PERMISSION_ADVERTISE; + } + if (p == NearbyPermission.CONNECT) { + return NearbyBridge.PERMISSION_CONNECT; + } + return 0; + } + + private static AsyncResource failedBoolean() { + EdtResult out = new EdtResult(); + out.error(new NearbyException(NearbyError.NOT_SUPPORTED, + "this platform does not support precision ranging")); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java new file mode 100644 index 00000000000..9d4bca3505b --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java @@ -0,0 +1,55 @@ +/* + * 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.nearby.ranging; + +import com.codename1.nearby.NearbyException; + +/// A [RangingListener] whose methods all do nothing, so a caller interested +/// in one event overrides one method. +/// +/// ```java +/// session.addRangingListener(new RangingAdapter() { +/// public void updated(RangingUpdate u) { +/// if (u.hasDistance()) { +/// label.setText(Math.round(u.getDistance(RangingUnit.CENTIMETERS)) + " cm"); +/// } +/// } +/// }); +/// ``` +public class RangingAdapter implements RangingListener { + + public void updated(RangingUpdate update) { + } + + public void peerRemoved(RangingRemovalReason reason) { + } + + public void suspended() { + } + + public void resumed() { + } + + public void invalidated(NearbyException error) { + } +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java new file mode 100644 index 00000000000..45534394012 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java @@ -0,0 +1,116 @@ +/* + * 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.nearby.ranging; + +/// What the current device can actually measure. Ask this before building a +/// UI: a device may support distance but not direction, which is the common +/// case on Android hardware and on any iPhone whose peer is behind it. +/// +/// [#UNSUPPORTED] is an all-false instance returned where ranging is absent, +/// so calling code needs no null check. +public final class RangingCapabilities { + + /// All-false capabilities, returned by [Ranging#getCapabilities()] on a + /// platform or device with no UWB at all. + public static final RangingCapabilities UNSUPPORTED = + new RangingCapabilities(false, false, false, false, false, false); + + private final boolean distance; + private final boolean direction; + private final boolean elevation; + private final boolean cameraAssistance; + private final boolean accessoryRanging; + private final boolean backgroundRanging; + + /// Ports construct this; application code reads it from + /// [Ranging#getCapabilities()]. + /// + /// #### Parameters + /// + /// - `distance`: precise distance measurement is available + /// - `direction`: horizontal direction (azimuth) is available + /// - `elevation`: vertical direction (elevation) is available + /// - `cameraAssistance`: camera assistance can sharpen direction + /// - `accessoryRanging`: third-party UWB accessories can be ranged + /// - `backgroundRanging`: a session may keep running in the background + public RangingCapabilities(boolean distance, boolean direction, + boolean elevation, boolean cameraAssistance, + boolean accessoryRanging, boolean backgroundRanging) { + this.distance = distance; + this.direction = direction; + this.elevation = elevation; + this.cameraAssistance = cameraAssistance; + this.accessoryRanging = accessoryRanging; + this.backgroundRanging = backgroundRanging; + } + + /// `true` when the device can measure distance to a peer. This is the + /// baseline capability: a device that answers `false` here has no usable + /// UWB radio and [Ranging#isSupported()] will also be `false`. + public boolean isDistanceSupported() { + return distance; + } + + /// `true` when the device can report the horizontal direction to a peer. + /// Both platforms only produce a direction while the peer is roughly in + /// front of the device, so an update may still omit it -- always check + /// [RangingUpdate#hasDirection()] as well. + public boolean isDirectionSupported() { + return direction; + } + + /// `true` when the device can report elevation as well as azimuth. + public boolean isElevationSupported() { + return elevation; + } + + /// `true` when the platform can use the camera to converge on a sharper + /// direction. iOS only, and only while an AR session is running; the + /// Codename One API does not turn it on by itself. + public boolean isCameraAssistanceSupported() { + return cameraAssistance; + } + + /// `true` when third-party UWB accessories can be ranged, as opposed to + /// only other phones. See [RangingSession#startAccessory]. + public boolean isAccessoryRangingSupported() { + return accessoryRanging; + } + + /// `true` when a session may keep delivering updates while the app is in + /// the background. On iOS this additionally requires the + /// `com.apple.developer.nearby-interaction` entitlement, which Codename + /// One never injects on its own -- see the developer guide. + public boolean isBackgroundRangingSupported() { + return backgroundRanging; + } + + public String toString() { + return "RangingCapabilities[distance=" + distance + + ", direction=" + direction + + ", elevation=" + elevation + + ", cameraAssistance=" + cameraAssistance + + ", accessoryRanging=" + accessoryRanging + + ", backgroundRanging=" + backgroundRanging + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingListener.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingListener.java new file mode 100644 index 00000000000..efab5f25c67 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingListener.java @@ -0,0 +1,67 @@ +/* + * 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.nearby.ranging; + +import com.codename1.nearby.NearbyException; + +/// Receives everything a [RangingSession] has to say. Every method is called +/// on the EDT. +/// +/// Most apps only care about [#updated]; extend [RangingAdapter] rather than +/// implementing the whole interface. +public interface RangingListener { + + /// A fresh measurement arrived. Expect these several times a second + /// while the peer is in range, and expect individual fields to drop in + /// and out -- see [RangingUpdate]. + /// + /// #### Parameters + /// + /// - `update`: the measurement + void updated(RangingUpdate update); + + /// The peer stopped being ranged. The session stays alive and will + /// resume delivering updates if the peer comes back, so this is a cue + /// to gray the UI out rather than to tear it down. + /// + /// #### Parameters + /// + /// - `reason`: why the peer went away + void peerRemoved(RangingRemovalReason reason); + + /// The platform paused the session -- typically because the app went to + /// the background without the entitlement that would let it keep + /// ranging. No updates arrive until [#resumed] fires. + void suspended(); + + /// A suspended session started running again. + void resumed(); + + /// The session died and cannot be restarted. Any further call on it + /// fails; prepare a new session if the feature is still wanted. + /// + /// #### Parameters + /// + /// - `error`: why the session ended + void invalidated(NearbyException error); +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingRemovalReason.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingRemovalReason.java new file mode 100644 index 00000000000..a8f763e1615 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingRemovalReason.java @@ -0,0 +1,38 @@ +/* + * 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.nearby.ranging; + +/// Why a peer stopped being ranged, delivered to +/// [RangingListener#peerRemoved]. +public enum RangingRemovalReason { + /// The peer ended its side of the session deliberately. + PEER_ENDED, + + /// The peer stopped responding -- moved out of range, went to sleep or + /// lost its radio. On both platforms this is the ordinary "walked away" + /// case. + TIMEOUT, + + /// Unclassified. + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingRole.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingRole.java new file mode 100644 index 00000000000..3924160f167 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingRole.java @@ -0,0 +1,42 @@ +/* + * 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.nearby.ranging; + +/// Which end of a UWB session this device is. +/// +/// The distinction is real on Android, where the controller owns the +/// channel and session parameters and the controlee joins them, and it +/// decides which side has to publish a token first. It is invisible on iOS: +/// Nearby Interaction negotiates the roles itself, both peers publish a +/// discovery token, and the value passed here is ignored. Code that will run +/// on both should still pick a role -- one side controller, the other +/// controlee -- because that costs nothing on iOS and is required on +/// Android. +public enum RangingRole { + /// This device chooses the channel and session parameters and publishes + /// them; peers join. Exactly one side of a session is the controller. + CONTROLLER, + + /// This device joins a session whose parameters the controller chose. + CONTROLEE +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java new file mode 100644 index 00000000000..de19fca4112 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java @@ -0,0 +1,432 @@ +/* + * 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.nearby.ranging; + +import com.codename1.impl.async.EdtResult; +import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.NearbyException; +import com.codename1.nearby.spi.NearbyBridge; +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.AtomicInteger; + +/// One ranging conversation with one peer or accessory, obtained from +/// [Ranging#prepareSession]. +/// +/// A prepared session has a [#getLocalToken()] to publish and is not yet +/// using the radio. It starts measuring when [#start] or [#startAccessory] +/// is called and keeps going until [#stop()], until the platform +/// invalidates it, or until the app exits. +/// +/// Sessions are not reusable: once stopped or invalidated, prepare another. +public final class RangingSession { + + private static final AtomicInteger NEXT_HANDLE = new AtomicInteger(1); + private static final Map SESSIONS = + new HashMap(); + + private final int handle; + private final RangingRole role; + private final RangingToken localToken; + private final List listeners = + new ArrayList(); + private boolean running; + private boolean closed; + private boolean starting; + + private RangingSession(int handle, RangingRole role, + RangingToken localToken) { + this.handle = handle; + this.role = role; + this.localToken = localToken; + } + + /// The token to publish so the peer can range against this device. Never + /// null, and available as soon as the session is prepared. + /// + /// #### Returns + /// + /// this device's token for this session + public RangingToken getLocalToken() { + return localToken; + } + + /// Which end of the session this device is. + /// + /// #### Returns + /// + /// the role this session was prepared with + public RangingRole getRole() { + return role; + } + + /// `true` while the radio is measuring. False before [#start] and after + /// [#stop()], and false while the session is suspended. + public boolean isRunning() { + return running; + } + + /// Starts ranging against a peer whose token arrived out of band. + /// + /// #### Parameters + /// + /// - `peerToken`: the peer's token, decoded from the bytes they + /// published + /// + /// #### Returns + /// + /// resolves with this session once the radio is measuring, or fails + /// with a [NearbyException] + public AsyncResource start(RangingToken peerToken) { + if (peerToken == null) { + return failedSession(NearbyError.INVALID_TOKEN, + "a peer token is required"); + } + NearbyException busy = checkStartable(); + if (busy != null) { + EdtResult out = new EdtResult(); + out.error(busy); + return out; + } + NearbyBridge b = NearbyRequests.bridge(); + int id = NearbyRequests.nextId(); + EdtResult out = Ranging.pendingSessions().open(id); + starting = true; + b.startRanging(id, handle, peerToken.toByteArray()); + return out; + } + + /// Starts ranging against a third-party UWB accessory. + /// + /// The accessory publishes a blob of configuration data over its own + /// channel -- in practice a GATT characteristic. Hand those bytes here, + /// and send whatever this resolves with back to the accessory: Apple's + /// Nearby Interaction Accessory Protocol needs that second half of the + /// handshake before the accessory begins ranging. + /// + /// Android has no equivalent protocol. There, an accessory simply names + /// the channel and session to join, so build a token with + /// [RangingToken#forUwbAddress] and call [#start] instead; this method + /// fails with [NearbyError#NOT_SUPPORTED]. + /// + /// #### Parameters + /// + /// - `accessoryConfigurationData`: what the accessory published + /// + /// #### Returns + /// + /// resolves with the bytes to send back to the accessory, empty where + /// the platform needs no handshake + public AsyncResource startAccessory( + byte[] accessoryConfigurationData) { + if (accessoryConfigurationData == null + || accessoryConfigurationData.length == 0) { + EdtResult out = new EdtResult(); + out.error(new NearbyException(NearbyError.INVALID_TOKEN, + "accessory configuration data is required")); + return out; + } + NearbyException busy = checkStartable(); + if (busy != null) { + EdtResult out = new EdtResult(); + out.error(busy); + return out; + } + NearbyBridge b = NearbyRequests.bridge(); + int id = NearbyRequests.nextId(); + EdtResult out = Ranging.pendingAccessory().open(id); + starting = true; + b.startAccessoryRanging(id, handle, accessoryConfigurationData); + return out; + } + + /// Stops measuring and releases the radio. Idempotent, and safe to call + /// on a session that never started. No further listener callback + /// arrives afterwards. + public void stop() { + boolean wasOpen; + synchronized (SESSIONS) { + wasOpen = !closed; + closed = true; + running = false; + SESSIONS.remove(Integer.valueOf(handle)); + } + if (wasOpen) { + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.stopRangingSession(handle); + } + } + synchronized (listeners) { + listeners.clear(); + } + } + + /// Registers a listener. Callbacks arrive on the EDT. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public void addRangingListener(RangingListener l) { + if (l == null) { + return; + } + synchronized (listeners) { + listeners.add(l); + } + } + + /// Removes a listener added by [#addRangingListener]. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public void removeRangingListener(RangingListener l) { + synchronized (listeners) { + listeners.remove(l); + } + } + + // ------------------------------------------------------------------ + // Port entry points + // ------------------------------------------------------------------ + + /// Delivers one measurement. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session the measurement belongs to + /// - `hasDistance`: whether a distance was measured + /// - `distanceMeters`: the distance in metres + /// - `hasDirection`: whether an azimuth was measured + /// - `azimuth`: the horizontal angle in degrees + /// - `hasElevation`: whether an elevation was measured + /// - `elevation`: the vertical angle in degrees + /// - `vector`: the raw unit direction vector, or null + public static void deliverUpdate(int sessionHandle, boolean hasDistance, + double distanceMeters, boolean hasDirection, double azimuth, + boolean hasElevation, double elevation, float[] vector) { + final RangingSession s = lookup(sessionHandle); + if (s == null) { + return; + } + final RangingUpdate u = new RangingUpdate(hasDistance, distanceMeters, + hasDirection, azimuth, hasElevation, elevation, vector, + System.currentTimeMillis()); + NearbyRequests.onEdt(new Runnable() { + public void run() { + s.running = true; + RangingListener[] ls = s.snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].updated(u); + } + } + }); + } + + /// Reports that the peer stopped being ranged. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session + /// - `reasonOrdinal`: the ordinal of a [RangingRemovalReason] constant + public static void deliverPeerRemoved(int sessionHandle, + int reasonOrdinal) { + final RangingSession s = lookup(sessionHandle); + if (s == null) { + return; + } + RangingRemovalReason[] all = RangingRemovalReason.values(); + final RangingRemovalReason reason = + reasonOrdinal >= 0 && reasonOrdinal < all.length + ? all[reasonOrdinal] : RangingRemovalReason.UNKNOWN; + NearbyRequests.onEdt(new Runnable() { + public void run() { + RangingListener[] ls = s.snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].peerRemoved(reason); + } + } + }); + } + + /// Reports that the platform paused the session. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session + public static void deliverSuspended(int sessionHandle) { + final RangingSession s = lookup(sessionHandle); + if (s == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + s.running = false; + RangingListener[] ls = s.snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].suspended(); + } + } + }); + } + + /// Reports that a suspended session resumed. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session + public static void deliverResumed(int sessionHandle) { + final RangingSession s = lookup(sessionHandle); + if (s == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + s.running = true; + RangingListener[] ls = s.snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].resumed(); + } + } + }); + } + + /// Reports that the session died and cannot be restarted. The session is + /// deregistered, so this is the last event it produces. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session + /// - `errorOrdinal`: the ordinal of a `com.codename1.nearby.NearbyError` + /// constant + /// - `message`: a human-readable detail, may be null + public static void deliverInvalidated(int sessionHandle, int errorOrdinal, + String message) { + final RangingSession s; + synchronized (SESSIONS) { + s = SESSIONS.remove(Integer.valueOf(sessionHandle)); + } + if (s == null) { + return; + } + final NearbyException ex = Ranging.toException(errorOrdinal, message); + NearbyRequests.onEdt(new Runnable() { + public void run() { + s.running = false; + s.closed = true; + RangingListener[] ls = s.snapshot(); + synchronized (s.listeners) { + s.listeners.clear(); + } + for (int i = 0; i < ls.length; i++) { + ls[i].invalidated(ex); + } + } + }); + } + + + /// Forgets every session, so one test cannot see the sessions of the test + /// that ran before it. Reached through + /// `com.codename1.impl.nearby.NearbyRequests#resetForTest`. + /// + /// @hidden not part of the public API; test-only. + public static void resetForTest() { + synchronized (SESSIONS) { + SESSIONS.clear(); + } + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + static int nextHandle() { + return NEXT_HANDLE.getAndIncrement(); + } + + static RangingSession create(int handle, RangingRole role, + RangingToken localToken) { + RangingSession s = new RangingSession(handle, role, localToken); + synchronized (SESSIONS) { + SESSIONS.put(Integer.valueOf(handle), s); + } + return s; + } + + static RangingSession lookup(int handle) { + synchronized (SESSIONS) { + return SESSIONS.get(Integer.valueOf(handle)); + } + } + + void markRunning() { + running = true; + starting = false; + } + + private RangingListener[] snapshot() { + synchronized (listeners) { + return listeners.toArray(new RangingListener[listeners.size()]); + } + } + + private NearbyException checkStartable() { + if (closed) { + return new NearbyException(NearbyError.SESSION_INVALIDATED, + "this session has been stopped; prepare another"); + } + if (running || starting) { + return new NearbyException(NearbyError.BUSY, + "this session is already ranging"); + } + if (NearbyRequests.bridge() == null) { + return new NearbyException(NearbyError.NOT_SUPPORTED, + "this platform does not support precision ranging"); + } + return null; + } + + private AsyncResource failedSession(NearbyError error, + String message) { + EdtResult out = new EdtResult(); + out.error(new NearbyException(error, message)); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java new file mode 100644 index 00000000000..a847e9b7fec --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java @@ -0,0 +1,252 @@ +/* + * 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.nearby.ranging; + +/// The handle one device publishes so another can range against it. +/// +/// A token is **opaque and platform-specific**. On iOS it wraps an archived +/// `NIDiscoveryToken`; on Android it carries the controller's UWB address, +/// complex channel, session id and session key. There is no cross-platform +/// UWB ranging on either OS, so a token is never portable between them -- +/// [#fromByteArray] rejects a token minted by a different platform with a +/// clear failure rather than handing garbage to a native call. +/// +/// What the token *is* for is the out-of-band exchange both platforms +/// require: prepare a session, publish [#toByteArray()] over some other +/// channel the two devices already share -- a GATT characteristic from +/// `com.codename1.bluetooth` is the usual one -- and feed what comes back +/// into [RangingSession#start]. +/// +/// ```java +/// byte[] mine = session.getLocalToken().toByteArray(); +/// characteristic.writeValue(mine); +/// // ... later, when the peer's token arrives ... +/// session.start(RangingToken.fromByteArray(theirs)); +/// ``` +public final class RangingToken { + + /// Token minted by Apple's Nearby Interaction, wrapping an archived + /// `NIDiscoveryToken`. + public static final int PLATFORM_APPLE_NI = 1; + + /// Token minted by the Android UWB stack, carrying address, channel, + /// session id and key. + public static final int PLATFORM_ANDROID_UWB = 2; + + /// Token minted by the simulated implementation used on the desktop + /// ports, the simulator and the JavaScript port. + public static final int PLATFORM_SIMULATED = 3; + + private static final byte[] MAGIC = {'C', 'N', '1', 'R'}; + private static final int VERSION = 1; + + private final int platform; + private final byte[] payload; + + private RangingToken(int platform, byte[] payload) { + this.platform = platform; + this.payload = payload; + } + + /// Builds an Android-shaped token from parameters a third-party UWB + /// accessory reported out of band. + /// + /// Android has no equivalent of Apple's Nearby Interaction Accessory + /// Protocol, so an accessory there simply tells the phone which channel + /// and session to join and the phone joins it -- that is what this + /// builds. On iOS, use [RangingSession#startAccessory] with the + /// accessory's configuration data instead; a token built here is + /// rejected there. + /// + /// #### Parameters + /// + /// - `address`: the accessory's UWB MAC address, 2 or 8 bytes + /// - `channel`: the UWB channel number + /// - `preambleIndex`: the preamble index that goes with the channel + /// - `sessionId`: the session id both ends agreed on + /// - `sessionKey`: the session key, or `null` for an unprovisioned + /// session + /// + /// #### Returns + /// + /// a token that [RangingSession#start] accepts on Android + public static RangingToken forUwbAddress(byte[] address, int channel, + int preambleIndex, int sessionId, byte[] sessionKey) { + if (address == null || (address.length != 2 && address.length != 8)) { + throw new IllegalArgumentException( + "a UWB address is 2 or 8 bytes"); + } + byte[] key = sessionKey == null ? new byte[0] : sessionKey; + byte[] out = new byte[4 + address.length + 12 + 4 + key.length]; + int p = 0; + p = writeInt(out, p, address.length); + System.arraycopy(address, 0, out, p, address.length); + p += address.length; + p = writeInt(out, p, channel); + p = writeInt(out, p, preambleIndex); + p = writeInt(out, p, sessionId); + p = writeInt(out, p, key.length); + System.arraycopy(key, 0, out, p, key.length); + return new RangingToken(PLATFORM_ANDROID_UWB, out); + } + + /// Rebuilds a token from the bytes [#toByteArray()] produced, typically + /// after they travelled to this device over Bluetooth. + /// + /// #### Parameters + /// + /// - `data`: the encoded token + /// + /// #### Returns + /// + /// the decoded token + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the bytes are not a Codename One + /// ranging token, or carry a version this build does not understand + public static RangingToken fromByteArray(byte[] data) { + if (data == null || data.length < 10) { + throw new IllegalArgumentException("not a ranging token"); + } + for (int i = 0; i < MAGIC.length; i++) { + if (data[i] != MAGIC[i]) { + throw new IllegalArgumentException("not a ranging token"); + } + } + if ((data[4] & 0xff) != VERSION) { + throw new IllegalArgumentException( + "unsupported ranging token version " + (data[4] & 0xff)); + } + int plat = data[5] & 0xff; + int len = readInt(data, 6); + if (len < 0 || 10 + len > data.length) { + throw new IllegalArgumentException("truncated ranging token"); + } + byte[] payload = new byte[len]; + System.arraycopy(data, 10, payload, 0, len); + return new RangingToken(plat, payload); + } + + /// The encoded form to hand to the peer. Self-describing, so the + /// receiving side can tell a corrupt or foreign token from a usable one. + /// + /// #### Returns + /// + /// a fresh byte array; mutating it does not affect this token + public byte[] toByteArray() { + byte[] out = new byte[10 + payload.length]; + System.arraycopy(MAGIC, 0, out, 0, MAGIC.length); + out[4] = (byte) VERSION; + out[5] = (byte) platform; + writeInt(out, 6, payload.length); + System.arraycopy(payload, 0, out, 10, payload.length); + return out; + } + + /// Which platform minted this token -- one of [#PLATFORM_APPLE_NI], + /// [#PLATFORM_ANDROID_UWB] or [#PLATFORM_SIMULATED]. Useful for telling + /// the user that the device they are pointing at is the wrong kind, + /// rather than letting the session fail with + /// `NearbyError.INVALID_TOKEN`. + public int getPlatform() { + return platform; + } + + /// The platform payload, without the framing. + /// + /// @hidden not part of the public API; ports read this to reach the + /// native token. + /// + /// #### Returns + /// + /// a fresh copy of the payload bytes + public byte[] getPayload() { + byte[] copy = new byte[payload.length]; + System.arraycopy(payload, 0, copy, 0, payload.length); + return copy; + } + + /// Wraps a native payload in a token. + /// + /// @hidden not part of the public API; ports call this to publish the + /// local token. + /// + /// #### Parameters + /// + /// - `platform`: one of the `PLATFORM_` constants + /// - `payload`: the native payload bytes + /// + /// #### Returns + /// + /// the wrapped token + public static RangingToken forPayload(int platform, byte[] payload) { + return new RangingToken(platform, + payload == null ? new byte[0] : payload); + } + + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RangingToken)) { + return false; + } + RangingToken t = (RangingToken) o; + if (t.platform != platform || t.payload.length != payload.length) { + return false; + } + for (int i = 0; i < payload.length; i++) { + if (t.payload[i] != payload[i]) { + return false; + } + } + return true; + } + + public int hashCode() { + int h = platform; + for (int i = 0; i < payload.length; i++) { + h = h * 31 + payload[i]; + } + return h; + } + + public String toString() { + return "RangingToken[platform=" + platform + + ", " + payload.length + " bytes]"; + } + + private static int writeInt(byte[] b, int p, int v) { + b[p] = (byte) ((v >> 24) & 0xff); + b[p + 1] = (byte) ((v >> 16) & 0xff); + b[p + 2] = (byte) ((v >> 8) & 0xff); + b[p + 3] = (byte) (v & 0xff); + return p + 4; + } + + private static int readInt(byte[] b, int p) { + return ((b[p] & 0xff) << 24) | ((b[p + 1] & 0xff) << 16) + | ((b[p + 2] & 0xff) << 8) | (b[p + 3] & 0xff); + } +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java new file mode 100644 index 00000000000..96c87ac16f1 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java @@ -0,0 +1,75 @@ +/* + * 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.nearby.ranging; + +/// The unit a distance is read in. +/// +/// There is deliberately no zero-argument distance getter on +/// [RangingUpdate]: the caller always names the unit. Both platforms report +/// metres natively, so a bare `getDistance()` would have been correct on +/// every device and still wrong in every app that displayed it as feet. +public enum RangingUnit { + /// Metres, the unit both platforms measure in. + METERS(1.0), + + /// International feet, 0.3048 m exactly. + FEET(0.3048), + + /// Centimetres. + CENTIMETERS(0.01), + + /// International inches, 0.0254 m exactly. + INCHES(0.0254); + + private final double metersPerUnit; + + private RangingUnit(double metersPerUnit) { + this.metersPerUnit = metersPerUnit; + } + + /// Converts a distance expressed in metres into this unit. + /// + /// #### Parameters + /// + /// - `meters`: the distance in metres + /// + /// #### Returns + /// + /// the same distance expressed in this unit + public double fromMeters(double meters) { + return meters / metersPerUnit; + } + + /// Converts a distance expressed in this unit into metres. + /// + /// #### Parameters + /// + /// - `value`: the distance in this unit + /// + /// #### Returns + /// + /// the same distance in metres + public double toMeters(double value) { + return value * metersPerUnit; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java new file mode 100644 index 00000000000..d60a75830d6 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java @@ -0,0 +1,181 @@ +/* + * 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.nearby.ranging; + +/// One measurement of where the peer is, delivered to +/// [RangingListener#updated] on the EDT. +/// +/// Every field except the timestamp is optional, and they drop out +/// independently: a peer directly behind the phone commonly reports a +/// distance with no direction, and a peer at the edge of range reports +/// neither. Guard each read with its `has` method rather than assuming a +/// sentinel value. +/// +/// ```java +/// public void updated(RangingUpdate u) { +/// if (u.hasDistance()) { +/// label.setText(String.format("%.1f m", u.getDistance(RangingUnit.METERS))); +/// } +/// if (u.hasDirection()) { +/// arrow.setAngle(u.getAzimuth()); +/// } +/// } +/// ``` +public final class RangingUpdate { + + private final boolean hasDistance; + private final double distanceMeters; + private final boolean hasDirection; + private final double azimuth; + private final boolean hasElevation; + private final double elevation; + private final float[] vector; + private final long timestamp; + + /// Ports construct these; application code receives them through + /// [RangingListener]. + /// + /// #### Parameters + /// + /// - `hasDistance`: whether this update carries a distance + /// - `distanceMeters`: the distance in metres, ignored when + /// `hasDistance` is false + /// - `hasDirection`: whether this update carries an azimuth + /// - `azimuth`: horizontal angle in degrees, ignored when `hasDirection` + /// is false + /// - `hasElevation`: whether this update carries an elevation + /// - `elevation`: vertical angle in degrees, ignored when `hasElevation` + /// is false + /// - `vector`: the platform's raw unit direction vector, or `null` + /// - `timestamp`: `System.currentTimeMillis()` when the port received + /// the measurement + public RangingUpdate(boolean hasDistance, double distanceMeters, + boolean hasDirection, double azimuth, + boolean hasElevation, double elevation, + float[] vector, long timestamp) { + this.hasDistance = hasDistance; + this.distanceMeters = distanceMeters; + this.hasDirection = hasDirection; + this.azimuth = azimuth; + this.hasElevation = hasElevation; + this.elevation = elevation; + this.vector = vector == null ? null : new float[] { + vector[0], vector[1], vector[2] + }; + this.timestamp = timestamp; + } + + /// `true` when this update carries a distance measurement. + public boolean hasDistance() { + return hasDistance; + } + + /// The straight-line distance to the peer, in the unit you name. + /// + /// Undefined when [#hasDistance()] is `false` -- check first. There is + /// no zero-argument form on purpose; see [RangingUnit]. + /// + /// #### Parameters + /// + /// - `unit`: the unit to read the distance in + /// + /// #### Returns + /// + /// the distance expressed in `unit` + public double getDistance(RangingUnit unit) { + return unit.fromMeters(distanceMeters); + } + + /// `true` when this update carries a horizontal direction. + public boolean hasDirection() { + return hasDirection; + } + + /// The horizontal angle to the peer in degrees, in the range -180 to + /// 180. Zero is straight ahead -- out of the top of a phone held + /// upright -- and positive is to the right. + /// + /// Undefined when [#hasDirection()] is `false`. + /// + /// Android reports this angle directly. On iOS the platform reports a + /// unit direction vector instead and the port converts it with + /// `atan2(x, -z)`, which is the same convention; [#getDirectionVector()] + /// still hands back the untouched vector for code that wants it. + public double getAzimuth() { + return azimuth; + } + + /// `true` when this update carries a vertical direction. + public boolean hasElevation() { + return hasElevation; + } + + /// The vertical angle to the peer in degrees, in the range -90 to 90, + /// where positive is above the device. + /// + /// Undefined when [#hasElevation()] is `false`. Fewer devices report + /// elevation than azimuth, so this drops out on its own. + public double getElevation() { + return elevation; + } + + /// The platform's raw unit direction vector as `{x, y, z}` -- x to the + /// right, y up, z toward the user, so the forward direction is negative + /// z. iOS only; `null` everywhere else and `null` on iOS whenever + /// [#hasDirection()] is `false`. + /// + /// Prefer [#getAzimuth()] and [#getElevation()], which are derived from + /// this on iOS and reported natively on Android, so they work on both. + /// A fresh copy is returned each call. + public float[] getDirectionVector() { + return vector == null ? null : new float[] { + vector[0], vector[1], vector[2] + }; + } + + /// `System.currentTimeMillis()` at the moment the port received this + /// measurement. The platforms disagree on what clock their own + /// timestamps use -- Android reports elapsed realtime nanoseconds and + /// iOS reports nothing at all -- so this is stamped on arrival rather + /// than translated, and is comparable only with other values from this + /// same clock. + public long getTimestamp() { + return timestamp; + } + + public String toString() { + StringBuilder b = new StringBuilder("RangingUpdate["); + if (hasDistance) { + b.append("distance=").append(distanceMeters).append("m"); + } else { + b.append("distance=none"); + } + if (hasDirection) { + b.append(", azimuth=").append(azimuth); + } + if (hasElevation) { + b.append(", elevation=").append(elevation); + } + return b.append(']').toString(); + } +} diff --git a/CodenameOne/src/com/codename1/nearby/ranging/package-info.java b/CodenameOne/src/com/codename1/nearby/ranging/package-info.java new file mode 100644 index 00000000000..23047fd536f --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/package-info.java @@ -0,0 +1,50 @@ +/* + * 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. + */ +/// Ultra-wideband precision ranging: how far away another device is, and in +/// which direction. +/// +/// UWB measures distance by timing a radio round trip, which is worth about +/// ten centimetres. That is a different kind of answer from a Bluetooth +/// signal-strength estimate, which is worth a few metres on a good day and +/// swings wildly when someone puts a hand over the phone -- so this is what +/// makes "unlock as I walk up to the door" and "point me at my bag" work at +/// all. +/// +/// Start at [Ranging]. The shape of a session, and why it takes two steps, +/// is documented there. +/// +/// #### What it costs to reference this package +/// +/// On iOS the build links NearbyInteraction.framework and injects the two +/// Nearby Interaction privacy strings. On Android it adds the +/// `androidx.core.uwb` dependency and the `UWB_RANGING` permission, and +/// declares the UWB hardware feature as optional so the app still installs +/// on devices without the radio. +/// +/// #### Hardware, not just platform +/// +/// [Ranging#isSupported()] answers `false` on plenty of current phones -- +/// iPhones before the 11, and most Android devices. Treat ranging as an +/// enhancement to a feature that also works without it rather than as the +/// feature itself. +package com.codename1.nearby.ranging; diff --git a/CodenameOne/src/com/codename1/nearby/spi/NearbyBridge.java b/CodenameOne/src/com/codename1/nearby/spi/NearbyBridge.java new file mode 100644 index 00000000000..d7d10bd89f4 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/spi/NearbyBridge.java @@ -0,0 +1,391 @@ +/* + * 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.nearby.spi; + +/// Internal service-provider interface implemented by each platform port to +/// carry the `com.codename1.nearby` API onto the native short-range stacks: +/// Apple's Nearby Interaction, MultipeerConnectivity and AccessorySetupKit, +/// Android's Jetpack UWB, Nearby Connections and `CompanionDeviceManager`. +/// +/// Application code never touches this interface. It is obtained by the +/// `com.codename1.nearby` packages from +/// `com.codename1.ui.Display#getNearbyBridge()`, and the base +/// implementation returns `null` -- which is why the public API degrades to +/// a well-behaved `NOT_SUPPORTED` on ports that implement nothing, and why +/// application code needs no platform `if` statements. +/// +/// #### Everything here is primitives, strings and byte arrays +/// +/// A port may be Objective-C reached through ParparVM, where constructing a +/// Java object is expensive and easy to get wrong. So no method on this +/// interface takes or returns a framework type: enums cross as their +/// ordinals, capability sets cross as bit masks, and structured records +/// cross as tab-delimited strings built by +/// `com.codename1.impl.nearby.NearbyWire`. +/// +/// #### Asynchrony is by request id, and every operation must answer +/// +/// Operations that can fail take a `requestId` allocated by the caller and +/// answer exactly once by calling the matching `deliver...` entry point on +/// the public class. **An operation that never answers is worse than one +/// that fails**: the caller holds an `AsyncResource` that will never settle +/// and has no way to find out. A port that cannot start something must +/// still report the failure. +/// +/// Unsolicited events -- ranging updates, endpoint discoveries, presence +/// changes -- carry the handle or endpoint id they belong to instead of a +/// request id. Every entry point may be called from any thread; they +/// marshal to the EDT themselves. +public interface NearbyBridge { + + /// [#getRangingCapabilities()] bit: precise distance is measurable. + int CAPABILITY_DISTANCE = 1; + + /// [#getRangingCapabilities()] bit: horizontal direction is measurable. + int CAPABILITY_DIRECTION = 2; + + /// [#getRangingCapabilities()] bit: vertical direction is measurable. + int CAPABILITY_ELEVATION = 4; + + /// [#getRangingCapabilities()] bit: camera assistance is available. + int CAPABILITY_CAMERA_ASSISTANCE = 8; + + /// [#getRangingCapabilities()] bit: third-party UWB accessories can be + /// ranged. + int CAPABILITY_ACCESSORY = 16; + + /// [#getRangingCapabilities()] bit: ranging continues in the background. + int CAPABILITY_BACKGROUND = 32; + + /// [#requestPermissions] bit for `NearbyPermission.RANGING`. + int PERMISSION_RANGING = 1; + + /// [#requestPermissions] bit for `NearbyPermission.DISCOVERY`. + int PERMISSION_DISCOVERY = 2; + + /// [#requestPermissions] bit for `NearbyPermission.ADVERTISE`. + int PERMISSION_ADVERTISE = 4; + + /// [#requestPermissions] bit for `NearbyPermission.CONNECT`. + int PERMISSION_CONNECT = 8; + + /// [#sendPayload] type: the payload is the `bytes` argument. + int PAYLOAD_BYTES = 0; + + /// [#sendPayload] type: the payload is the file at `path`. + int PAYLOAD_FILE = 1; + + // ------------------------------------------------------------------ + // Shared + // ------------------------------------------------------------------ + + /// Whether this port implements precision ranging at all. Answer for the + /// port and the hardware, not for whether a peer is around. + /// + /// #### Returns + /// + /// true when `com.codename1.nearby.ranging` has a real implementation + boolean isRangingSupported(); + + /// Whether this port implements companion-device association. + /// + /// #### Returns + /// + /// true when `com.codename1.nearby.companion` has a real implementation + boolean isCompanionSupported(); + + /// Whether this port implements the nearby transport. + /// + /// #### Returns + /// + /// true when `com.codename1.nearby.transport` has a real implementation + boolean isTransportSupported(); + + /// How usable ranging is right now, as a `NearbyAvailability` ordinal. + /// A port backed by a simulation must answer `LOCAL_ONLY` rather than + /// `AVAILABLE`, so an app can tell the developer their peers are not + /// real. + /// + /// #### Returns + /// + /// the ordinal of a `com.codename1.nearby.NearbyAvailability` constant + int getRangingAvailability(); + + /// How usable companion association is right now, as a + /// `NearbyAvailability` ordinal. + /// + /// #### Returns + /// + /// the ordinal of a `com.codename1.nearby.NearbyAvailability` constant + int getCompanionAvailability(); + + /// How usable the transport is right now, as a `NearbyAvailability` + /// ordinal. + /// + /// #### Returns + /// + /// the ordinal of a `com.codename1.nearby.NearbyAvailability` constant + int getTransportAvailability(); + + /// Requests the platform permissions behind the given + /// `NearbyPermission` bits, answering with + /// `com.codename1.nearby.ranging.Ranging#deliverPermissionResult`. + /// + /// A port that needs no permission for the bits it was given must still + /// answer, reporting them granted. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `permissionBits`: an OR of the `PERMISSION_` constants + void requestPermissions(int requestId, int permissionBits); + + // ------------------------------------------------------------------ + // Ranging + // ------------------------------------------------------------------ + + /// What the device can measure, as an OR of the `CAPABILITY_` constants. + /// + /// #### Returns + /// + /// the capability bits, or zero when ranging is unsupported + int getRangingCapabilities(); + + /// Allocates a platform ranging session and publishes its local token. + /// + /// The port answers with + /// `com.codename1.nearby.ranging.Ranging#deliverSessionPrepared` on + /// success, passing back the same `sessionHandle` it was given, or with + /// `Ranging#deliverRequestFailed` on failure. The session is not ranging + /// yet -- [#startRanging] or [#startAccessoryRanging] does that. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `sessionHandle`: the handle every later call and event uses to name + /// this session + /// - `controller`: true for `RangingRole.CONTROLLER`. Ports whose + /// platform negotiates roles by itself ignore this. + void prepareRangingSession(int requestId, int sessionHandle, + boolean controller); + + /// Starts ranging a peer whose token arrived out of band. + /// + /// The token is the full encoded form from + /// `com.codename1.nearby.ranging.RangingToken#toByteArray()`; the port + /// validates that it was minted by this platform and fails the request + /// with `NearbyError.INVALID_TOKEN` when it was not. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with, via + /// `Ranging#deliverSessionStarted` + /// - `sessionHandle`: the prepared session + /// - `peerToken`: the peer's encoded token + void startRanging(int requestId, int sessionHandle, byte[] peerToken); + + /// Starts ranging a third-party UWB accessory. + /// + /// The port answers with + /// `com.codename1.nearby.ranging.Ranging#deliverAccessoryConfiguration`, + /// passing the bytes the app must send back to the accessory to make it + /// start ranging (Apple's Nearby Interaction Accessory Protocol + /// requires this handshake). A platform with no such handshake answers + /// with an empty array rather than failing. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `sessionHandle`: the prepared session + /// - `accessoryData`: the configuration data the accessory published + void startAccessoryRanging(int requestId, int sessionHandle, + byte[] accessoryData); + + /// Tears a ranging session down and releases the radio. Idempotent, and + /// must not deliver any further event for this handle. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session to stop + void stopRangingSession(int sessionHandle); + + // ------------------------------------------------------------------ + // Companion device association + // ------------------------------------------------------------------ + + /// Runs the platform's device chooser and associates whatever the user + /// picks, answering with + /// `com.codename1.nearby.companion.CompanionDevices#deliverAssociated`. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `profile`: the ordinal of a + /// `com.codename1.nearby.companion.CompanionProfile` constant + /// - `singleDevice`: whether to associate without showing a list when + /// exactly one device matches + /// - `filters`: the encoded filters from + /// `com.codename1.impl.nearby.NearbyWire`, never null and possibly + /// empty + void associate(int requestId, int profile, boolean singleDevice, + String[] filters); + + /// Every association this app currently holds, each encoded by + /// `com.codename1.impl.nearby.NearbyWire`. + /// + /// #### Returns + /// + /// the associations, never null and possibly empty + String[] getAssociations(); + + /// Drops an association, answering with + /// `com.codename1.nearby.companion.CompanionDevices#deliverRequestFailed` + /// only on failure and + /// `CompanionDevices#deliverDisassociated` on success. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `associationId`: the association to drop + void disassociate(int requestId, String associationId); + + /// Asks the platform to wake this app when the associated device comes + /// and goes. + /// + /// #### Parameters + /// + /// - `associationId`: the association to watch + /// + /// #### Returns + /// + /// true when the platform accepted the request + boolean startObservingPresence(String associationId); + + /// Stops watching an association. Idempotent. + /// + /// #### Parameters + /// + /// - `associationId`: the association to stop watching + void stopObservingPresence(String associationId); + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + /// The largest byte payload [#sendPayload] accepts in one call. + /// + /// #### Returns + /// + /// the limit in bytes, or zero when the transport is unsupported + int getMaxPayloadSize(); + + /// Starts advertising this device under a service id, answering with + /// `com.codename1.nearby.transport.NearbyTransport#deliverRequestOk`. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `serviceId`: the service both ends agreed on + /// - `localName`: the name to show peers + /// - `strategy`: the ordinal of a + /// `com.codename1.nearby.transport.TransportStrategy` constant + void startAdvertising(int requestId, String serviceId, String localName, + int strategy); + + /// Stops advertising. Idempotent. + void stopAdvertising(); + + /// Starts looking for peers advertising the same service id, answering + /// with `NearbyTransport#deliverRequestOk`. Sightings arrive + /// unsolicited through `NearbyTransport#deliverEndpointFound`. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `serviceId`: the service both ends agreed on + /// - `strategy`: the ordinal of a + /// `com.codename1.nearby.transport.TransportStrategy` constant + void startDiscovery(int requestId, String serviceId, int strategy); + + /// Stops discovery. Idempotent. + void stopDiscovery(); + + /// Asks a discovered endpoint to connect. The endpoint answers by + /// accepting or rejecting, which arrives through + /// `NearbyTransport#deliverConnectionResult`. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `endpointId`: the endpoint to ask + /// - `localName`: the name to show them + void requestConnection(int requestId, String endpointId, String localName); + + /// Accepts an incoming connection request. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with + /// - `endpointId`: the endpoint that asked + void acceptConnection(int requestId, String endpointId); + + /// Rejects an incoming connection request. + /// + /// #### Parameters + /// + /// - `endpointId`: the endpoint that asked + void rejectConnection(String endpointId); + + /// Sends a payload to one or more connected endpoints, reporting + /// progress through `NearbyTransport#deliverPayloadProgress`. + /// + /// #### Parameters + /// + /// - `requestId`: the id to answer with once the payload is handed to + /// the platform + /// - `endpointIds`: the recipients + /// - `payloadId`: the id progress and cancellation use + /// - `payloadType`: [#PAYLOAD_BYTES] or [#PAYLOAD_FILE] + /// - `bytes`: the payload for [#PAYLOAD_BYTES], otherwise null + /// - `path`: the file for [#PAYLOAD_FILE], otherwise null + void sendPayload(int requestId, String[] endpointIds, int payloadId, + int payloadType, byte[] bytes, String path); + + /// Cancels an in-flight payload. Idempotent. + /// + /// #### Parameters + /// + /// - `payloadId`: the payload to cancel + void cancelPayload(int payloadId); + + /// Disconnects one endpoint. Idempotent. + /// + /// #### Parameters + /// + /// - `endpointId`: the endpoint to drop + void disconnect(String endpointId); + + /// Stops advertising and discovery and drops every connection. Called + /// when the app is shutting the transport down. + void stopAllTransport(); +} diff --git a/CodenameOne/src/com/codename1/nearby/spi/package-info.java b/CodenameOne/src/com/codename1/nearby/spi/package-info.java new file mode 100644 index 00000000000..75dc56d69af --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/spi/package-info.java @@ -0,0 +1,33 @@ +/* + * 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 service-provider interface each port implements to carry +/// `com.codename1.nearby` onto the platform's short-range stacks. +/// +/// Not part of the public API. Application code uses +/// [com.codename1.nearby.ranging.Ranging], +/// [com.codename1.nearby.companion.CompanionDevices] and +/// [com.codename1.nearby.transport.NearbyTransport]; those find the bridge +/// through `com.codename1.ui.Display#getNearbyBridge()`, and the base +/// implementation returns null so every port that implements nothing +/// degrades identically. +package com.codename1.nearby.spi; diff --git a/CodenameOne/src/com/codename1/nearby/transport/ConnectionRequest.java b/CodenameOne/src/com/codename1/nearby/transport/ConnectionRequest.java new file mode 100644 index 00000000000..4caf7dfb566 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/ConnectionRequest.java @@ -0,0 +1,111 @@ +/* + * 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.nearby.transport; + +import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.nearby.spi.NearbyBridge; + +/// An incoming request from another device that wants to connect, delivered +/// to [TransportListener#connectionRequested]. +/// +/// Answer it with [#accept()] or [#reject()]. A request that is never +/// answered times out on the far side, so answer every one -- and answer it +/// promptly, because both platforms hold radio resources open meanwhile. +/// +/// #### Show the token +/// +/// [#getAuthenticationToken()] is a short string both devices compute from +/// the connection, and it is the only defence against a device in the middle +/// pretending to be the one the user meant. Showing it on both screens and +/// asking "do these match?" is what makes the pairing trustworthy; skipping +/// that step is a choice to trust whoever answered first. +public final class ConnectionRequest { + + private final Endpoint endpoint; + private final String authenticationToken; + private boolean answered; + + /// Ports construct these. + /// + /// @hidden not part of the public API. + /// + /// #### Parameters + /// + /// - `endpoint`: who is asking + /// - `authenticationToken`: the short comparison string, never null + public ConnectionRequest(Endpoint endpoint, String authenticationToken) { + this.endpoint = endpoint; + this.authenticationToken = + authenticationToken == null ? "" : authenticationToken; + } + + /// Who is asking. + public Endpoint getEndpoint() { + return endpoint; + } + + /// The short string both devices compute from this connection. Identical + /// on both sides when nothing is in the middle. Never null; empty on a + /// platform that does not produce one. + public String getAuthenticationToken() { + return authenticationToken; + } + + /// Whether [#accept()] or [#reject()] has already been called. + public boolean isAnswered() { + return answered; + } + + /// Accepts the connection. The result arrives as + /// [TransportListener#connected] or + /// [TransportListener#connectionFailed], because the far side has to + /// accept too. Calling this twice, or after [#reject()], does nothing. + public void accept() { + if (answered) { + return; + } + answered = true; + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.acceptConnection(NearbyRequests.nextId(), endpoint.getId()); + } + } + + /// Rejects the connection. Calling this twice, or after [#accept()], + /// does nothing. + public void reject() { + if (answered) { + return; + } + answered = true; + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.rejectConnection(endpoint.getId()); + } + } + + public String toString() { + return "ConnectionRequest[" + endpoint + ", token=" + + authenticationToken + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java b/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java new file mode 100644 index 00000000000..0c5c39c2e2a --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java @@ -0,0 +1,85 @@ +/* + * 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.nearby.transport; + +/// Another device seen advertising the same service id. +/// +/// An endpoint id is only meaningful for as long as the endpoint is visible +/// -- both platforms mint a fresh one per discovery session -- so persist +/// nothing from here. To recognise a device across sessions, use +/// `com.codename1.nearby.companion` or a name the app chooses itself. +public final class Endpoint { + + private final String id; + private final String name; + private final String serviceId; + + /// Ports construct these; application code receives them through + /// [TransportListener]. + /// + /// #### Parameters + /// + /// - `id`: the platform's endpoint id + /// - `name`: the name the peer advertised + /// - `serviceId`: the service both ends agreed on + public Endpoint(String id, String name, String serviceId) { + this.id = id; + this.name = name == null ? "" : name; + this.serviceId = serviceId == null ? "" : serviceId; + } + + /// The endpoint id, which every other call in this package takes. + /// Valid only while this endpoint is visible. + public String getId() { + return id; + } + + /// The name the peer advertised itself under. Never null. + public String getName() { + return name; + } + + /// The service id this endpoint was found under. Never null. + public String getServiceId() { + return serviceId; + } + + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Endpoint)) { + return false; + } + Endpoint e = (Endpoint) o; + return id == null ? e.id == null : id.equals(e.id); + } + + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } + + public String toString() { + return "Endpoint[" + id + ", " + name + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java new file mode 100644 index 00000000000..726ad51aa42 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -0,0 +1,674 @@ +/* + * 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.nearby.transport; + +import com.codename1.impl.async.EdtResult; +import com.codename1.impl.async.PendingMap; +import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.impl.nearby.NearbyWire; +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.NearbyException; +import com.codename1.nearby.NearbyPermission; +import com.codename1.nearby.spi.NearbyBridge; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/// Moving bytes and files to a device that is physically nearby, with no +/// access point, no pairing and no internet. +/// +/// The platform picks and combines the radios itself -- Bluetooth to find +/// each other, then Wi-Fi to move the data -- so an app advertises a service +/// id, discovers peers using the same id, connects, and sends payloads. +/// +/// #### This transport does not cross ecosystems +/// +/// **Android talks to Android and Apple talks to Apple, and the two do not +/// meet.** Underneath are Google's Nearby Connections and Apple's +/// MultipeerConnectivity, which share no wire protocol; nothing in this API +/// papers over that, because a portable-looking API that silently never +/// finds the peer is worse than an honest limitation. +/// +/// For an iPhone that must talk to an Android phone, the framework already +/// has two things that do work across the divide: +/// +/// - `com.codename1.bluetooth.le.L2capChannel` -- a raw bidirectional byte +/// stream over BLE, on every platform that has BLE. +/// - `com.codename1.io.bonjour` plus ordinary sockets, when both devices are +/// on the same Wi-Fi network. +/// +/// #### Quick start +/// +/// ```java +/// NearbyTransport.addTransportListener(new TransportAdapter() { +/// public void endpointFound(Endpoint e) { +/// NearbyTransport.requestConnection(e, "Shai's phone"); +/// } +/// public void connectionRequested(ConnectionRequest r) { +/// // show r.getAuthenticationToken() on both screens before this +/// r.accept(); +/// } +/// public void connected(Endpoint e) { +/// NearbyTransport.send(e, Payload.fromBytes(data)); +/// } +/// public void payloadReceived(Endpoint e, Payload p) { +/// process(p.getBytes()); +/// } +/// }); +/// NearbyTransport.startAdvertising("com.example.chat", "Shai's phone", +/// TransportStrategy.CLUSTER); +/// NearbyTransport.startDiscovery("com.example.chat", TransportStrategy.CLUSTER); +/// ``` +/// +/// #### Threading +/// +/// Every callback here is delivered on the EDT. +public final class NearbyTransport { + + private static final PendingMap PENDING = + new PendingMap(); + private static final List LISTENERS = + new ArrayList(); + + private NearbyTransport() { + } + + /// `true` when this port implements the nearby transport. + public static boolean isSupported() { + NearbyBridge b = NearbyRequests.bridge(); + return b != null && b.isTransportSupported(); + } + + /// How usable the transport is right now. + /// + /// #### Returns + /// + /// the current availability, never null + public static NearbyAvailability getAvailability() { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isTransportSupported()) { + return NearbyAvailability.NOT_SUPPORTED; + } + NearbyAvailability[] all = NearbyAvailability.values(); + int o = b.getTransportAvailability(); + return o >= 0 && o < all.length ? all[o] + : NearbyAvailability.NOT_SUPPORTED; + } + + /// The largest byte payload [#send] accepts in one call. Anything bigger + /// has to go as a file payload. + /// + /// #### Returns + /// + /// the limit in bytes, or zero when the transport is unsupported + public static int getMaxPayloadSize() { + NearbyBridge b = NearbyRequests.bridge(); + return b == null || !b.isTransportSupported() + ? 0 : b.getMaxPayloadSize(); + } + + /// Asks for the runtime permissions the transport needs -- on Android + /// that is the Bluetooth trio plus nearby Wi-Fi, which is a lot to ask + /// for at once, so ask when the user reaches the feature rather than at + /// startup. + /// + /// #### Parameters + /// + /// - `permissions`: what the app intends to do + /// + /// #### Returns + /// + /// resolves `true` when every requested permission is granted + public static AsyncResource requestPermissions( + NearbyPermission... permissions) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isTransportSupported()) { + return unsupported(); + } + int bits = 0; + if (permissions != null) { + for (int i = 0; i < permissions.length; i++) { + bits |= bitFor(permissions[i]); + } + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING.open(id); + b.requestPermissions(id, bits); + return out; + } + + /// Starts advertising this device so peers running the same service id + /// can find it. + /// + /// The service id must match exactly on both sides. On iOS it also + /// becomes the Bonjour service type, which the platform restricts to + /// fifteen characters of lowercase letters, digits and hyphens -- so a + /// reverse-DNS string works on Android and is rejected on iOS. Pick a + /// short one. + /// + /// #### Parameters + /// + /// - `serviceId`: the service both ends agreed on + /// - `localName`: the name to show peers + /// - `strategy`: the topology to use; must match on both sides + /// + /// #### Returns + /// + /// resolves `true` once the platform is advertising + public static AsyncResource startAdvertising(String serviceId, + String localName, TransportStrategy strategy) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isTransportSupported()) { + return unsupported(); + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING.open(id); + b.startAdvertising(id, serviceId, localName, ordinalOf(strategy)); + return out; + } + + /// Stops advertising. Idempotent; existing connections stay open. + public static void stopAdvertising() { + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.stopAdvertising(); + } + } + + /// Starts looking for peers advertising the same service id. Sightings + /// arrive as [TransportListener#endpointFound]. + /// + /// #### Parameters + /// + /// - `serviceId`: the service both ends agreed on + /// - `strategy`: the topology to use; must match on both sides + /// + /// #### Returns + /// + /// resolves `true` once the platform is discovering + public static AsyncResource startDiscovery(String serviceId, + TransportStrategy strategy) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isTransportSupported()) { + return unsupported(); + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING.open(id); + b.startDiscovery(id, serviceId, ordinalOf(strategy)); + return out; + } + + /// Stops discovery. Idempotent; existing connections stay open. + public static void stopDiscovery() { + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.stopDiscovery(); + } + } + + /// Asks a discovered endpoint to connect. + /// + /// The resource here resolves once the request has been sent, which is + /// not the same as being connected: the far side still has to accept, + /// and that answer arrives as [TransportListener#connected] or + /// [TransportListener#connectionFailed]. + /// + /// #### Parameters + /// + /// - `endpoint`: the peer to ask + /// - `localName`: the name to show them + /// + /// #### Returns + /// + /// resolves `true` once the request has been sent + public static AsyncResource requestConnection(Endpoint endpoint, + String localName) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isTransportSupported()) { + return unsupported(); + } + if (endpoint == null) { + return failed(NearbyError.PEER_UNAVAILABLE, + "an endpoint is required"); + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING.open(id); + b.requestConnection(id, endpoint.getId(), localName); + return out; + } + + /// Sends a payload to one connected endpoint. + /// + /// #### Parameters + /// + /// - `endpoint`: the recipient + /// - `payload`: what to send + /// + /// #### Returns + /// + /// resolves `true` once the payload is handed to the platform. Delivery + /// is reported by [TransportListener#payloadProgress]. + public static AsyncResource send(Endpoint endpoint, + Payload payload) { + return send(new Endpoint[] {endpoint}, payload); + } + + /// Sends a payload to several connected endpoints at once, which both + /// platforms do more efficiently than one call each. + /// + /// #### Parameters + /// + /// - `endpoints`: the recipients + /// - `payload`: what to send + /// + /// #### Returns + /// + /// resolves `true` once the payload is handed to the platform + public static AsyncResource send(Endpoint[] endpoints, + Payload payload) { + NearbyBridge b = NearbyRequests.bridge(); + if (b == null || !b.isTransportSupported()) { + return unsupported(); + } + if (endpoints == null || endpoints.length == 0) { + return failed(NearbyError.PEER_UNAVAILABLE, + "at least one endpoint is required"); + } + if (payload == null) { + return failed(NearbyError.IO_ERROR, "a payload is required"); + } + if (payload.getType() == Payload.TYPE_BYTES) { + int max = b.getMaxPayloadSize(); + if (max > 0 && payload.getBytes().length > max) { + return failed(NearbyError.IO_ERROR, + "a byte payload is limited to " + max + + " bytes on this platform; send a file" + + " payload instead"); + } + } + String[] ids = new String[endpoints.length]; + for (int i = 0; i < endpoints.length; i++) { + if (endpoints[i] == null) { + return failed(NearbyError.PEER_UNAVAILABLE, + "a null endpoint was passed to send"); + } + ids[i] = endpoints[i].getId(); + } + int id = NearbyRequests.nextId(); + EdtResult out = PENDING.open(id); + b.sendPayload(id, ids, payload.getId(), + payload.getType() == Payload.TYPE_FILE + ? NearbyBridge.PAYLOAD_FILE + : NearbyBridge.PAYLOAD_BYTES, + payload.getBytes(), payload.getPath()); + return out; + } + + /// Cancels an in-flight payload on both sides. Idempotent. + /// + /// #### Parameters + /// + /// - `payloadId`: the id from [Payload#getId()] + public static void cancel(int payloadId) { + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.cancelPayload(payloadId); + } + } + + /// Disconnects one endpoint. Idempotent. + /// + /// #### Parameters + /// + /// - `endpoint`: the peer to drop + public static void disconnect(Endpoint endpoint) { + NearbyBridge b = NearbyRequests.bridge(); + if (b != null && endpoint != null) { + b.disconnect(endpoint.getId()); + } + } + + /// Stops advertising and discovery and drops every connection. Call it + /// when the feature's UI closes: both platforms keep the radios busy + /// until something says stop. + public static void stop() { + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.stopAllTransport(); + } + } + + /// Registers a listener. Callbacks arrive on the EDT. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addTransportListener(TransportListener l) { + if (l == null) { + return; + } + synchronized (LISTENERS) { + LISTENERS.add(l); + } + } + + /// Removes a listener added by [#addTransportListener]. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removeTransportListener(TransportListener l) { + synchronized (LISTENERS) { + LISTENERS.remove(l); + } + } + + + /// Clears every in-flight request, so one test cannot see the requests of + /// the test that ran before it. Reached through + /// `com.codename1.impl.nearby.NearbyRequests#resetForTest`. + /// + /// In-flight requests are failed rather than dropped: a resource that + /// never settles is worse than one that fails, and a test holding one + /// would hang rather than report. + /// + /// @hidden not part of the public API; test-only. + public static void resetForTest() { + NearbyException reset = new NearbyException(NearbyError.UNKNOWN, + "the nearby framework was reset"); + PENDING.failAll(reset); + synchronized (LISTENERS) { + LISTENERS.clear(); + } + } + + // ------------------------------------------------------------------ + // Port entry points + // ------------------------------------------------------------------ + + /// Answers any request that resolves with a simple acknowledgement -- + /// advertising, discovery, a connection request, a payload handoff. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + public static void deliverRequestOk(int requestId) { + EdtResult r = PENDING.take(requestId); + if (r != null) { + r.complete(Boolean.TRUE); + } + } + + /// Fails whichever transport request carries this id. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `requestId`: the id the request was made with + /// - `errorOrdinal`: the ordinal of a `com.codename1.nearby.NearbyError` + /// constant + /// - `message`: a human-readable detail, may be null + public static void deliverRequestFailed(int requestId, int errorOrdinal, + String message) { + EdtResult r = PENDING.take(requestId); + if (r != null) { + r.error(NearbyWire.decodeError(errorOrdinal, message)); + } + } + + /// Reports a discovered or lost endpoint. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `encodedEndpoint`: the endpoint, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + /// - `found`: true for a sighting, false when it went away + public static void deliverEndpointFound(String encodedEndpoint, + final boolean found) { + final Endpoint e = NearbyWire.decodeEndpoint(encodedEndpoint); + if (e == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + TransportListener[] ls = snapshot(); + for (int i = 0; i < ls.length; i++) { + if (found) { + ls[i].endpointFound(e); + } else { + ls[i].endpointLost(e); + } + } + } + }); + } + + /// Reports an incoming connection request. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `encodedEndpoint`: the endpoint, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + /// - `authenticationToken`: the short comparison string + public static void deliverConnectionRequested(String encodedEndpoint, + final String authenticationToken) { + final Endpoint e = NearbyWire.decodeEndpoint(encodedEndpoint); + if (e == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + ConnectionRequest r = + new ConnectionRequest(e, authenticationToken); + TransportListener[] ls = snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].connectionRequested(r); + } + if (!r.isAnswered()) { + // Nobody was listening, so nobody will ever answer. The + // far side would sit in its connecting state until it + // timed out; reject instead so it learns immediately. + r.reject(); + } + } + }); + } + + /// Reports the outcome of a connection attempt. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `encodedEndpoint`: the endpoint, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + /// - `connected`: whether the connection is now open + /// - `errorOrdinal`: when not connected, the ordinal of a + /// `com.codename1.nearby.NearbyError` constant + /// - `message`: when not connected, a human-readable detail + public static void deliverConnectionResult(String encodedEndpoint, + final boolean connected, final int errorOrdinal, + final String message) { + final Endpoint e = NearbyWire.decodeEndpoint(encodedEndpoint); + if (e == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + TransportListener[] ls = snapshot(); + for (int i = 0; i < ls.length; i++) { + if (connected) { + ls[i].connected(e); + } else { + ls[i].connectionFailed(e, + NearbyWire.decodeError(errorOrdinal, message)); + } + } + } + }); + } + + /// Reports that an open connection closed. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `encodedEndpoint`: the endpoint, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + public static void deliverDisconnected(String encodedEndpoint) { + final Endpoint e = NearbyWire.decodeEndpoint(encodedEndpoint); + if (e == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + TransportListener[] ls = snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].disconnected(e); + } + } + }); + } + + /// Reports a complete incoming payload. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `encodedEndpoint`: who sent it, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + /// - `payloadId`: the sender's payload id + /// - `payloadType`: `NearbyBridge.PAYLOAD_BYTES` or + /// `NearbyBridge.PAYLOAD_FILE` + /// - `bytes`: the payload for a byte payload, otherwise null + /// - `path`: the file the port wrote, for a file payload + public static void deliverPayloadReceived(String encodedEndpoint, + final int payloadId, final int payloadType, final byte[] bytes, + final String path) { + final Endpoint e = NearbyWire.decodeEndpoint(encodedEndpoint); + if (e == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + Payload p = Payload.received(payloadId, + payloadType == NearbyBridge.PAYLOAD_FILE + ? Payload.TYPE_FILE : Payload.TYPE_BYTES, + bytes, path); + TransportListener[] ls = snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].payloadReceived(e, p); + } + } + }); + } + + /// Reports progress on a payload. + /// + /// @hidden not part of the public API; called by ports from any thread. + /// + /// #### Parameters + /// + /// - `encodedEndpoint`: the other end, encoded by + /// `com.codename1.impl.nearby.NearbyWire` + /// - `payloadId`: the payload + /// - `bytesTransferred`: bytes moved so far + /// - `totalBytes`: the payload size, or -1 when unknown + /// - `statusOrdinal`: the ordinal of a [PayloadStatus] constant + public static void deliverPayloadProgress(String encodedEndpoint, + final int payloadId, final long bytesTransferred, + final long totalBytes, final int statusOrdinal) { + final Endpoint e = NearbyWire.decodeEndpoint(encodedEndpoint); + if (e == null) { + return; + } + NearbyRequests.onEdt(new Runnable() { + public void run() { + PayloadStatus[] all = PayloadStatus.values(); + PayloadStatus s = statusOrdinal >= 0 + && statusOrdinal < all.length + ? all[statusOrdinal] : PayloadStatus.IN_PROGRESS; + PayloadTransferUpdate u = new PayloadTransferUpdate(payloadId, + bytesTransferred, totalBytes, s); + TransportListener[] ls = snapshot(); + for (int i = 0; i < ls.length; i++) { + ls[i].payloadProgress(e, u); + } + } + }); + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private static TransportListener[] snapshot() { + synchronized (LISTENERS) { + return LISTENERS.toArray( + new TransportListener[LISTENERS.size()]); + } + } + + private static int ordinalOf(TransportStrategy s) { + return s == null ? TransportStrategy.CLUSTER.ordinal() : s.ordinal(); + } + + private static int bitFor(NearbyPermission p) { + if (p == NearbyPermission.RANGING) { + return NearbyBridge.PERMISSION_RANGING; + } + if (p == NearbyPermission.DISCOVERY) { + return NearbyBridge.PERMISSION_DISCOVERY; + } + if (p == NearbyPermission.ADVERTISE) { + return NearbyBridge.PERMISSION_ADVERTISE; + } + if (p == NearbyPermission.CONNECT) { + return NearbyBridge.PERMISSION_CONNECT; + } + return 0; + } + + private static AsyncResource unsupported() { + return failed(NearbyError.NOT_SUPPORTED, + "this platform does not support the nearby transport"); + } + + private static AsyncResource failed(NearbyError error, + String message) { + EdtResult out = new EdtResult(); + out.error(new NearbyException(error, message)); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/Payload.java b/CodenameOne/src/com/codename1/nearby/transport/Payload.java new file mode 100644 index 00000000000..54f80ea12b6 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/Payload.java @@ -0,0 +1,136 @@ +/* + * 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.nearby.transport; + +import java.util.concurrent.atomic.AtomicInteger; + +/// Something to send to a connected endpoint: either a block of bytes or a +/// file. +/// +/// Bytes are the simple case and are capped at +/// [NearbyTransport#getMaxPayloadSize()], which is a few kilobytes on both +/// platforms. Anything larger goes as a file, which streams and reports +/// progress. +public final class Payload { + + /// This payload carries bytes; [#getBytes()] has them. + public static final int TYPE_BYTES = 0; + + /// This payload carries a file; [#getPath()] names it. + public static final int TYPE_FILE = 1; + + private static final AtomicInteger NEXT_ID = new AtomicInteger(1); + + private final int id; + private final int type; + private final byte[] bytes; + private final String path; + + private Payload(int id, int type, byte[] bytes, String path) { + this.id = id; + this.type = type; + this.bytes = bytes; + this.path = path; + } + + /// Wraps a block of bytes. + /// + /// #### Parameters + /// + /// - `bytes`: the payload, no larger than + /// [NearbyTransport#getMaxPayloadSize()] + /// + /// #### Returns + /// + /// the payload + public static Payload fromBytes(byte[] bytes) { + if (bytes == null) { + throw new IllegalArgumentException("bytes are required"); + } + return new Payload(NEXT_ID.getAndIncrement(), TYPE_BYTES, bytes, null); + } + + /// Wraps a file, which is streamed rather than loaded. + /// + /// #### Parameters + /// + /// - `path`: a `com.codename1.io.FileSystemStorage` path + /// + /// #### Returns + /// + /// the payload + public static Payload fromFile(String path) { + if (path == null || path.length() == 0) { + throw new IllegalArgumentException("a file path is required"); + } + return new Payload(NEXT_ID.getAndIncrement(), TYPE_FILE, null, path); + } + + /// Rebuilds a received payload. + /// + /// @hidden not part of the public API; called by ports. + /// + /// #### Parameters + /// + /// - `id`: the id the sending side used + /// - `type`: [#TYPE_BYTES] or [#TYPE_FILE] + /// - `bytes`: the bytes for a byte payload, otherwise null + /// - `path`: the file for a file payload, otherwise null + /// + /// #### Returns + /// + /// the payload + public static Payload received(int id, int type, byte[] bytes, + String path) { + return new Payload(id, type, bytes, path); + } + + /// The id progress updates and [NearbyTransport#cancel] use. + public int getId() { + return id; + } + + /// [#TYPE_BYTES] or [#TYPE_FILE]. + public int getType() { + return type; + } + + /// The bytes, or `null` for a file payload. The array is not copied -- + /// do not mutate it while the payload is in flight. + public byte[] getBytes() { + return bytes; + } + + /// The file path, or `null` for a byte payload. On a received file + /// payload this names a file the port already wrote, in the app's + /// storage. + public String getPath() { + return path; + } + + public String toString() { + return "Payload[" + id + ", " + + (type == TYPE_FILE ? "file " + path + : bytes.length + " bytes") + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/PayloadStatus.java b/CodenameOne/src/com/codename1/nearby/transport/PayloadStatus.java new file mode 100644 index 00000000000..cbf8ae3be35 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/PayloadStatus.java @@ -0,0 +1,39 @@ +/* + * 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.nearby.transport; + +/// Where a payload transfer got to, carried by [PayloadTransferUpdate]. +public enum PayloadStatus { + /// Bytes are still moving. [PayloadTransferUpdate#getBytesTransferred()] + /// says how many so far. + IN_PROGRESS, + + /// Every byte arrived. + SUCCESS, + + /// The transfer failed and will not resume. + FAILURE, + + /// The transfer was cancelled by either side. + CANCELED +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java b/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java new file mode 100644 index 00000000000..d5015254173 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java @@ -0,0 +1,80 @@ +/* + * 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.nearby.transport; + +/// Progress on one payload, delivered to +/// [TransportListener#payloadProgress]. +/// +/// A byte payload typically produces a single update with +/// [PayloadStatus#SUCCESS]; a file payload produces a stream of +/// [PayloadStatus#IN_PROGRESS] updates and then a terminal one. +public final class PayloadTransferUpdate { + + private final int payloadId; + private final long bytesTransferred; + private final long totalBytes; + private final PayloadStatus status; + + /// Ports construct these. + /// + /// #### Parameters + /// + /// - `payloadId`: the payload this is about + /// - `bytesTransferred`: bytes moved so far + /// - `totalBytes`: the payload size, or -1 when unknown + /// - `status`: where the transfer got to + public PayloadTransferUpdate(int payloadId, long bytesTransferred, + long totalBytes, PayloadStatus status) { + this.payloadId = payloadId; + this.bytesTransferred = bytesTransferred; + this.totalBytes = totalBytes; + this.status = status == null ? PayloadStatus.IN_PROGRESS : status; + } + + /// The payload this update is about, matching [Payload#getId()]. + public int getPayloadId() { + return payloadId; + } + + /// How many bytes have moved so far. + public long getBytesTransferred() { + return bytesTransferred; + } + + /// The payload size, or `-1` when the platform did not say. A stream + /// payload legitimately has no total, so guard a progress bar on this + /// being positive. + public long getTotalBytes() { + return totalBytes; + } + + /// Where the transfer got to. Never null. + public PayloadStatus getStatus() { + return status; + } + + public String toString() { + return "PayloadTransferUpdate[" + payloadId + ", " + bytesTransferred + + "/" + totalBytes + ", " + status + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java b/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java new file mode 100644 index 00000000000..6bca025643f --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java @@ -0,0 +1,55 @@ +/* + * 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.nearby.transport; + +import com.codename1.nearby.NearbyException; + +/// A [TransportListener] whose methods all do nothing, so a caller +/// interested in two events overrides two methods. +public class TransportAdapter implements TransportListener { + + public void endpointFound(Endpoint endpoint) { + } + + public void endpointLost(Endpoint endpoint) { + } + + public void connectionRequested(ConnectionRequest request) { + } + + public void connected(Endpoint endpoint) { + } + + public void connectionFailed(Endpoint endpoint, NearbyException error) { + } + + public void disconnected(Endpoint endpoint) { + } + + public void payloadReceived(Endpoint endpoint, Payload payload) { + } + + public void payloadProgress(Endpoint endpoint, + PayloadTransferUpdate update) { + } +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java b/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java new file mode 100644 index 00000000000..45981c21040 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java @@ -0,0 +1,95 @@ +/* + * 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.nearby.transport; + +import com.codename1.nearby.NearbyException; + +/// Receives everything the nearby transport has to say. Every method is +/// called on the EDT. +/// +/// Extend [TransportAdapter] rather than implementing all of this. +public interface TransportListener { + + /// A peer advertising the same service id came into view. Expect this + /// repeatedly for the same endpoint across discovery sessions. + /// + /// #### Parameters + /// + /// - `endpoint`: the peer that appeared + void endpointFound(Endpoint endpoint); + + /// A discovered peer went away before any connection was made. + /// + /// #### Parameters + /// + /// - `endpoint`: the peer that disappeared + void endpointLost(Endpoint endpoint); + + /// A peer wants to connect. Call [ConnectionRequest#accept()] or + /// [ConnectionRequest#reject()]; a request that is never answered times + /// out on the far side. + /// + /// #### Parameters + /// + /// - `request`: the request to answer + void connectionRequested(ConnectionRequest request); + + /// A connection is open in both directions and payloads may be sent. + /// + /// #### Parameters + /// + /// - `endpoint`: the connected peer + void connected(Endpoint endpoint); + + /// A connection attempt failed, or was rejected by the far side. + /// + /// #### Parameters + /// + /// - `endpoint`: the peer that did not connect + /// - `error`: why + void connectionFailed(Endpoint endpoint, NearbyException error); + + /// An open connection closed, whether deliberately or because the peer + /// went out of range. + /// + /// #### Parameters + /// + /// - `endpoint`: the peer that disconnected + void disconnected(Endpoint endpoint); + + /// A complete payload arrived. + /// + /// #### Parameters + /// + /// - `endpoint`: who sent it + /// - `payload`: what they sent + void payloadReceived(Endpoint endpoint, Payload payload); + + /// Progress on a payload being sent or received. + /// + /// #### Parameters + /// + /// - `endpoint`: the other end of the transfer + /// - `update`: how far it has got + void payloadProgress(Endpoint endpoint, PayloadTransferUpdate update); +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/TransportStrategy.java b/CodenameOne/src/com/codename1/nearby/transport/TransportStrategy.java new file mode 100644 index 00000000000..54a06a1cd34 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/TransportStrategy.java @@ -0,0 +1,43 @@ +/* + * 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.nearby.transport; + +/// The connection topology a transport session uses. The platforms trade +/// bandwidth against the number of simultaneous links, and this is where an +/// app says which side of that trade it wants. +public enum TransportStrategy { + /// Many-to-many: every device may connect to every other. The most + /// flexible and the slowest per link. Android + /// `Strategy.P2P_CLUSTER`; the natural fit for MultipeerConnectivity, + /// which is a mesh by nature. + CLUSTER, + + /// One advertiser, many discoverers. The advertiser accepts several + /// connections and each discoverer holds exactly one. Android + /// `Strategy.P2P_STAR`. + STAR, + + /// Exactly one connection on each side, and the highest bandwidth of + /// the three. Android `Strategy.P2P_POINT_TO_POINT`. + POINT_TO_POINT +} diff --git a/CodenameOne/src/com/codename1/nearby/transport/package-info.java b/CodenameOne/src/com/codename1/nearby/transport/package-info.java new file mode 100644 index 00000000000..510fcf474f2 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/package-info.java @@ -0,0 +1,42 @@ +/* + * 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. + */ +/// Sending bytes and files to a device in the same room, with no access +/// point, no pairing and no internet. +/// +/// Start at [NearbyTransport]. +/// +/// #### Read the limitation before designing around this +/// +/// **This transport does not cross ecosystems.** It is Google's Nearby +/// Connections on Android and Apple's MultipeerConnectivity on iOS, and the +/// two share no wire protocol, so an Android phone and an iPhone will never +/// discover each other here no matter how the app is written. The API does +/// not hide that, because an API that looked portable and silently never +/// found the peer would be worse. +/// +/// When both ends of the conversation are not the same platform, the +/// framework already has two options that do work across the divide: +/// `com.codename1.bluetooth.le.L2capChannel` for a raw byte stream over BLE, +/// and `com.codename1.io.bonjour` plus sockets when both devices share a +/// Wi-Fi network. +package com.codename1.nearby.transport; diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 071adbc8abb..a309b73ef5b 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4819,6 +4819,18 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { return impl.getHomeBridge(); } + /// Returns the platform bridge used by the `com.codename1.nearby` API to reach precision + /// ranging, companion-device association and the nearby transport, or null when this port + /// implements none of them. Internal -- application code uses the `com.codename1.nearby` + /// packages rather than this bridge directly. + /// + /// #### Returns + /// + /// the nearby bridge, or null + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + return impl.getNearbyBridge(); + } + /// Returns the platform bridge used by the `com.codename1.surfaces` API to render external /// surfaces (home-screen widgets and live activities), or null when unsupported on this port. /// Internal -- application code uses the `com.codename1.surfaces` API rather than this bridge diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index d0b590befb4..834342dd90e 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -15835,6 +15835,8 @@ public com.codename1.health.Health getHealth() { private static com.codename1.impl.home.LocalHomeBridge homeBridge; + private static com.codename1.impl.nearby.LocalNearbyBridge nearbyBridge; + /// Returns the simulator's smart home. There is no desktop HomeKit or /// Google Home, so this is a local simulated house reporting /// {@code HomeAvailability.LOCAL_ONLY}: the accessories come from @@ -15851,6 +15853,33 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { return getSimulatedHome(); } + /// The nearby bridge for the simulator and desktop builds: a simulated + /// implementation rather than no implementation, for the same reason + /// [#getHomeBridge()] carries one. + /// Ranging UI, an association flow and a transport screen are almost + /// entirely code with nothing to do with radios, and a port that reported + /// nothing would make all of it testable only on a pair of phones. + /// + /// It reports `LOCAL_ONLY`, never `AVAILABLE`, so an app can tell the + /// developer the peers it is tracking are not real. + @Override + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + // Guarded for the reason the home bridge is: the bridge holds the + // live sessions, the association store and the connection set, and + // two threads racing this getter would each get their own -- a + // session prepared through one would be invisible to the other. + synchronized (JavaSEPort.class) { + if (nearbyBridge == null) { + com.codename1.impl.nearby.LocalNearbyBridge local = + new com.codename1.impl.nearby.LocalNearbyBridge(); + com.codename1.impl.nearby.SyntheticNearby.populate(local); + nearbyBridge = local; + } + return nearbyBridge; + } + } + + /// The simulated house, for the Simulate menu to script. /// /// Static and class-guarded because the simulator's own window reaches it 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 09bcc55a1a8..db884cd6b98 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 @@ -7019,6 +7019,8 @@ public com.codename1.health.Health getHealth() { private com.codename1.home.spi.HomeBridge homeBridge; + private com.codename1.impl.nearby.LocalNearbyBridge nearbyBridge; + /// Returns a local simulated home. There is no HomeKit or Google Home on /// this port, so the bridge reports /// {@code HomeAvailability.LOCAL_ONLY}: the accessories are furnished by @@ -7048,6 +7050,33 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { } } + /// The nearby bridge for the JavaScript port: a simulated + /// implementation rather than no implementation, for the same reason + /// [#getHomeBridge()] carries one. + /// Ranging UI, an association flow and a transport screen are almost + /// entirely code with nothing to do with radios, and a port that reported + /// nothing would make all of it testable only on a pair of phones. + /// + /// It reports `LOCAL_ONLY`, never `AVAILABLE`, so an app can tell the + /// developer the peers it is tracking are not real. + @Override + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + // Guarded for the reason the home bridge is: the bridge holds the + // live sessions, the association store and the connection set, and + // two threads racing this getter would each get their own -- a + // session prepared through one would be invisible to the other. + synchronized (HTML5Implementation.class) { + if (nearbyBridge == null) { + com.codename1.impl.nearby.LocalNearbyBridge local = + new com.codename1.impl.nearby.LocalNearbyBridge(); + com.codename1.impl.nearby.SyntheticNearby.populate(local); + nearbyBridge = local; + } + return nearbyBridge; + } + } + + private com.codename1.media.VideoIO videoIO; private boolean videoIOResolved; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 9cbf602cfee..71f18a3d0d7 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -455,6 +455,8 @@ public com.codename1.health.Health getHealth() { private com.codename1.home.spi.HomeBridge homeBridge; + private com.codename1.impl.nearby.LocalNearbyBridge nearbyBridge; + /// Returns a local simulated home. There is no HomeKit or Google Home on /// this port, so the bridge reports /// {@code HomeAvailability.LOCAL_ONLY}: the accessories are furnished by @@ -484,6 +486,33 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { } } + /// The nearby bridge for the native Linux port: a simulated + /// implementation rather than no implementation, for the same reason + /// [#getHomeBridge()] carries one. + /// Ranging UI, an association flow and a transport screen are almost + /// entirely code with nothing to do with radios, and a port that reported + /// nothing would make all of it testable only on a pair of phones. + /// + /// It reports `LOCAL_ONLY`, never `AVAILABLE`, so an app can tell the + /// developer the peers it is tracking are not real. + @Override + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + // Guarded for the reason the home bridge is: the bridge holds the + // live sessions, the association store and the connection set, and + // two threads racing this getter would each get their own -- a + // session prepared through one would be invisible to the other. + synchronized (LinuxImplementation.class) { + if (nearbyBridge == null) { + com.codename1.impl.nearby.LocalNearbyBridge local = + new com.codename1.impl.nearby.LocalNearbyBridge(); + com.codename1.impl.nearby.SyntheticNearby.populate(local); + nearbyBridge = local; + } + return nearbyBridge; + } + } + + // 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/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 8906a10f5e9..90b9189f870 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -445,6 +445,8 @@ public com.codename1.health.Health getHealth() { private com.codename1.home.spi.HomeBridge homeBridge; + private com.codename1.impl.nearby.LocalNearbyBridge nearbyBridge; + /// Returns a local simulated home. There is no HomeKit or Google Home on /// this port, so the bridge reports /// {@code HomeAvailability.LOCAL_ONLY}: the accessories are furnished by @@ -474,6 +476,33 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { } } + /// The nearby bridge for the native Windows port: a simulated + /// implementation rather than no implementation, for the same reason + /// [#getHomeBridge()] carries one. + /// Ranging UI, an association flow and a transport screen are almost + /// entirely code with nothing to do with radios, and a port that reported + /// nothing would make all of it testable only on a pair of phones. + /// + /// It reports `LOCAL_ONLY`, never `AVAILABLE`, so an app can tell the + /// developer the peers it is tracking are not real. + @Override + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + // Guarded for the reason the home bridge is: the bridge holds the + // live sessions, the association store and the connection set, and + // two threads racing this getter would each get their own -- a + // session prepared through one would be invisible to the other. + synchronized (WindowsImplementation.class) { + if (nearbyBridge == null) { + com.codename1.impl.nearby.LocalNearbyBridge local = + new com.codename1.impl.nearby.LocalNearbyBridge(); + com.codename1.impl.nearby.SyntheticNearby.populate(local); + nearbyBridge = local; + } + return nearbyBridge; + } + } + + // 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/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java new file mode 100644 index 00000000000..135d6286c59 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -0,0 +1,729 @@ +/* + * 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.nearby; + +import com.codename1.impl.nearby.LocalNearbyBridge; +import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.impl.nearby.SyntheticNearby; +import com.codename1.nearby.companion.AssociationRequest; +import com.codename1.nearby.companion.CompanionDevice; +import com.codename1.nearby.companion.CompanionDevices; +import com.codename1.nearby.companion.CompanionProfile; +import com.codename1.nearby.companion.DeviceFilter; +import com.codename1.nearby.companion.PresenceListener; +import com.codename1.nearby.ranging.Ranging; +import com.codename1.nearby.ranging.RangingCapabilities; +import com.codename1.nearby.ranging.RangingListener; +import com.codename1.nearby.ranging.RangingAdapter; +import com.codename1.nearby.ranging.RangingRemovalReason; +import com.codename1.nearby.ranging.RangingRole; +import com.codename1.nearby.ranging.RangingSession; +import com.codename1.nearby.ranging.RangingToken; +import com.codename1.nearby.ranging.RangingUnit; +import com.codename1.nearby.ranging.RangingUpdate; +import com.codename1.nearby.transport.Endpoint; +import com.codename1.nearby.transport.NearbyTransport; +import com.codename1.nearby.transport.Payload; +import com.codename1.nearby.transport.PayloadStatus; +import com.codename1.nearby.transport.PayloadTransferUpdate; +import com.codename1.nearby.transport.TransportAdapter; +import com.codename1.nearby.transport.TransportStrategy; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static com.codename1.nearby.NearbyAwait.assertFailedWith; +import static com.codename1.nearby.NearbyAwait.value; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The whole stack against the simulated implementation: the three facades, + * the wire codec, and the local bridge that backs the simulator, the desktop + * ports and the JavaScript port. + */ +class LocalNearbyTest { + + private LocalNearbyBridge bridge; + + @BeforeEach + void furnish() { + bridge = new LocalNearbyBridge(); + SyntheticNearby.populate(bridge); + NearbyRequests.resetForTest(bridge); + } + + @AfterEach + void clear() { + NearbyRequests.resetForTest(null); + } + + // ------------------------------------------------------------------ + // availability + // ------------------------------------------------------------------ + + @Test + void everythingWorksAndSaysItIsNotReal() { + assertTrue(Ranging.isSupported()); + assertTrue(CompanionDevices.isSupported()); + assertTrue(NearbyTransport.isSupported()); + // LOCAL_ONLY rather than AVAILABLE, so an app can tell the developer + // the peers it is tracking exist only in this process. + assertSame(NearbyAvailability.LOCAL_ONLY, Ranging.getAvailability()); + assertSame(NearbyAvailability.LOCAL_ONLY, + CompanionDevices.getAvailability()); + assertSame(NearbyAvailability.LOCAL_ONLY, + NearbyTransport.getAvailability()); + } + + @Test + void capabilitiesReportWhatTheSimulationCanActuallyProduce() { + RangingCapabilities c = Ranging.getCapabilities(); + assertTrue(c.isDistanceSupported()); + assertTrue(c.isDirectionSupported()); + assertTrue(c.isElevationSupported()); + assertTrue(c.isAccessoryRangingSupported()); + // Claimed by nothing here, because nothing here does them. + assertFalse(c.isCameraAssistanceSupported()); + assertFalse(c.isBackgroundRangingSupported()); + } + + @Test + void permissionsAreGrantedButNotInline() { + assertTrue(value(Ranging.requestPermissions(NearbyPermission.RANGING)) + .booleanValue()); + } + + // ------------------------------------------------------------------ + // ranging + // ------------------------------------------------------------------ + + @Test + void aPreparedSessionHasATokenAndIsNotYetRunning() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + assertNotNull(s); + assertSame(RangingRole.CONTROLLER, s.getRole()); + assertFalse(s.isRunning()); + RangingToken token = s.getLocalToken(); + assertNotNull(token); + assertEquals(RangingToken.PLATFORM_SIMULATED, token.getPlatform()); + assertTrue(token.toByteArray().length > 0); + } + + @Test + void twoSessionsGetDifferentTokens() { + RangingSession a = value(Ranging.prepareSession(RangingRole.CONTROLLER)); + RangingSession b = value(Ranging.prepareSession(RangingRole.CONTROLEE)); + assertFalse(a.getLocalToken().equals(b.getLocalToken())); + assertSame(RangingRole.CONTROLEE, b.getRole()); + } + + @Test + void startingASessionMakesItRunAndDeliverMeasurements() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + final List updates = new ArrayList(); + s.addRangingListener(new RangingAdapter() { + @Override + public void updated(RangingUpdate u) { + updates.add(u); + } + }); + RangingSession started = value(s.start(peerToken())); + assertSame(s, started); + assertTrue(s.isRunning()); + assertFalse(updates.isEmpty(), + "a started session must produce a measurement"); + RangingUpdate first = updates.get(0); + assertTrue(first.hasDistance()); + assertTrue(first.getDistance(RangingUnit.METERS) > 0); + assertTrue(first.getTimestamp() > 0); + } + + @Test + void aDistanceReadsTheSameNumberInDifferentUnits() { + RangingSession s = running(); + bridge.setSimulatedDistance(handleOf(), 2.0); + final AtomicReference last = + new AtomicReference(); + s.addRangingListener(new RangingAdapter() { + @Override + public void updated(RangingUpdate u) { + last.set(u); + } + }); + nudge(handleOf()); + RangingUpdate u = last.get(); + assertNotNull(u); + double meters = u.getDistance(RangingUnit.METERS); + assertEquals(meters * 100.0, + u.getDistance(RangingUnit.CENTIMETERS), 1e-9); + assertEquals(meters / 0.3048, u.getDistance(RangingUnit.FEET), 1e-9); + assertEquals(meters / 0.0254, u.getDistance(RangingUnit.INCHES), 1e-9); + } + + @Test + void aDirectionVectorAgreesWithTheAnglesDerivedFromIt() { + RangingSession s = running(); + final AtomicReference withDirection = + new AtomicReference(); + s.addRangingListener(new RangingAdapter() { + @Override + public void updated(RangingUpdate u) { + if (u.hasDirection() && withDirection.get() == null) { + withDirection.set(u); + } + } + }); + bridge.setSimulatedDistance(handleOf(), 1.0); + nudge(handleOf()); + RangingUpdate u = withDirection.get(); + assertNotNull(u, "a peer one metre away must have a direction"); + float[] v = u.getDirectionVector(); + assertNotNull(v); + assertEquals(3, v.length); + double len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + assertEquals(1.0, len, 1e-4, "the direction vector must be a unit" + + " vector, because that is what iOS produces"); + // The frame is x right, y up, forward is negative z -- so the azimuth + // the API reports must come back out of atan2(x, -z). + double azimuth = Math.toDegrees(Math.atan2(v[0], -v[2])); + assertEquals(u.getAzimuth(), azimuth, 1e-3); + double elevation = Math.toDegrees(Math.asin(v[1])); + assertEquals(u.getElevation(), elevation, 1e-3); + } + + @Test + void aTokenFromAnotherPlatformIsRejectedHereRatherThanOnTheDevice() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + assertFailedWith(NearbyError.INVALID_TOKEN, s.start( + RangingToken.forPayload(RangingToken.PLATFORM_APPLE_NI, + new byte[] {1, 2, 3}))); + } + + @Test + void aMissingTokenFailsRatherThanReachingTheBridge() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + assertFailedWith(NearbyError.INVALID_TOKEN, s.start(null)); + } + + @Test + void aSecondStartOnARunningSessionIsRefusedRatherThanQueued() { + RangingSession s = running(); + assertFailedWith(NearbyError.BUSY, s.start(peerToken())); + } + + @Test + void aStoppedSessionCannotBeRestarted() { + RangingSession s = running(); + s.stop(); + assertFalse(s.isRunning()); + assertFailedWith(NearbyError.SESSION_INVALIDATED, s.start(peerToken())); + } + + @Test + void stoppingTwiceIsHarmless() { + RangingSession s = running(); + s.stop(); + s.stop(); + } + + @Test + void noListenerHearsAnythingAfterStop() { + RangingSession s = running(); + final AtomicInteger seen = new AtomicInteger(); + s.addRangingListener(new RangingAdapter() { + @Override + public void updated(RangingUpdate u) { + seen.incrementAndGet(); + } + }); + // Read the handle first: stop() deregisters the session, which is + // itself part of the contract being tested here. + int handle = handleOf(); + s.stop(); + assertEquals(0, bridge.getSessionHandles().length); + int before = seen.get(); + bridge.dropPeer(handle); + assertEquals(before, seen.get()); + } + + @Test + void aPeerCanWalkAwayWithoutKillingTheSession() { + RangingSession s = running(); + final AtomicReference reason = + new AtomicReference(); + s.addRangingListener(new RangingAdapter() { + @Override + public void peerRemoved(RangingRemovalReason r) { + reason.set(r); + } + }); + bridge.dropPeer(handleOf()); + assertSame(RangingRemovalReason.TIMEOUT, reason.get()); + assertTrue(s.isRunning(), "losing the peer does not end the session"); + } + + @Test + void suspendAndResumeAreReportedAndStopTheMeasurements() { + RangingSession s = running(); + final List events = new ArrayList(); + final AtomicInteger updates = new AtomicInteger(); + s.addRangingListener(new RangingAdapter() { + @Override + public void suspended() { + events.add("suspended"); + } + + @Override + public void resumed() { + events.add("resumed"); + } + + @Override + public void updated(RangingUpdate u) { + updates.incrementAndGet(); + } + }); + bridge.suspendSession(handleOf()); + assertEquals(1, events.size()); + assertEquals("suspended", events.get(0)); + assertFalse(s.isRunning()); + int whileSuspended = updates.get(); + bridge.suspendSession(handleOf()); + assertEquals(1, events.size(), "suspending twice reports once"); + assertEquals(whileSuspended, updates.get(), + "a suspended session produces no measurements"); + + bridge.resumeSession(handleOf()); + assertEquals(2, events.size()); + assertEquals("resumed", events.get(1)); + assertTrue(s.isRunning()); + assertTrue(updates.get() > whileSuspended); + } + + @Test + void anAccessorySessionAnswersWithTheBytesToSendBack() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + byte[] shareable = value(s.startAccessory( + new byte[] {1, 2, 3, 4})); + assertNotNull(shareable); + assertTrue(shareable.length > 0, "an app that forgets to forward this" + + " should have something to forget"); + assertTrue(s.isRunning()); + } + + @Test + void anEmptyAccessoryConfigurationIsRefused() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + assertFailedWith(NearbyError.INVALID_TOKEN, + s.startAccessory(new byte[0])); + assertFailedWith(NearbyError.INVALID_TOKEN, s.startAccessory(null)); + } + + @Test + void theWalkIsReproducibleRunToRun() { + // A test that asserts on the tenth measurement has to get the same + // tenth measurement every time, or the simulation is a flake factory. + double[] first = walk(); + NearbyRequests.resetForTest(null); + bridge = new LocalNearbyBridge(); + SyntheticNearby.populate(bridge); + NearbyRequests.resetForTest(bridge); + double[] second = walk(); + assertEquals(first.length, second.length); + for (int i = 0; i < first.length; i++) { + assertEquals(first[i], second[i], 1e-12, + "measurement " + i + " differed between runs"); + } + } + + @Test + void theWalkStaysInsideItsBoundsAndKeepsMoving() { + double[] w = walk(); + boolean moved = false; + for (int i = 0; i < w.length; i++) { + assertTrue(w[i] > 0, "a distance is positive"); + assertTrue(w[i] <= 14.0, "the peer stays in the simulated room"); + if (i > 0 && Math.abs(w[i] - w[i - 1]) > 1e-9) { + moved = true; + } + } + assertTrue(moved, "a peer that never moves would let an app ship a" + + " label that flickers unreadably against real hardware"); + } + + // ------------------------------------------------------------------ + // companion + // ------------------------------------------------------------------ + + @Test + void associatingWithNoFilterOffersTheFirstCandidate() { + CompanionDevice d = value(CompanionDevices.associate( + new AssociationRequest.Builder() + .profile(CompanionProfile.WATCH).build())); + assertEquals("Simulated Watch", d.getDisplayName()); + assertSame(CompanionProfile.WATCH, d.getProfile()); + assertNotNull(d.getId()); + assertEquals(1, CompanionDevices.getAssociations().size()); + } + + @Test + void aServiceFilterPicksTheDeviceAdvertisingIt() { + CompanionDevice d = value(CompanionDevices.associate( + new AssociationRequest.Builder() + .addFilter(DeviceFilter.bleService( + SyntheticNearby.HEART_RATE_SERVICE)) + .build())); + assertEquals("Simulated Heart Rate Strap", d.getDisplayName()); + } + + @Test + void aFilterThatMatchesNothingReadsAsTheUserWalkingAway() { + // There is no other honest answer: the chooser had nothing to show, + // so from the app's point of view the user closed it. + assertFailedWith(NearbyError.USER_CANCELED, + CompanionDevices.associate(new AssociationRequest.Builder() + .addFilter(DeviceFilter.bleService("FFFF")) + .build())); + } + + @Test + void anAssociationSurvivesUntilItIsDropped() { + CompanionDevice d = value(CompanionDevices.associate( + new AssociationRequest.Builder().build())); + List held = CompanionDevices.getAssociations(); + assertEquals(1, held.size()); + assertEquals(d, held.get(0)); + + assertTrue(value(CompanionDevices.disassociate(d.getId())) + .booleanValue()); + assertTrue(CompanionDevices.getAssociations().isEmpty()); + assertFailedWith(NearbyError.PEER_UNAVAILABLE, + CompanionDevices.disassociate(d.getId())); + } + + @Test + void presenceIsOnlyReportedForAnObservedAssociation() { + final CompanionDevice d = value(CompanionDevices.associate( + new AssociationRequest.Builder().build())); + final List events = new ArrayList(); + CompanionDevices.addPresenceListener(new PresenceListener() { + @Override + public void deviceAppeared(CompanionDevice device) { + events.add("appeared:" + device.getId()); + } + + @Override + public void deviceDisappeared(CompanionDevice device) { + events.add("disappeared:" + device.getId()); + } + }); + + // Not observed yet, so nothing is reported. + bridge.setPresent(d.getId(), false); + assertTrue(events.isEmpty()); + + assertTrue(CompanionDevices.startObservingPresence(d.getId())); + bridge.setPresent(d.getId(), false); + bridge.setPresent(d.getId(), true); + assertEquals(2, events.size()); + assertEquals("disappeared:" + d.getId(), events.get(0)); + assertEquals("appeared:" + d.getId(), events.get(1)); + + CompanionDevices.stopObservingPresence(d.getId()); + bridge.setPresent(d.getId(), false); + assertEquals(2, events.size()); + } + + @Test + void observingSomethingThatIsNotAssociatedIsRefused() { + assertFalse(CompanionDevices.startObservingPresence("nope")); + } + + // ------------------------------------------------------------------ + // transport + // ------------------------------------------------------------------ + + @Test + void discoveryFindsTheSyntheticEndpoints() { + final List found = new ArrayList(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.add(e); + } + }); + assertTrue(value(NearbyTransport.startDiscovery("chat", + TransportStrategy.CLUSTER)).booleanValue()); + assertEquals(2, found.size()); + assertEquals("chat", found.get(0).getServiceId()); + assertTrue(NearbyTransport.getMaxPayloadSize() > 0); + } + + @Test + void aConnectionOpensInTwoStepsTheWayARealOneDoes() { + final List connected = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void connected(Endpoint e) { + connected.add(e); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + assertNotNull(e); + // Resolving means the request was sent, not that we are connected. + assertTrue(value(NearbyTransport.requestConnection(e, "me")) + .booleanValue()); + assertEquals(1, connected.size()); + assertEquals(e, connected.get(0)); + } + + @Test + void aPayloadReportsProgressAndComesBackFromTheEcho() { + final List progress = + new ArrayList(); + final List received = new ArrayList(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void payloadProgress(Endpoint e, + PayloadTransferUpdate u) { + progress.add(u); + } + + @Override + public void payloadReceived(Endpoint e, Payload p) { + received.add(p); + } + }); + Endpoint e = connectedEndpoint(); + byte[] data = {1, 2, 3, 4, 5}; + assertTrue(value(NearbyTransport.send(e, Payload.fromBytes(data))) + .booleanValue()); + assertEquals(1, progress.size()); + assertSame(PayloadStatus.SUCCESS, progress.get(0).getStatus()); + assertEquals(5L, progress.get(0).getTotalBytes()); + assertEquals(1, received.size()); + assertEquals(Payload.TYPE_BYTES, received.get(0).getType()); + assertEquals(5, received.get(0).getBytes().length); + } + + @Test + void theEchoCanBeTurnedOffForATestThatCountsDeliveries() { + bridge.setEchoPayloads(false); + final AtomicInteger received = new AtomicInteger(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void payloadReceived(Endpoint e, Payload p) { + received.incrementAndGet(); + } + }); + value(NearbyTransport.send(connectedEndpoint(), + Payload.fromBytes(new byte[] {1}))); + assertEquals(0, received.get()); + } + + @Test + void aBytedPayloadOverTheLimitIsRefusedBeforeItReachesTheRadio() { + Endpoint e = connectedEndpoint(); + byte[] tooBig = new byte[NearbyTransport.getMaxPayloadSize() + 1]; + assertFailedWith(NearbyError.IO_ERROR, + NearbyTransport.send(e, Payload.fromBytes(tooBig))); + } + + @Test + void sendingToNobodyIsRefused() { + assertFailedWith(NearbyError.PEER_UNAVAILABLE, + NearbyTransport.send(new Endpoint[0], + Payload.fromBytes(new byte[] {1}))); + assertFailedWith(NearbyError.PEER_UNAVAILABLE, + NearbyTransport.send(new Endpoint[] {null}, + Payload.fromBytes(new byte[] {1}))); + } + + @Test + void connectingToAnEndpointThatIsNotThereFails() { + assertFailedWith(NearbyError.PEER_UNAVAILABLE, + NearbyTransport.requestConnection( + new Endpoint("ghost", "Ghost", "chat"), "me")); + } + + @Test + void disconnectingIsReportedAndIsIdempotent() { + final AtomicInteger drops = new AtomicInteger(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void disconnected(Endpoint e) { + drops.incrementAndGet(); + } + }); + Endpoint e = connectedEndpoint(); + NearbyTransport.disconnect(e); + assertEquals(1, drops.get()); + NearbyTransport.disconnect(e); + assertEquals(1, drops.get()); + } + + @Test + void stoppingEverythingDropsEveryConnection() { + final AtomicInteger drops = new AtomicInteger(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void disconnected(Endpoint e) { + drops.incrementAndGet(); + } + }); + connectedEndpoint(); + value(NearbyTransport.startAdvertising("chat", "me", + TransportStrategy.CLUSTER)); + assertTrue(bridge.isAdvertising()); + NearbyTransport.stop(); + assertEquals(1, drops.get()); + assertFalse(bridge.isAdvertising()); + assertFalse(bridge.isDiscovering()); + } + + @Test + void anUnansweredConnectionRequestIsRejectedRatherThanLeftHanging() { + // Nobody registers a listener, so nobody answers. The far side must + // learn that immediately instead of timing out. + NearbyTransport.deliverConnectionRequested( + "ep-x\tSomebody\tchat", "1234"); + // Reaching here without an exception is the assertion: the framework + // answered on the app's behalf. + } + + @Test + void aRemovedListenerStopsHearingThings() { + final AtomicInteger seen = new AtomicInteger(); + TransportAdapter l = new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + seen.incrementAndGet(); + } + }; + NearbyTransport.addTransportListener(l); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.CLUSTER)); + int after = seen.get(); + assertTrue(after > 0); + NearbyTransport.removeTransportListener(l); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.CLUSTER)); + assertEquals(after, seen.get()); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private RangingToken peerToken() { + return RangingToken.forPayload(RangingToken.PLATFORM_SIMULATED, + new byte[] {'p', 'e', 'e', 'r'}); + } + + private RangingSession running() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + value(s.start(peerToken())); + return s; + } + + /** + * Drives one more measurement out of a running session. + * + *

The simulation only re-arms its own timer when there is an event loop + * to re-arm it on, so under a unit test each trigger produces exactly one + * measurement. Suspending and resuming is the trigger.

+ */ + private void nudge(int handle) { + bridge.suspendSession(handle); + bridge.resumeSession(handle); + } + + private int handleOf() { + int[] handles = bridge.getSessionHandles(); + assertEquals(1, handles.length, + "these helpers assume exactly one live session"); + return handles[0]; + } + + private double[] walk() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + final List seen = new ArrayList(); + s.addRangingListener(new RangingAdapter() { + @Override + public void updated(RangingUpdate u) { + seen.add(Double.valueOf(u.getDistance(RangingUnit.METERS))); + } + }); + value(s.start(peerToken())); + int handle = handleOf(); + for (int i = 0; i < 12; i++) { + nudge(handle); + } + s.stop(); + double[] out = new double[seen.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = seen.get(i).doubleValue(); + } + return out; + } + + private Endpoint connectedEndpoint() { + final AtomicReference found = new AtomicReference(); + TransportAdapter finder = new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + }; + NearbyTransport.addTransportListener(finder); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.CLUSTER)); + NearbyTransport.removeTransportListener(finder); + Endpoint e = found.get(); + assertNotNull(e); + value(NearbyTransport.requestConnection(e, "me")); + return e; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyAwait.java b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyAwait.java new file mode 100644 index 00000000000..66b2404a210 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyAwait.java @@ -0,0 +1,111 @@ +/* + * 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.nearby; + +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Waiting on, and inspecting, a nearby operation. + * + *

Two things here that every test class would otherwise reinvent, and that + * {@code HomeAwait} spells out at length for the smart-home suite.

+ * + *

Settling. {@code LocalNearbyBridge} answers after a deliberate + * few-millisecond delay rather than inline, so {@code isDone()} straight after + * a call is false and that is the contract working rather than a hang.

+ * + *

Reading a failure. {@code AsyncResource} has no + * {@code getError()}; the way to see a failure is to register a callback and + * look at what it captured. That works because {@code EdtResult} leaves + * {@code except} synchronous on purpose -- introspecting a failure that + * already happened is not the same act as handling one.

+ */ +final class NearbyAwait { + + private static final long LIMIT_MILLIS = 10000L; + + private NearbyAwait() { + } + + /** Blocks until the operation settles, and returns it for chaining. */ + static AsyncResource settled(AsyncResource resource) { + long limit = System.currentTimeMillis() + LIMIT_MILLIS; + while (!resource.isDone() && System.currentTimeMillis() < limit) { + try { + Thread.sleep(2); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } + } + assertTrue(resource.isDone(), + "the operation must settle rather than hang"); + return resource; + } + + /** The failure a settled operation carries, or null when it succeeded. */ + static Throwable errorOf(AsyncResource resource) { + settled(resource); + if (resource.isReady()) { + return null; + } + final AtomicReference captured = + new AtomicReference(); + resource.except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + captured.set(value); + } + }); + return captured.get(); + } + + /** Asserts the operation failed for a particular typed reason. */ + static void assertFailedWith(NearbyError expected, + AsyncResource resource) { + Throwable error = errorOf(resource); + assertNotNull(error, "this operation was expected to fail with " + + expected.name() + " and it succeeded"); + assertTrue(error instanceof NearbyException, + "a nearby failure has to be a NearbyException so callers can" + + " branch on a typed reason rather than parsing a" + + " message; got " + error.getClass().getName()); + assertSame(expected, ((NearbyException) error).getError()); + } + + /** Asserts the operation succeeded, naming the failure when it did not. */ + static T value(AsyncResource resource) { + Throwable error = errorOf(resource); + if (error != null) { + throw new AssertionError("the operation failed: " + error); + } + return resource.get(); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java new file mode 100644 index 00000000000..f6aa0eb9e51 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java @@ -0,0 +1,162 @@ +/* + * 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.nearby; + +import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.nearby.companion.AssociationRequest; +import com.codename1.nearby.companion.CompanionDevices; +import com.codename1.nearby.ranging.Ranging; +import com.codename1.nearby.ranging.RangingCapabilities; +import com.codename1.nearby.ranging.RangingRole; +import com.codename1.nearby.transport.Endpoint; +import com.codename1.nearby.transport.NearbyTransport; +import com.codename1.nearby.transport.Payload; +import com.codename1.nearby.transport.TransportStrategy; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.codename1.nearby.NearbyAwait.assertFailedWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What every entry point does on a port that implements no bridge at all -- + * which is most of them, and is the state an app hits on a device whose OS is + * too old. + * + *

The rule the whole family is built on: nothing returns null and + * nothing hangs. A query answers a "no" the caller can act on, and an + * operation fails fast with {@code NOT_SUPPORTED} rather than handing back a + * resource that never settles. That is what lets application code skip the + * platform conditionals entirely.

+ */ +class NearbyDegradationTest { + + @BeforeEach + void noBridgeAtAll() { + NearbyRequests.resetForTest(null); + } + + @AfterEach + void clear() { + NearbyRequests.resetForTest(null); + } + + @Test + void everyEntryPointReportsItselfUnsupported() { + assertFalse(Ranging.isSupported()); + assertFalse(CompanionDevices.isSupported()); + assertFalse(NearbyTransport.isSupported()); + assertSame(NearbyAvailability.NOT_SUPPORTED, Ranging.getAvailability()); + assertSame(NearbyAvailability.NOT_SUPPORTED, + CompanionDevices.getAvailability()); + assertSame(NearbyAvailability.NOT_SUPPORTED, + NearbyTransport.getAvailability()); + } + + @Test + void capabilitiesAreAllFalseRatherThanNull() { + RangingCapabilities c = Ranging.getCapabilities(); + assertNotNull(c, "getCapabilities must never return null: the whole" + + " point is that callers need no null check"); + assertSame(RangingCapabilities.UNSUPPORTED, c); + assertFalse(c.isDistanceSupported()); + assertFalse(c.isDirectionSupported()); + assertFalse(c.isElevationSupported()); + assertFalse(c.isCameraAssistanceSupported()); + assertFalse(c.isAccessoryRangingSupported()); + assertFalse(c.isBackgroundRangingSupported()); + } + + @Test + void listQueriesAreEmptyRatherThanNull() { + assertNotNull(CompanionDevices.getAssociations()); + assertTrue(CompanionDevices.getAssociations().isEmpty()); + assertEquals(0, NearbyTransport.getMaxPayloadSize()); + } + + @Test + void everyOperationFailsFastRatherThanHanging() { + assertFailedWith(NearbyError.NOT_SUPPORTED, + Ranging.requestPermissions(NearbyPermission.RANGING)); + assertFailedWith(NearbyError.NOT_SUPPORTED, + Ranging.prepareSession(RangingRole.CONTROLLER)); + assertFailedWith(NearbyError.NOT_SUPPORTED, CompanionDevices.associate( + new AssociationRequest.Builder().build())); + assertFailedWith(NearbyError.NOT_SUPPORTED, + CompanionDevices.disassociate("whatever")); + assertFailedWith(NearbyError.NOT_SUPPORTED, + NearbyTransport.startAdvertising("svc", "me", + TransportStrategy.CLUSTER)); + assertFailedWith(NearbyError.NOT_SUPPORTED, + NearbyTransport.startDiscovery("svc", + TransportStrategy.CLUSTER)); + assertFailedWith(NearbyError.NOT_SUPPORTED, + NearbyTransport.requestConnection( + new Endpoint("e", "n", "svc"), "me")); + assertFailedWith(NearbyError.NOT_SUPPORTED, + NearbyTransport.send(new Endpoint("e", "n", "svc"), + Payload.fromBytes(new byte[] {1}))); + } + + @Test + void voidOperationsAreInertRatherThanThrowing() { + // An app tearing its UI down calls these on the way out, and it must + // not have to know whether the feature was ever supported. + CompanionDevices.stopObservingPresence("nope"); + assertFalse(CompanionDevices.startObservingPresence("nope")); + NearbyTransport.stopAdvertising(); + NearbyTransport.stopDiscovery(); + NearbyTransport.disconnect(new Endpoint("e", "n", "svc")); + NearbyTransport.cancel(7); + NearbyTransport.stop(); + } + + @Test + void deliveriesForRequestsNobodyIsWaitingOnAreIgnored() { + // A port that answers twice, or answers after the caller cancelled, + // must not take the process down with it. + Ranging.deliverPermissionResult(9999, true); + Ranging.deliverSessionStarted(9999, 1234); + Ranging.deliverRequestFailed(9999, NearbyError.TIMEOUT.ordinal(), "x"); + CompanionDevices.deliverDisassociated(9999); + CompanionDevices.deliverRequestFailed(9999, 0, null); + NearbyTransport.deliverRequestOk(9999); + NearbyTransport.deliverRequestFailed(9999, 0, null); + } + + @Test + void eventsNamingAMalformedRecordAreDroppedNotThrown() { + // Native code hands these over; a record with no id is a bug in a + // port, and losing that one event beats taking down the delivery. + CompanionDevices.deliverPresenceChanged("", true); + NearbyTransport.deliverEndpointFound("", true); + NearbyTransport.deliverDisconnected(""); + NearbyTransport.deliverConnectionRequested("", "1234"); + NearbyTransport.deliverPayloadReceived("", 1, 0, new byte[0], null); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyWireTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyWireTest.java new file mode 100644 index 00000000000..08b40fda390 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyWireTest.java @@ -0,0 +1,177 @@ +/* + * 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.nearby; + +import com.codename1.impl.nearby.NearbyWire; +import com.codename1.nearby.companion.CompanionDevice; +import com.codename1.nearby.companion.CompanionProfile; +import com.codename1.nearby.companion.DeviceFilter; +import com.codename1.nearby.transport.Endpoint; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The encoding the SPI speaks. + * + *

The property that matters most is that every decoder is total. + * Records arrive from native code in batches, so a decoder that threw on a bad + * row would discard the good rows next to it -- and the bad row is most often + * a port from a newer build naming something this one has not heard of.

+ */ +class NearbyWireTest { + + @Test + void splitPreservesTrailingEmptyFields() { + // String.split drops them, which would shift every index for a device + // that has no address and is not present. + String[] f = NearbyWire.split("a\tb\t\t"); + assertEquals(4, f.length); + assertEquals("a", f[0]); + assertEquals("b", f[1]); + assertEquals("", f[2]); + assertEquals("", f[3]); + } + + @Test + void readingPastTheEndOfARecordGivesEmptyRatherThanThrowing() { + String[] f = NearbyWire.split("only"); + assertEquals("", NearbyWire.field(f, 7)); + assertEquals("", NearbyWire.field(null, 0)); + assertEquals("", NearbyWire.field(f, -1)); + assertEquals(5, NearbyWire.integer(f, 7, 5)); + assertEquals(5L, NearbyWire.integer64(f, 7, 5L)); + assertTrue(!NearbyWire.flag(f, 7)); + } + + @Test + void aFieldThatIsNotANumberFallsBackRatherThanThrowing() { + String[] f = NearbyWire.split("abc\t12"); + assertEquals(-1, NearbyWire.integer(f, 0, -1)); + assertEquals(12, NearbyWire.integer(f, 1, -1)); + assertEquals(-1L, NearbyWire.integer64(f, 0, -1L)); + } + + @Test + void aSeparatorInsideAFieldCannotSplitTheRecord() { + String encoded = NearbyWire.join(new String[] { + "id", "a\tname\nwith\rcontrol chars", "svc" + }); + String[] f = NearbyWire.split(encoded); + assertEquals(3, f.length); + assertEquals("a name with control chars", f[1]); + } + + @Test + void aNullFieldEncodesAsEmpty() { + assertEquals("", NearbyWire.sanitize(null)); + assertEquals("a\t\tb", NearbyWire.join(new String[] {"a", null, "b"})); + assertEquals("", NearbyWire.join(null)); + } + + @Test + void aCompanionDeviceSurvivesTheRoundTrip() { + CompanionDevice d = new CompanionDevice("assoc-1", "Watch", + "00:11:22:33:44:55", CompanionProfile.WATCH, true); + CompanionDevice back = NearbyWire.decodeCompanionDevice( + NearbyWire.encodeCompanionDevice(d)); + assertNotNull(back); + assertEquals("assoc-1", back.getId()); + assertEquals("Watch", back.getDisplayName()); + assertEquals("00:11:22:33:44:55", back.getAddress()); + assertSame(CompanionProfile.WATCH, back.getProfile()); + assertTrue(back.isPresent()); + } + + @Test + void anAbsentAddressDecodesToNullRatherThanEmpty() { + // getAddress() documents null as "the platform withholds it", and an + // empty string here would be handed straight to + // BluetoothLE.getPeripheral. + CompanionDevice d = new CompanionDevice("assoc-2", "Tag", null, + CompanionProfile.GENERIC, false); + CompanionDevice back = NearbyWire.decodeCompanionDevice( + NearbyWire.encodeCompanionDevice(d)); + assertNotNull(back); + assertNull(back.getAddress()); + assertTrue(!back.isPresent()); + } + + @Test + void aRecordWithNoIdDecodesToNullSoTheCallerCanSkipIt() { + assertNull(NearbyWire.decodeCompanionDevice("")); + assertNull(NearbyWire.decodeCompanionDevice("\tname\t\t0\t0")); + assertNull(NearbyWire.decodeCompanionDevice(null)); + assertNull(NearbyWire.decodeEndpoint("")); + assertNull(NearbyWire.decodeEndpoint(null)); + } + + @Test + void aProfileOrdinalFromANewerBuildDegradesRatherThanLosingTheRecord() { + CompanionDevice back = NearbyWire.decodeCompanionDevice( + "assoc-3\tSomething\t\t97\t1"); + assertNotNull(back, "an unknown profile must not cost us the device"); + assertSame(CompanionProfile.GENERIC, back.getProfile()); + assertSame(CompanionProfile.GENERIC, NearbyWire.profileFor(-1)); + } + + @Test + void anEndpointSurvivesTheRoundTrip() { + Endpoint e = new Endpoint("ep-1", "Phone", "svc"); + Endpoint back = NearbyWire.decodeEndpoint(NearbyWire.encodeEndpoint(e)); + assertNotNull(back); + assertEquals("ep-1", back.getId()); + assertEquals("Phone", back.getName()); + assertEquals("svc", back.getServiceId()); + assertEquals(e, back); + } + + @Test + void aFilterEncodesAsItsKindAndValue() { + String[] f = NearbyWire.split( + NearbyWire.encodeFilter(DeviceFilter.bleService("180D"))); + assertEquals(DeviceFilter.KIND_BLE_SERVICE, + NearbyWire.integer(f, 0, -1)); + assertEquals("180D", NearbyWire.field(f, 1)); + } + + @Test + void decodeErrorIsTheOneDecoderThatAlwaysProducesSomething() { + NearbyException known = NearbyWire.decodeError( + NearbyError.TIMEOUT.ordinal(), "took too long"); + assertSame(NearbyError.TIMEOUT, known.getError()); + assertEquals("took too long", known.getMessage()); + + NearbyException unknown = NearbyWire.decodeError(9999, null); + assertSame(NearbyError.UNKNOWN, unknown.getError()); + assertEquals("UNKNOWN", unknown.getMessage()); + + NearbyException blank = NearbyWire.decodeError( + NearbyError.BUSY.ordinal(), ""); + assertEquals("BUSY", blank.getMessage()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java new file mode 100644 index 00000000000..4900f35a29d --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java @@ -0,0 +1,147 @@ +/* + * 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.nearby; + +import com.codename1.nearby.ranging.RangingToken; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The token is the one value in this API that travels over a wire the + * framework does not control -- an app writes it into a GATT characteristic + * and reads whatever comes back. So it has to survive the round trip, and it + * has to reject what is not one of ours rather than handing garbage to a + * native call. + */ +class RangingTokenTest { + + @Test + void aTokenSurvivesTheRoundTrip() { + RangingToken original = RangingToken.forPayload( + RangingToken.PLATFORM_APPLE_NI, + new byte[] {1, 2, 3, (byte) 200, 0, -7}); + RangingToken back = RangingToken.fromByteArray(original.toByteArray()); + assertEquals(RangingToken.PLATFORM_APPLE_NI, back.getPlatform()); + assertArrayEquals(original.getPayload(), back.getPayload()); + assertEquals(original, back); + assertEquals(original.hashCode(), back.hashCode()); + } + + @Test + void anEmptyPayloadIsStillAValidToken() { + RangingToken t = RangingToken.forPayload( + RangingToken.PLATFORM_SIMULATED, new byte[0]); + RangingToken back = RangingToken.fromByteArray(t.toByteArray()); + assertEquals(0, back.getPayload().length); + assertEquals(RangingToken.PLATFORM_SIMULATED, back.getPlatform()); + } + + @Test + void aUwbAddressTokenCarriesItsParameters() { + byte[] address = {(byte) 0xAB, (byte) 0xCD}; + byte[] key = {9, 8, 7, 6, 5, 4, 3, 2}; + RangingToken t = RangingToken.forUwbAddress(address, 9, 11, 42, key); + assertEquals(RangingToken.PLATFORM_ANDROID_UWB, t.getPlatform()); + RangingToken back = RangingToken.fromByteArray(t.toByteArray()); + assertEquals(RangingToken.PLATFORM_ANDROID_UWB, back.getPlatform()); + assertArrayEquals(t.getPayload(), back.getPayload()); + } + + @Test + void aUwbAddressTokenAcceptsAnEightByteAddressAndNoKey() { + RangingToken t = RangingToken.forUwbAddress( + new byte[] {1, 2, 3, 4, 5, 6, 7, 8}, 5, 9, 1, null); + assertEquals(RangingToken.PLATFORM_ANDROID_UWB, + RangingToken.fromByteArray(t.toByteArray()).getPlatform()); + } + + @Test + void aUwbAddressOfTheWrongLengthIsRejectedAtTheCallSite() { + // Better here, where the stack trace names the app's own code, than + // three layers down in a native call that reads past the end. + assertThrows(IllegalArgumentException.class, + () -> RangingToken.forUwbAddress(new byte[] {1, 2, 3}, 9, 11, + 1, null)); + } + + @Test + void garbageIsRejectedRatherThanDecoded() { + assertThrows(IllegalArgumentException.class, + () -> RangingToken.fromByteArray(null)); + assertThrows(IllegalArgumentException.class, + () -> RangingToken.fromByteArray(new byte[0])); + assertThrows(IllegalArgumentException.class, + () -> RangingToken.fromByteArray("not a token at all".getBytes())); + } + + @Test + void aTruncatedTokenIsRejectedRatherThanReadPastItsEnd() { + byte[] full = RangingToken.forPayload(RangingToken.PLATFORM_APPLE_NI, + new byte[] {1, 2, 3, 4, 5, 6, 7, 8}).toByteArray(); + byte[] cut = new byte[full.length - 3]; + System.arraycopy(full, 0, cut, 0, cut.length); + assertThrows(IllegalArgumentException.class, + () -> RangingToken.fromByteArray(cut)); + } + + @Test + void anUnknownVersionIsRejectedRatherThanGuessedAt() { + byte[] t = RangingToken.forPayload(RangingToken.PLATFORM_APPLE_NI, + new byte[] {1}).toByteArray(); + t[4] = 99; + assertThrows(IllegalArgumentException.class, + () -> RangingToken.fromByteArray(t)); + } + + @Test + void theEncodedFormIsCopiedSoACallerCannotMutateTheToken() { + RangingToken t = RangingToken.forPayload( + RangingToken.PLATFORM_SIMULATED, new byte[] {1, 2, 3}); + byte[] a = t.toByteArray(); + byte[] b = t.toByteArray(); + assertNotSame(a, b); + a[10] = 99; + assertArrayEquals(new byte[] {1, 2, 3}, t.getPayload()); + assertArrayEquals(b, t.toByteArray()); + byte[] payload = t.getPayload(); + payload[0] = 42; + assertArrayEquals(new byte[] {1, 2, 3}, t.getPayload()); + } + + @Test + void tokensFromDifferentPlatformsAreNotEqual() { + RangingToken apple = RangingToken.forPayload( + RangingToken.PLATFORM_APPLE_NI, new byte[] {1, 2}); + RangingToken android = RangingToken.forPayload( + RangingToken.PLATFORM_ANDROID_UWB, new byte[] {1, 2}); + assertFalse(apple.equals(android)); + assertTrue(apple.equals(apple)); + assertFalse(apple.equals("not a token")); + } +} From 2a1b00dd09ff4c10e9e4dee90c924aa540373f47 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:22:30 +0300 Subject: [PATCH 02/94] Nearby devices: the iOS port Carries com.codename1.nearby onto Nearby Interaction, MultipeerConnectivity and AccessorySetupKit. Behaviour is unchanged for every existing app: the three CN1_NEARBY_* defines are commented out, and the #else half of CN1Nearby.m answers every native with "unsupported" so a build that never touched the package links exactly as before and carries none of the three frameworks' symbols. Three gates, not one. An app that only wants to know how far away its tag is must not link MultipeerConnectivity, because linking it obliges NSLocalNetworkUsageDescription and puts a local-network prompt in front of a user who never asked for one. So CN1_NEARBY_RANGING, CN1_NEARBY_TRANSPORT and CN1_NEARBY_COMPANION are separate, the three isSupported answers are separate, and a tvOS build reports a working transport with no ranging rather than reporting the whole feature missing. Four things here that would have been silent failures. NINearbyObject.distance and .direction are plain scalars in Objective-C, not the optionals they are in Swift, and "not available" is signalled in-band as NaN. Testing them for nil compiles and always takes the has-a-value branch, so the app renders an arrow pointing at NaN. Both are NaN-tested instead. The direction vector is folded to azimuth and elevation with atan2(x, -z), because Apple's frame puts forward at negative z. Android reports those two angles directly, and this is the conversion that makes the same application code read the same on both platforms. fromNSString, toNSString and nsDataToByteArr live in IOSNative.m and no shared header exports them, so they are declared per translation unit as CN1Bluetooth.m and CN1Camera.m do. Without that the file compiles with an implicit declaration and reads the result out of the wrong register. A session that dies before its start request was answered fails that request explicitly. A caller holding an AsyncResource that never settles has no way to find out, which is worse than being told the session failed. Two honest limitations, both documented at the point of the code rather than buried. startObservingPresence returns false: AccessorySetupKit reports an accessory being added to or removed from the app's set, which is not the same event as it coming into range, and reporting those as presence would tell an app a device in a drawer is nearby. And MultipeerConnectivity refuses a service type outside 1-15 lowercase characters by raising -- which on a device is a crash rather than an error an app can show -- so a reverse-DNS service id that is legal on Android is folded into something legal here. Every configuration was syntax-checked against the iOS 26.2 SDK for arm64-apple-ios and arm64-apple-tvos: no defines, each of the three alone, and all three together, with zero errors and zero warnings in each. All 29 natives resolve under scripts/check-native-signatures.sh, and all 22 callback call sites were checked mechanically against the Java methods they name -- the verifier does not cover that direction, and a typo there is a link error that only an app build would find. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/CN1Nearby.h | 110 ++ Ports/iOSPort/nativeSources/CN1Nearby.m | 1614 +++++++++++++++++ .../CodenameOne_GLViewController.h | 30 + .../codename1/impl/ios/IOSImplementation.java | 18 + .../src/com/codename1/impl/ios/IOSNative.java | 133 ++ .../codename1/impl/ios/IOSNearbyBridge.java | 223 +++ .../impl/ios/IOSNearbyCallbacks.java | 332 ++++ 7 files changed, 2460 insertions(+) create mode 100644 Ports/iOSPort/nativeSources/CN1Nearby.h create mode 100644 Ports/iOSPort/nativeSources/CN1Nearby.m create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyBridge.java create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.h b/Ports/iOSPort/nativeSources/CN1Nearby.h new file mode 100644 index 00000000000..ffad71f6169 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Nearby.h @@ -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. + */ + +// +// CN1Nearby.h +// The Nearby Interaction, MultipeerConnectivity and AccessorySetupKit +// bridge behind com.codename1.nearby. +// +// Everything here is gated on CN1_INCLUDE_NEARBY, which IPhoneBuilder +// uncomments in CodenameOne_GLViewController.h only for apps that reference +// com.codename1.nearby. The three halves are gated again, separately, on +// CN1_NEARBY_RANGING, CN1_NEARBY_TRANSPORT and CN1_NEARBY_COMPANION -- an app +// that only wants ranging must not link MultipeerConnectivity, because +// linking it obliges NSLocalNetworkUsageDescription and puts a local-network +// prompt in front of a user who never asked for one. +// +// The sessions, the peer registry and the pending-request bookkeeping are all +// file-static in CN1Nearby.m -- nothing is exported -- so this header exists +// only to carry the shared ordinal constants. +// +// The #else branch of CN1Nearby.m provides no-op trampolines for every native +// declared in IOSNative.java, so an app that never touched the package still +// links. +// + +#ifndef CN1_NEARBY_H +#define CN1_NEARBY_H + +#import + +// com.codename1.nearby.NearbyAvailability ordinals. The order there is the +// contract and this is the only place it is repeated, so every constant is +// spelled with its Java name and NearbyNativeConstantParityTest compares the +// two: appending to that enum silently repoints these defines, and the failure +// is a device reporting the wrong state, which no build can show. +#define CN1_NEARBY_AVAIL_AVAILABLE 0 +#define CN1_NEARBY_AVAIL_LOCAL_ONLY 1 +#define CN1_NEARBY_AVAIL_UNAUTHORIZED 2 +#define CN1_NEARBY_AVAIL_TEMPORARILY_UNAVAILABLE 3 +#define CN1_NEARBY_AVAIL_NOT_SUPPORTED 4 + +// com.codename1.nearby.NearbyError ordinals. +#define CN1_NEARBY_ERR_NOT_SUPPORTED 0 +#define CN1_NEARBY_ERR_UNAUTHORIZED 1 +#define CN1_NEARBY_ERR_RADIO_UNAVAILABLE 2 +#define CN1_NEARBY_ERR_PEER_UNAVAILABLE 3 +#define CN1_NEARBY_ERR_SESSION_FAILED 4 +#define CN1_NEARBY_ERR_SESSION_INVALIDATED 5 +#define CN1_NEARBY_ERR_INVALID_TOKEN 6 +#define CN1_NEARBY_ERR_TIMEOUT 7 +#define CN1_NEARBY_ERR_BUSY 8 +#define CN1_NEARBY_ERR_USER_CANCELED 9 +#define CN1_NEARBY_ERR_IO_ERROR 10 +#define CN1_NEARBY_ERR_UNKNOWN 11 + +// com.codename1.nearby.ranging.RangingRemovalReason ordinals. +#define CN1_NEARBY_REMOVED_PEER_ENDED 0 +#define CN1_NEARBY_REMOVED_TIMEOUT 1 +#define CN1_NEARBY_REMOVED_UNKNOWN 2 + +// com.codename1.nearby.transport.PayloadStatus ordinals. +#define CN1_NEARBY_PAYLOAD_IN_PROGRESS 0 +#define CN1_NEARBY_PAYLOAD_SUCCESS 1 +#define CN1_NEARBY_PAYLOAD_FAILURE 2 +#define CN1_NEARBY_PAYLOAD_CANCELED 3 + +// com.codename1.nearby.transport.TransportStrategy ordinals. +#define CN1_NEARBY_STRATEGY_CLUSTER 0 +#define CN1_NEARBY_STRATEGY_STAR 1 +#define CN1_NEARBY_STRATEGY_POINT_TO_POINT 2 + +// com.codename1.nearby.companion.DeviceFilter kind constants. +#define CN1_NEARBY_FILTER_BLE_SERVICE 0 +#define CN1_NEARBY_FILTER_NAME_PATTERN 1 +#define CN1_NEARBY_FILTER_ADDRESS 2 +#define CN1_NEARBY_FILTER_WIFI_SSID 3 + +// com.codename1.nearby.spi.NearbyBridge capability bits. +#define CN1_NEARBY_CAP_DISTANCE 1 +#define CN1_NEARBY_CAP_DIRECTION 2 +#define CN1_NEARBY_CAP_ELEVATION 4 +#define CN1_NEARBY_CAP_CAMERA_ASSISTANCE 8 +#define CN1_NEARBY_CAP_ACCESSORY 16 +#define CN1_NEARBY_CAP_BACKGROUND 32 + +// com.codename1.nearby.spi.NearbyBridge payload types. +#define CN1_NEARBY_PAYLOAD_BYTES 0 +#define CN1_NEARBY_PAYLOAD_FILE 1 + +#endif diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m new file mode 100644 index 00000000000..10f884ebc01 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -0,0 +1,1614 @@ +/* + * 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. + */ + +#import "CodenameOne_GLViewController.h" +#import "CN1Nearby.h" + +#ifdef CN1_INCLUDE_NEARBY + +#include "com_codename1_impl_ios_IOSNearbyCallbacks.h" +#import "java_lang_String.h" + +#if defined(CN1_NEARBY_RANGING) && __has_include() +#import +#import +#import +#define CN1_NEARBY_HAS_NI 1 +#endif + +#if defined(CN1_NEARBY_TRANSPORT) \ + && __has_include() +#import +#define CN1_NEARBY_HAS_MPC 1 +#endif + +#if defined(CN1_NEARBY_COMPANION) \ + && __has_include() +#import +// ASDiscoveryDescriptor.bluetoothServiceUUID is a CBUUID, so the companion +// half links CoreBluetooth. That is only the type -- no scanning happens here, +// and the point of AccessorySetupKit is precisely that the app does not need +// the blanket Bluetooth authorization to talk to what the user picked. +#import +#define CN1_NEARBY_HAS_ASK 1 +#endif + +// --------------------------------------------------------------------- +// Three frameworks, three lifetimes, one bridge +// +// Nearby Interaction, MultipeerConnectivity and AccessorySetupKit share +// nothing but this file. Each is compiled in only when its own define is on, +// and each is also guarded with __has_include so an older Xcode that has never +// heard of AccessorySetupKit still builds the other two rather than failing +// the whole app. +// +// Everything below is manual retain/release, like CN1Bluetooth.m beside it. +// Blocks that outlive their call -- the MultipeerConnectivity invitation +// handler is the only one -- are copied on the way into a dictionary and +// released when they are answered. +// +// Threads: NI, MPC and ASK all call back on queues of their own, and under +// ParparVM none of them is the Codename One EDT. Nothing here hops; the +// callbacks forward straight to IOSNearbyCallbacks, which forwards to the +// public facades, and those own EDT dispatch. That is the same arrangement +// CN1SmartHome.m uses and the reason it is safe. +// --------------------------------------------------------------------- + +// Declared per translation unit, as CN1Bluetooth.m and CN1Camera.m do: these +// live in IOSNative.m and no shared header exports them, so a file that uses +// one without saying so compiles with an implicit declaration and then reads +// its result out of the wrong register. +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); + +static JAVA_OBJECT cn1nbJString(NSString *s) { + return s == nil ? JAVA_NULL : fromNSString(getThreadLocalData(), s); +} + +static JAVA_OBJECT cn1nbJBytes(NSData *d) { + return d == nil ? JAVA_NULL : nsDataToByteArr(d); +} + +static NSData *cn1nbDataFromJavaArray(JAVA_OBJECT arr) { + if (arr == JAVA_NULL) { + return nil; + } + JAVA_ARRAY a = (JAVA_ARRAY)arr; + if (a->length <= 0) { + return [NSData data]; + } + return [NSData dataWithBytes:a->data length:a->length]; +} + +/// Replaces the characters a tab-delimited record cannot carry, exactly as +/// NearbyWire.sanitize does on the Java side. A device whose name contains a +/// tab would otherwise shift every field after it. +static NSString *cn1nbSanitize(NSString *s) { + if (s == nil) { + return @""; + } + NSString *out = [s stringByReplacingOccurrencesOfString:@"\t" + withString:@" "]; + out = [out stringByReplacingOccurrencesOfString:@"\n" withString:@" "]; + return [out stringByReplacingOccurrencesOfString:@"\r" withString:@" "]; +} + +static NSString *cn1nbJoin(NSArray *fields) { + NSMutableArray *safe = [NSMutableArray arrayWithCapacity:[fields count]]; + for (NSString *f in fields) { + [safe addObject:cn1nbSanitize(f)]; + } + return [safe componentsJoinedByString:@"\t"]; +} + +static NSArray *cn1nbSplitLines(NSString *joined) { + if (joined == nil || [joined length] == 0) { + return [NSArray array]; + } + return [joined componentsSeparatedByString:@"\n"]; +} + +static void cn1nbFailRanging(int requestId, int error, NSString *message) { + com_codename1_impl_ios_IOSNearbyCallbacks_rangingFailed___int_int_java_lang_String( + getThreadLocalData(), requestId, error, cn1nbJString(message)); +} + +static void cn1nbFailCompanion(int requestId, int error, NSString *message) { + com_codename1_impl_ios_IOSNearbyCallbacks_companionFailed___int_int_java_lang_String( + getThreadLocalData(), requestId, error, cn1nbJString(message)); +} + +static void cn1nbFailTransport(int requestId, int error, NSString *message) { + com_codename1_impl_ios_IOSNearbyCallbacks_transportFailed___int_int_java_lang_String( + getThreadLocalData(), requestId, error, cn1nbJString(message)); +} + +static void cn1nbTransportOk(int requestId) { + com_codename1_impl_ios_IOSNearbyCallbacks_transportOk___int( + getThreadLocalData(), requestId); +} + +// ===================================================================== +// Ranging -- Nearby Interaction +// ===================================================================== + +#ifdef CN1_NEARBY_HAS_NI + +API_AVAILABLE(ios(14.0)) +@interface CN1NearbyRangingSession : NSObject +@property (nonatomic, assign) int handle; +@property (nonatomic, assign) int pendingStartRequest; +@property (nonatomic, retain) NISession *session; +@end + +static NSMutableDictionary *cn1nbSessions = nil; + +static void cn1nbSessionsInit(void) { + if (cn1nbSessions == nil) { + cn1nbSessions = [[NSMutableDictionary alloc] init]; + } +} + +@implementation CN1NearbyRangingSession + +- (void)dealloc { + [_session release]; + [super dealloc]; +} + +/// Folds Apple's unit direction vector into the azimuth and elevation the +/// portable API reports. +/// +/// The frame is x right, y up, z toward the user, so forward is negative z -- +/// which is why the azimuth is atan2(x, -z) and not atan2(x, z). Android +/// reports these two angles directly, and this is the conversion that makes +/// the same code read the same on both. +- (void)deliver:(NINearbyObject *)object { + // Both of these are plain scalars in Objective-C, NOT nullable objects as + // they are in Swift, and "not available" is signalled in-band: the + // distance and every component of the direction come back NaN. Testing + // them for nil would compile and always take the has-a-value branch, and + // the app would render an arrow pointing at NaN. + float rawDistance = object.distance; + simd_float3 d = object.direction; + JAVA_BOOLEAN hasDistance = + (!isnan(rawDistance) && rawDistance >= 0 + && rawDistance != NINearbyObjectDistanceNotAvailable) + ? JAVA_TRUE : JAVA_FALSE; + JAVA_DOUBLE distance = hasDistance == JAVA_TRUE ? rawDistance : 0; + JAVA_BOOLEAN hasDirection = JAVA_FALSE; + JAVA_DOUBLE azimuth = 0; + JAVA_DOUBLE elevation = 0; + JAVA_FLOAT x = 0; + JAVA_FLOAT y = 0; + JAVA_FLOAT z = 0; + if (!isnan(d.x) && !isnan(d.y) && !isnan(d.z)) { + x = d.x; + y = d.y; + z = d.z; + hasDirection = JAVA_TRUE; + azimuth = atan2f(d.x, -d.z) * 180.0f / (float)M_PI; + float clamped = d.y < -1.0f ? -1.0f : (d.y > 1.0f ? 1.0f : d.y); + elevation = asinf(clamped) * 180.0f / (float)M_PI; + } + com_codename1_impl_ios_IOSNearbyCallbacks_rangingUpdate___int_boolean_double_boolean_double_boolean_double_boolean_float_float_float( + getThreadLocalData(), self.handle, hasDistance, distance, + hasDirection, azimuth, hasDirection, elevation, hasDirection, + x, y, z); +} + +- (void)session:(NISession *)session + didUpdateNearbyObjects:(NSArray *)nearbyObjects { + @autoreleasepool { + for (NINearbyObject *o in nearbyObjects) { + [self deliver:o]; + } + } +} + +- (void)session:(NISession *)session + didRemoveNearbyObjects:(NSArray *)nearbyObjects + withReason:(NINearbyObjectRemovalReason)reason { + @autoreleasepool { + int mapped = reason == NINearbyObjectRemovalReasonPeerEnded + ? CN1_NEARBY_REMOVED_PEER_ENDED : CN1_NEARBY_REMOVED_TIMEOUT; + com_codename1_impl_ios_IOSNearbyCallbacks_peerRemoved___int_int( + getThreadLocalData(), self.handle, mapped); + } +} + +- (void)sessionWasSuspended:(NISession *)session { + @autoreleasepool { + com_codename1_impl_ios_IOSNearbyCallbacks_sessionSuspended___int( + getThreadLocalData(), self.handle); + } +} + +- (void)sessionSuspensionEnded:(NISession *)session { + @autoreleasepool { + // Apple requires the configuration to be run again after a + // suspension; the session does not resume by itself. Doing it here + // rather than making the app do it is what lets the portable API + // promise that a resumed session starts measuring again. + if (session.configuration != nil) { + [session runWithConfiguration:session.configuration]; + } + com_codename1_impl_ios_IOSNearbyCallbacks_sessionResumed___int( + getThreadLocalData(), self.handle); + } +} + +- (void)session:(NISession *)session didInvalidateWithError:(NSError *)error { + @autoreleasepool { + int code = CN1_NEARBY_ERR_SESSION_INVALIDATED; + if (@available(iOS 14.0, *)) { + if (error.code == NIErrorCodeUserDidNotAllow) { + code = CN1_NEARBY_ERR_UNAUTHORIZED; + } else if (error.code == NIErrorCodeResourceUsageTimeout) { + code = CN1_NEARBY_ERR_TIMEOUT; + } + } + int handle = self.handle; + int pending = self.pendingStartRequest; + self.pendingStartRequest = 0; + if (pending != 0) { + // A session that dies before its start request was answered has + // a caller holding a resource that would otherwise never settle. + cn1nbFailRanging(pending, code, [error localizedDescription]); + } + com_codename1_impl_ios_IOSNearbyCallbacks_sessionInvalidated___int_int_java_lang_String( + getThreadLocalData(), handle, code, + cn1nbJString([error localizedDescription])); + [cn1nbSessions removeObjectForKey:[NSNumber numberWithInt:handle]]; + } +} + +- (void)session:(NISession *)session + didGenerateShareableConfigurationData:(NSData *)shareableConfigurationData + forObject:(NINearbyObject *)object API_AVAILABLE(ios(16.0)) { + @autoreleasepool { + int pending = self.pendingStartRequest; + if (pending == 0) { + return; + } + self.pendingStartRequest = 0; + com_codename1_impl_ios_IOSNearbyCallbacks_accessoryConfiguration___int_int_byte_1ARRAY( + getThreadLocalData(), pending, self.handle, + cn1nbJBytes(shareableConfigurationData)); + } +} + +@end + +static CN1NearbyRangingSession *cn1nbSessionFor(int handle) + API_AVAILABLE(ios(14.0)) { + cn1nbSessionsInit(); + return [cn1nbSessions objectForKey:[NSNumber numberWithInt:handle]]; +} + +#endif // CN1_NEARBY_HAS_NI + +// ===================================================================== +// Transport -- MultipeerConnectivity +// ===================================================================== + +#ifdef CN1_NEARBY_HAS_MPC + +@interface CN1NearbyTransport : NSObject +@property (nonatomic, retain) MCPeerID *localPeer; +@property (nonatomic, retain) MCSession *session; +@property (nonatomic, retain) MCNearbyServiceAdvertiser *advertiser; +@property (nonatomic, retain) MCNearbyServiceBrowser *browser; +@property (nonatomic, retain) NSMutableDictionary *peersById; +@property (nonatomic, retain) NSMutableDictionary *invitations; +@property (nonatomic, retain) NSMutableDictionary *progressByPayload; +@property (nonatomic, retain) NSString *serviceType; +@end + +static CN1NearbyTransport *cn1nbTransport = nil; + +/// MultipeerConnectivity refuses a service type that is not 1-15 characters +/// of lowercase ASCII letters, digits and hyphens -- it raises, which on a +/// device is a crash rather than an error the app can show. Android has no +/// such rule, so a perfectly good reverse-DNS service id from a cross-platform +/// app arrives here illegal. Folding it into something legal beats crashing, +/// and the public API documents the constraint so an app can pick a name that +/// survives the fold unchanged. +static NSString *cn1nbServiceType(NSString *serviceId) { + NSMutableString *out = [NSMutableString stringWithCapacity:15]; + NSString *lower = [serviceId lowercaseString]; + for (NSUInteger i = 0; i < [lower length] && [out length] < 15; i++) { + unichar c = [lower characterAtIndex:i]; + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + [out appendFormat:@"%C", c]; + } else if ([out length] > 0 && [out length] < 15) { + // A hyphen may not lead or trail, and two may not be adjacent. + if (![out hasSuffix:@"-"]) { + [out appendString:@"-"]; + } + } + } + while ([out hasSuffix:@"-"]) { + [out deleteCharactersInRange:NSMakeRange([out length] - 1, 1)]; + } + return [out length] == 0 ? @"cn1-nearby" : out; +} + +static NSString *cn1nbIdForPeer(MCPeerID *peer) { + // MCPeerID has no stable identifier of its own and two peers may share a + // display name, so the id an app sees is the pointer-derived hash paired + // with the name. It is meaningless past the end of this discovery + // session, which is exactly what Endpoint.getId() documents. + return [NSString stringWithFormat:@"%lu-%@", (unsigned long)[peer hash], + cn1nbSanitize(peer.displayName)]; +} + +@implementation CN1NearbyTransport + +- (void)dealloc { + [_localPeer release]; + [_session release]; + [_advertiser release]; + [_browser release]; + [_peersById release]; + [_invitations release]; + [_progressByPayload release]; + [_serviceType release]; + [super dealloc]; +} + +- (NSString *)encodePeer:(MCPeerID *)peer { + NSString *pid = cn1nbIdForPeer(peer); + [self.peersById setObject:peer forKey:pid]; + return cn1nbJoin([NSArray arrayWithObjects:pid, + peer.displayName == nil ? @"" : peer.displayName, + self.serviceType == nil ? @"" : self.serviceType, nil]); +} + +- (MCPeerID *)peerForId:(NSString *)pid { + return pid == nil ? nil : [self.peersById objectForKey:pid]; +} + +/// The short string both devices show the user before accepting. +/// +/// MultipeerConnectivity produces nothing like Nearby Connections' +/// authentication token, so this is computed from the two display names and +/// the service, sorted so both ends compute the same value. It is a +/// comparison aid rather than a secret, which is all the Android one is too. +- (NSString *)tokenForPeer:(MCPeerID *)peer { + NSArray *names = [[NSArray arrayWithObjects: + self.localPeer.displayName == nil ? @"" : self.localPeer.displayName, + peer.displayName == nil ? @"" : peer.displayName, nil] + sortedArrayUsingSelector:@selector(compare:)]; + NSString *material = [NSString stringWithFormat:@"%@|%@|%@", + [names objectAtIndex:0], [names objectAtIndex:1], + self.serviceType == nil ? @"" : self.serviceType]; + unsigned long h = 5381; + const char *utf8 = [material UTF8String]; + for (NSUInteger i = 0; utf8 != NULL && utf8[i] != 0; i++) { + h = ((h << 5) + h) + (unsigned char)utf8[i]; + } + return [NSString stringWithFormat:@"%04lu", h % 10000]; +} + +// ---- MCSessionDelegate ---------------------------------------------- + +- (void)session:(MCSession *)session peer:(MCPeerID *)peerID + didChangeState:(MCSessionState)state { + @autoreleasepool { + if (state == MCSessionStateConnecting) { + return; + } + NSString *encoded = [self encodePeer:peerID]; + if (state == MCSessionStateConnected) { + com_codename1_impl_ios_IOSNearbyCallbacks_connectionResult___java_lang_String_boolean_int_java_lang_String( + getThreadLocalData(), cn1nbJString(encoded), JAVA_TRUE, 0, + JAVA_NULL); + } else { + com_codename1_impl_ios_IOSNearbyCallbacks_disconnected___java_lang_String( + getThreadLocalData(), cn1nbJString(encoded)); + } + } +} + +- (void)session:(MCSession *)session didReceiveData:(NSData *)data + fromPeer:(MCPeerID *)peerID { + @autoreleasepool { + NSString *encoded = [self encodePeer:peerID]; + com_codename1_impl_ios_IOSNearbyCallbacks_payloadReceived___java_lang_String_int_int_byte_1ARRAY_java_lang_String( + getThreadLocalData(), cn1nbJString(encoded), 0, + CN1_NEARBY_PAYLOAD_BYTES, cn1nbJBytes(data), JAVA_NULL); + } +} + +- (void)session:(MCSession *)session + didStartReceivingResourceWithName:(NSString *)resourceName + fromPeer:(MCPeerID *)peerID withProgress:(NSProgress *)progress { + // Progress is reported on completion; a KVO observer per transfer would + // buy finer granularity at the cost of an observer lifetime to get wrong. +} + +- (void)session:(MCSession *)session + didFinishReceivingResourceWithName:(NSString *)resourceName + fromPeer:(MCPeerID *)peerID atURL:(NSURL *)localURL + withError:(NSError *)error { + @autoreleasepool { + NSString *encoded = [self encodePeer:peerID]; + if (error != nil || localURL == nil) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), 0, 0, -1, + CN1_NEARBY_PAYLOAD_FAILURE); + return; + } + // The URL the framework hands over is in a temporary location it will + // delete, so the file is moved somewhere the app can still read when + // the callback returns. + NSString *docs = [NSSearchPathForDirectoriesInDomains( + NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]; + NSString *target = [docs stringByAppendingPathComponent: + [NSString stringWithFormat:@"cn1nearby-%@", resourceName]]; + [[NSFileManager defaultManager] removeItemAtPath:target error:nil]; + NSError *moveError = nil; + [[NSFileManager defaultManager] moveItemAtPath:[localURL path] + toPath:target + error:&moveError]; + if (moveError != nil) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), 0, 0, -1, + CN1_NEARBY_PAYLOAD_FAILURE); + return; + } + com_codename1_impl_ios_IOSNearbyCallbacks_payloadReceived___java_lang_String_int_int_byte_1ARRAY_java_lang_String( + getThreadLocalData(), cn1nbJString(encoded), 0, + CN1_NEARBY_PAYLOAD_FILE, JAVA_NULL, + cn1nbJString([@"file://" stringByAppendingString:target])); + } +} + +- (void)session:(MCSession *)session + didReceiveStream:(NSInputStream *)stream withName:(NSString *)streamName + fromPeer:(MCPeerID *)peerID { + // The portable API has no stream payload, so nothing here consumes one. +} + +// ---- MCNearbyServiceAdvertiserDelegate ------------------------------- + +- (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser + didReceiveInvitationFromPeer:(MCPeerID *)peerID + withContext:(NSData *)context + invitationHandler:(void (^)(BOOL, MCSession *))invitationHandler { + @autoreleasepool { + NSString *pid = cn1nbIdForPeer(peerID); + NSString *encoded = [self encodePeer:peerID]; + // Copied because the block outlives this call: it is answered when + // the app calls accept or reject, which is at least an EDT hop away. + [self.invitations setObject:[[invitationHandler copy] autorelease] + forKey:pid]; + com_codename1_impl_ios_IOSNearbyCallbacks_connectionRequested___java_lang_String_java_lang_String( + getThreadLocalData(), cn1nbJString(encoded), + cn1nbJString([self tokenForPeer:peerID])); + } +} + +- (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser + didNotStartAdvertisingPeer:(NSError *)error { + // Reported through the request that asked, which has already been + // answered by the time this can fire; nothing useful is left to tell. +} + +// ---- MCNearbyServiceBrowserDelegate ---------------------------------- + +- (void)browser:(MCNearbyServiceBrowser *)browser + foundPeer:(MCPeerID *)peerID + withDiscoveryInfo:(NSDictionary *)info { + @autoreleasepool { + NSString *encoded = [self encodePeer:peerID]; + com_codename1_impl_ios_IOSNearbyCallbacks_endpointFound___java_lang_String_boolean( + getThreadLocalData(), cn1nbJString(encoded), JAVA_TRUE); + } +} + +- (void)browser:(MCNearbyServiceBrowser *)browser + lostPeer:(MCPeerID *)peerID { + @autoreleasepool { + NSString *encoded = [self encodePeer:peerID]; + com_codename1_impl_ios_IOSNearbyCallbacks_endpointFound___java_lang_String_boolean( + getThreadLocalData(), cn1nbJString(encoded), JAVA_FALSE); + } +} + +- (void)browser:(MCNearbyServiceBrowser *)browser + didNotStartBrowsingForPeers:(NSError *)error { +} + +@end + +static CN1NearbyTransport *cn1nbTransportInit(NSString *serviceId, + NSString *localName) { + if (cn1nbTransport == nil) { + cn1nbTransport = [[CN1NearbyTransport alloc] init]; + cn1nbTransport.peersById = [NSMutableDictionary dictionary]; + cn1nbTransport.invitations = [NSMutableDictionary dictionary]; + cn1nbTransport.progressByPayload = [NSMutableDictionary dictionary]; + } + if (cn1nbTransport.serviceType == nil && serviceId != nil) { + cn1nbTransport.serviceType = cn1nbServiceType(serviceId); + } + if (cn1nbTransport.localPeer == nil) { + NSString *name = localName == nil || [localName length] == 0 + ? [[UIDevice currentDevice] name] : localName; + // MCPeerID rejects a display name longer than 63 UTF-8 bytes. + if ([name lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) { + name = [name substringToIndex:20]; + } + cn1nbTransport.localPeer = + [[[MCPeerID alloc] initWithDisplayName:name] autorelease]; + } + if (cn1nbTransport.session == nil) { + cn1nbTransport.session = [[[MCSession alloc] + initWithPeer:cn1nbTransport.localPeer + securityIdentity:nil + encryptionPreference:MCEncryptionRequired] autorelease]; + cn1nbTransport.session.delegate = cn1nbTransport; + } + return cn1nbTransport; +} + +#endif // CN1_NEARBY_HAS_MPC + +// ===================================================================== +// Companion association -- AccessorySetupKit +// ===================================================================== + +#ifdef CN1_NEARBY_HAS_ASK + +API_AVAILABLE(ios(18.0)) +@interface CN1NearbyCompanion : NSObject +@property (nonatomic, retain) ASAccessorySession *session; +@property (nonatomic, assign) BOOL activated; +@end + +// Typed as id rather than CN1NearbyCompanion *: a file-scope variable of an +// API_AVAILABLE(ios(18.0)) type is itself flagged as unguarded, and there is +// no availability annotation for a variable declaration to carry. +static id cn1nbCompanion = nil; + +@implementation CN1NearbyCompanion + +- (void)dealloc { + [_session release]; + [super dealloc]; +} + +/// Encodes an accessory the way NearbyWire.decodeCompanionDevice expects. +/// +/// The address field carries the per-app CoreBluetooth identifier rather than +/// a MAC address, because that is the only handle iOS gives out -- and it is +/// the same one `BluetoothLE.getPeripheral(String)` takes, which is what makes +/// an association useful rather than decorative. +- (NSString *)encode:(ASAccessory *)accessory present:(BOOL)present { + NSString *identifier = accessory.bluetoothIdentifier != nil + ? [accessory.bluetoothIdentifier UUIDString] : @""; + return cn1nbJoin([NSArray arrayWithObjects: + identifier.length > 0 ? identifier + : [NSString stringWithFormat:@"%lu", + (unsigned long)[accessory hash]], + accessory.displayName == nil ? @"" : accessory.displayName, + identifier, + @"0", + present ? @"1" : @"0", + nil]); +} + +- (ASAccessory *)accessoryForId:(NSString *)associationId { + if (self.session == nil || associationId == nil) { + return nil; + } + for (ASAccessory *a in self.session.accessories) { + if (a.bluetoothIdentifier != nil + && [[a.bluetoothIdentifier UUIDString] + isEqualToString:associationId]) { + return a; + } + } + return nil; +} + +- (void)handleEvent:(ASAccessoryEvent *)event { + @autoreleasepool { + if (event.accessory == nil) { + return; + } + BOOL added = event.eventType == ASAccessoryEventTypeAccessoryAdded; + BOOL removed = event.eventType == ASAccessoryEventTypeAccessoryRemoved; + if (!added && !removed) { + return; + } + com_codename1_impl_ios_IOSNearbyCallbacks_presenceChanged___java_lang_String_boolean( + getThreadLocalData(), + cn1nbJString([self encode:event.accessory present:added]), + added ? JAVA_TRUE : JAVA_FALSE); + } +} + +- (void)activate { + if (self.activated) { + return; + } + self.activated = YES; + self.session = [[[ASAccessorySession alloc] init] autorelease]; + CN1NearbyCompanion *weakSelf = self; + [self.session activateWithQueue:dispatch_get_main_queue() + eventHandler:^(ASAccessoryEvent *event) { + [weakSelf handleEvent:event]; + }]; +} + +@end + +static CN1NearbyCompanion *cn1nbCompanionInit(void) API_AVAILABLE(ios(18.0)) { + if (cn1nbCompanion == nil) { + cn1nbCompanion = [[CN1NearbyCompanion alloc] init]; + } + CN1NearbyCompanion *companion = (CN1NearbyCompanion *)cn1nbCompanion; + [companion activate]; + return companion; +} + +#endif // CN1_NEARBY_HAS_ASK + +// ===================================================================== +// Natives +// ===================================================================== + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyRangingSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_NI + if (@available(iOS 16.0, *)) { + return NISession.deviceCapabilities.supportsPreciseDistanceMeasurement + ? JAVA_TRUE : JAVA_FALSE; + } + if (@available(iOS 14.0, *)) { + return NISession.isSupported ? JAVA_TRUE : JAVA_FALSE; + } +#endif + return JAVA_FALSE; +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyCompanionSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_ASK + if (@available(iOS 18.0, *)) { + return JAVA_TRUE; + } +#endif + return JAVA_FALSE; +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyTransportSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_MPC + return JAVA_TRUE; +#else + return JAVA_FALSE; +#endif +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyRangingAvailability___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_NI + if (@available(iOS 14.0, *)) { + JAVA_BOOLEAN supported = + com_codename1_impl_ios_IOSNative_nearbyRangingSupported___R_boolean( + CN1_THREAD_STATE_PASS_ARG me); + return supported == JAVA_TRUE ? CN1_NEARBY_AVAIL_AVAILABLE + : CN1_NEARBY_AVAIL_NOT_SUPPORTED; + } +#endif + return CN1_NEARBY_AVAIL_NOT_SUPPORTED; +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyCompanionAvailability___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_ASK + if (@available(iOS 18.0, *)) { + return CN1_NEARBY_AVAIL_AVAILABLE; + } +#endif + return CN1_NEARBY_AVAIL_NOT_SUPPORTED; +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyTransportAvailability___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_MPC + return CN1_NEARBY_AVAIL_AVAILABLE; +#else + return CN1_NEARBY_AVAIL_NOT_SUPPORTED; +#endif +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyRangingCapabilities___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + JAVA_INT bits = 0; +#ifdef CN1_NEARBY_HAS_NI + if (@available(iOS 16.0, *)) { + id caps = NISession.deviceCapabilities; + if (caps.supportsPreciseDistanceMeasurement) { + bits |= CN1_NEARBY_CAP_DISTANCE; + } + if (caps.supportsDirectionMeasurement) { + // Apple reports one direction capability and produces a full + // vector, so azimuth and elevation stand or fall together here. + bits |= CN1_NEARBY_CAP_DIRECTION | CN1_NEARBY_CAP_ELEVATION; + } + if (caps.supportsCameraAssistance) { + bits |= CN1_NEARBY_CAP_CAMERA_ASSISTANCE; + } + if (bits != 0) { + bits |= CN1_NEARBY_CAP_ACCESSORY; + } + } else if (@available(iOS 14.0, *)) { + if (NISession.isSupported) { + bits = CN1_NEARBY_CAP_DISTANCE | CN1_NEARBY_CAP_DIRECTION + | CN1_NEARBY_CAP_ELEVATION; + if (@available(iOS 15.0, *)) { + bits |= CN1_NEARBY_CAP_ACCESSORY; + } + } + } + // CAP_BACKGROUND is deliberately never set. Background ranging needs the + // com.apple.developer.nearby-interaction entitlement, which the builder + // never injects on its own because it has to be enabled on the App ID + // first -- so claiming it here would be a promise the binary usually + // cannot keep. +#endif + return bits; +} + +void com_codename1_impl_ios_IOSNative_nearbyRequestPermissions___int_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT permissionBits) { + // iOS has nothing to ask for up front: Nearby Interaction prompts on the + // first session and the local network prompt appears on the first browse. + // The answer still has to arrive rather than not, because a caller is + // holding a resource. + com_codename1_impl_ios_IOSNearbyCallbacks_permissionResult___int_boolean( + CN1_THREAD_STATE_PASS_ARG requestId, JAVA_TRUE); +} + +void com_codename1_impl_ios_IOSNative_nearbyPrepareSession___int_int_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT sessionHandle, JAVA_BOOLEAN controller) { +#ifdef CN1_NEARBY_HAS_NI + if (@available(iOS 14.0, *)) { + @autoreleasepool { + if (!NISession.isSupported) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this device has no ultra-wideband radio"); + return; + } + cn1nbSessionsInit(); + CN1NearbyRangingSession *entry = + [[[CN1NearbyRangingSession alloc] init] autorelease]; + entry.handle = sessionHandle; + entry.session = [[[NISession alloc] init] autorelease]; + entry.session.delegate = entry; + NIDiscoveryToken *token = entry.session.discoveryToken; + if (token == nil) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_SESSION_FAILED, + @"the session produced no discovery token"); + return; + } + NSError *err = nil; + NSData *archived = + [NSKeyedArchiver archivedDataWithRootObject:token + requiringSecureCoding:YES + error:&err]; + if (archived == nil) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_SESSION_FAILED, + [err localizedDescription]); + return; + } + [cn1nbSessions setObject:entry + forKey:[NSNumber numberWithInt:sessionHandle]]; + com_codename1_impl_ios_IOSNearbyCallbacks_sessionPrepared___int_int_boolean_byte_1ARRAY( + CN1_THREAD_STATE_PASS_ARG requestId, sessionHandle, + controller, cn1nbJBytes(archived)); + return; + } + } +#endif + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this build does not include precision ranging"); +} + +void com_codename1_impl_ios_IOSNative_nearbyStartRanging___int_int_byte_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT sessionHandle, JAVA_OBJECT peerToken) { +#ifdef CN1_NEARBY_HAS_NI + if (@available(iOS 14.0, *)) { + @autoreleasepool { + CN1NearbyRangingSession *entry = cn1nbSessionFor(sessionHandle); + if (entry == nil) { + cn1nbFailRanging(requestId, + CN1_NEARBY_ERR_SESSION_INVALIDATED, @"no such session"); + return; + } + NSData *raw = cn1nbDataFromJavaArray(peerToken); + // The framing NearbyWire puts around a token: magic, version, + // platform, length. Stripping it here rather than in Java keeps + // the native interface to plain bytes. + if (raw == nil || [raw length] < 10) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_INVALID_TOKEN, + @"the peer token is not a Codename One token"); + return; + } + const unsigned char *b = (const unsigned char *)[raw bytes]; + if (b[0] != 'C' || b[1] != 'N' || b[2] != '1' || b[3] != 'R' + || b[5] != 1) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_INVALID_TOKEN, + @"this token was minted by another platform"); + return; + } + NSData *payload = [raw subdataWithRange: + NSMakeRange(10, [raw length] - 10)]; + NSError *err = nil; + NIDiscoveryToken *token = + [NSKeyedUnarchiver unarchivedObjectOfClass: + [NIDiscoveryToken class] fromData:payload + error:&err]; + if (token == nil) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_INVALID_TOKEN, + [err localizedDescription]); + return; + } + NINearbyPeerConfiguration *config = + [[[NINearbyPeerConfiguration alloc] + initWithPeerToken:token] autorelease]; + entry.pendingStartRequest = 0; + [entry.session runWithConfiguration:config]; + com_codename1_impl_ios_IOSNearbyCallbacks_sessionStarted___int_int( + CN1_THREAD_STATE_PASS_ARG requestId, sessionHandle); + return; + } + } +#endif + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this build does not include precision ranging"); +} + +void com_codename1_impl_ios_IOSNative_nearbyStartAccessoryRanging___int_int_byte_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT sessionHandle, JAVA_OBJECT accessoryData) { +#ifdef CN1_NEARBY_HAS_NI + if (@available(iOS 15.0, *)) { + @autoreleasepool { + CN1NearbyRangingSession *entry = cn1nbSessionFor(sessionHandle); + if (entry == nil) { + cn1nbFailRanging(requestId, + CN1_NEARBY_ERR_SESSION_INVALIDATED, @"no such session"); + return; + } + NSData *data = cn1nbDataFromJavaArray(accessoryData); + if (data == nil || [data length] == 0) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_INVALID_TOKEN, + @"accessory configuration data is required"); + return; + } + NSError *err = nil; + NINearbyAccessoryConfiguration *config = + [[[NINearbyAccessoryConfiguration alloc] + initWithData:data error:&err] autorelease]; + if (config == nil) { + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_INVALID_TOKEN, + [err localizedDescription]); + return; + } + // Answered from the delegate, not here: the accessory protocol + // needs the shareable configuration data the session generates, + // and that arrives asynchronously. + entry.pendingStartRequest = requestId; + [entry.session runWithConfiguration:config]; + return; + } + } +#endif + cn1nbFailRanging(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"accessory ranging needs iOS 15 or later"); +} + +void com_codename1_impl_ios_IOSNative_nearbyStopSession___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT sessionHandle) { +#ifdef CN1_NEARBY_HAS_NI + if (@available(iOS 14.0, *)) { + @autoreleasepool { + CN1NearbyRangingSession *entry = cn1nbSessionFor(sessionHandle); + if (entry != nil) { + // Cleared before invalidate so the delegate callback that + // invalidation triggers finds nothing left to report -- the + // app asked for this and does not need to be told. + [cn1nbSessions removeObjectForKey: + [NSNumber numberWithInt:sessionHandle]]; + entry.session.delegate = nil; + [entry.session invalidate]; + } + } + } +#endif +} + +// ---- Companion ------------------------------------------------------ + +void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT profile, JAVA_BOOLEAN singleDevice, + JAVA_OBJECT joinedFilters) { +#ifdef CN1_NEARBY_HAS_ASK + if (@available(iOS 18.0, *)) { + @autoreleasepool { + NSString *joined = toNSString(CN1_THREAD_STATE_PASS_ARG + joinedFilters); + NSMutableArray *items = [NSMutableArray array]; + for (NSString *line in cn1nbSplitLines(joined)) { + NSArray *fields = [line componentsSeparatedByString:@"\t"]; + if ([fields count] < 2) { + continue; + } + int kind = [[fields objectAtIndex:0] intValue]; + NSString *value = [fields objectAtIndex:1]; + ASDiscoveryDescriptor *descriptor = + [[[ASDiscoveryDescriptor alloc] init] autorelease]; + descriptor.supportedOptions = + ASAccessorySupportBluetoothPairingLE; + if (kind == CN1_NEARBY_FILTER_BLE_SERVICE) { + @try { + descriptor.bluetoothServiceUUID = + [CBUUID UUIDWithString:value]; + } @catch (NSException *bad) { + // CBUUID raises on a malformed UUID rather than + // returning nil, and one bad filter must not take the + // whole picker down. + continue; + } + } else if (kind == CN1_NEARBY_FILTER_NAME_PATTERN) { + // A substring, not a regular expression: this is the + // weakest of the three backends and the portable + // documentation says so. + descriptor.bluetoothNameSubstring = value; + } else if (kind == CN1_NEARBY_FILTER_WIFI_SSID) { + descriptor.SSID = value; + } else { + // KIND_ADDRESS. AccessorySetupKit discovers accessories; + // it has no way to be pointed at one identifier, and + // widening the picker to everything would be worse than + // skipping the filter. + continue; + } + ASPickerDisplayItem *item = [[[ASPickerDisplayItem alloc] + initWithName:value + productImage:[[[UIImage alloc] init] autorelease] + descriptor:descriptor] autorelease]; + [items addObject:item]; + } + if ([items count] == 0) { + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"AccessorySetupKit needs at least one Bluetooth" + @" service, name or SSID filter to show a picker"); + return; + } + CN1NearbyCompanion *companion = cn1nbCompanionInit(); + [companion.session showPickerForDisplayItems:items + completionHandler:^(NSError *error) { + @autoreleasepool { + if (error != nil) { + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_USER_CANCELED, + [error localizedDescription]); + return; + } + ASAccessory *picked = + [companion.session.accessories lastObject]; + if (picked == nil) { + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_USER_CANCELED, + @"the picker returned no accessory"); + return; + } + com_codename1_impl_ios_IOSNearbyCallbacks_associated___int_java_lang_String( + getThreadLocalData(), requestId, + cn1nbJString([companion encode:picked + present:YES])); + } + }]; + return; + } + } +#endif + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"companion association needs iOS 18 or later"); +} + +JAVA_OBJECT +com_codename1_impl_ios_IOSNative_nearbyAssociations___R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_ASK + if (@available(iOS 18.0, *)) { + @autoreleasepool { + CN1NearbyCompanion *companion = cn1nbCompanionInit(); + NSMutableArray *lines = [NSMutableArray array]; + for (ASAccessory *a in companion.session.accessories) { + [lines addObject:[companion encode:a present:NO]]; + } + return cn1nbJString([lines componentsJoinedByString:@"\n"]); + } + } +#endif + return cn1nbJString(@""); +} + +void com_codename1_impl_ios_IOSNative_nearbyDisassociate___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT associationId) { +#ifdef CN1_NEARBY_HAS_ASK + if (@available(iOS 18.0, *)) { + @autoreleasepool { + CN1NearbyCompanion *companion = cn1nbCompanionInit(); + NSString *aid = toNSString(CN1_THREAD_STATE_PASS_ARG associationId); + ASAccessory *accessory = [companion accessoryForId:aid]; + if (accessory == nil) { + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"no such association"); + return; + } + [companion.session removeAccessory:accessory + completionHandler:^(NSError *error) { + @autoreleasepool { + if (error != nil) { + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_UNKNOWN, + [error localizedDescription]); + } else { + com_codename1_impl_ios_IOSNearbyCallbacks_disassociated___int( + getThreadLocalData(), requestId); + } + } + }]; + return; + } + } +#endif + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"companion association needs iOS 18 or later"); +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyStartObservingPresence___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT associationId) { + // False on purpose, and documented as such in the guide's capability + // matrix. AccessorySetupKit reports an accessory being added to or removed + // from the app's set, which is a different event from it coming into + // range: an accessory sitting in a drawer stays "added". Reporting those + // as presence would tell an app the device is nearby when it is not, and + // an app that believed it would show a live reading for something it + // cannot reach. Android has real presence; on iOS the honest answer is + // that the app should scan with com.codename1.bluetooth instead. + return JAVA_FALSE; +} + +void com_codename1_impl_ios_IOSNative_nearbyStopObservingPresence___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT associationId) { +} + +// ---- Transport ------------------------------------------------------ + +JAVA_INT com_codename1_impl_ios_IOSNative_nearbyMaxPayloadSize___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_MPC + // MultipeerConnectivity has no published limit for sendData, but it + // degrades badly past a few tens of kilobytes and the portable API + // promises one number an app can rely on everywhere. Matching the tighter + // of the two real backends means a payload that fits here fits on Android. + return 32 * 1024; +#else + return 0; +#endif +} + +void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_String_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT serviceId, JAVA_OBJECT localName, JAVA_INT strategy) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + NSString *sid = toNSString(CN1_THREAD_STATE_PASS_ARG serviceId); + NSString *name = toNSString(CN1_THREAD_STATE_PASS_ARG localName); + CN1NearbyTransport *t = cn1nbTransportInit(sid, name); + if (t.advertiser != nil) { + [t.advertiser stopAdvertisingPeer]; + t.advertiser = nil; + } + t.advertiser = [[[MCNearbyServiceAdvertiser alloc] + initWithPeer:t.localPeer + discoveryInfo:nil + serviceType:t.serviceType] autorelease]; + t.advertiser.delegate = t; + [t.advertiser startAdvertisingPeer]; + cn1nbTransportOk(requestId); + return; + } +#endif + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this build does not include the nearby transport"); +} + +void com_codename1_impl_ios_IOSNative_nearbyStopAdvertising__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport != nil && cn1nbTransport.advertiser != nil) { + [cn1nbTransport.advertiser stopAdvertisingPeer]; + cn1nbTransport.advertiser.delegate = nil; + cn1nbTransport.advertiser = nil; + } + } +#endif +} + +void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT serviceId, JAVA_INT strategy) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + NSString *sid = toNSString(CN1_THREAD_STATE_PASS_ARG serviceId); + CN1NearbyTransport *t = cn1nbTransportInit(sid, nil); + if (t.browser != nil) { + [t.browser stopBrowsingForPeers]; + t.browser = nil; + } + t.browser = [[[MCNearbyServiceBrowser alloc] + initWithPeer:t.localPeer + serviceType:t.serviceType] autorelease]; + t.browser.delegate = t; + [t.browser startBrowsingForPeers]; + cn1nbTransportOk(requestId); + return; + } +#endif + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this build does not include the nearby transport"); +} + +void com_codename1_impl_ios_IOSNative_nearbyStopDiscovery__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport != nil && cn1nbTransport.browser != nil) { + [cn1nbTransport.browser stopBrowsingForPeers]; + cn1nbTransport.browser.delegate = nil; + cn1nbTransport.browser = nil; + } + } +#endif +} + +void com_codename1_impl_ios_IOSNative_nearbyRequestConnection___int_java_lang_String_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT endpointId, JAVA_OBJECT localName) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport == nil || cn1nbTransport.browser == nil) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, + @"start discovery before requesting a connection"); + return; + } + NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); + MCPeerID *peer = [cn1nbTransport peerForId:pid]; + if (peer == nil) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"no such endpoint"); + return; + } + [cn1nbTransport.browser invitePeer:peer + toSession:cn1nbTransport.session + withContext:nil + timeout:30]; + cn1nbTransportOk(requestId); + return; + } +#endif + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this build does not include the nearby transport"); +} + +void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT endpointId) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); + void (^handler)(BOOL, MCSession *) = + cn1nbTransport == nil ? nil + : [cn1nbTransport.invitations objectForKey:pid]; + if (handler == nil) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"there is no invitation from that endpoint"); + return; + } + [cn1nbTransport.invitations removeObjectForKey:pid]; + handler(YES, cn1nbTransport.session); + cn1nbTransportOk(requestId); + return; + } +#endif + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this build does not include the nearby transport"); +} + +void com_codename1_impl_ios_IOSNative_nearbyRejectConnection___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT endpointId) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport == nil) { + return; + } + NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); + void (^handler)(BOOL, MCSession *) = + [cn1nbTransport.invitations objectForKey:pid]; + if (handler != nil) { + [cn1nbTransport.invitations removeObjectForKey:pid]; + handler(NO, nil); + } + } +#endif +} + +void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_int_int_byte_1ARRAY_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT joinedEndpointIds, JAVA_INT payloadId, + JAVA_INT payloadType, JAVA_OBJECT bytes, JAVA_OBJECT path) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport == nil || cn1nbTransport.session == nil) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, + @"the transport is not running"); + return; + } + NSString *joined = toNSString(CN1_THREAD_STATE_PASS_ARG + joinedEndpointIds); + NSMutableArray *peers = [NSMutableArray array]; + for (NSString *pid in cn1nbSplitLines(joined)) { + MCPeerID *peer = [cn1nbTransport peerForId:pid]; + if (peer != nil) { + [peers addObject:peer]; + } + } + if ([peers count] == 0) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"none of those endpoints is connected"); + return; + } + if (payloadType == CN1_NEARBY_PAYLOAD_FILE) { + NSString *p = toNSString(CN1_THREAD_STATE_PASS_ARG path); + if ([p hasPrefix:@"file://"]) { + p = [p substringFromIndex:7]; + } + NSURL *url = [NSURL fileURLWithPath:p]; + for (MCPeerID *peer in peers) { + [cn1nbTransport.session sendResourceAtURL:url + withName:[p lastPathComponent] + toPeer:peer + withCompletionHandler:^(NSError *error) { + @autoreleasepool { + NSString *encoded = [cn1nbTransport encodePeer:peer]; + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), + payloadId, 0, -1, + error == nil ? CN1_NEARBY_PAYLOAD_SUCCESS + : CN1_NEARBY_PAYLOAD_FAILURE); + } + }]; + } + cn1nbTransportOk(requestId); + return; + } + NSData *data = cn1nbDataFromJavaArray(bytes); + NSError *err = nil; + BOOL sent = [cn1nbTransport.session sendData:data == nil + ? [NSData data] : data + toPeers:peers + withMode:MCSessionSendDataReliable + error:&err]; + if (!sent) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, + [err localizedDescription]); + return; + } + cn1nbTransportOk(requestId); + for (MCPeerID *peer in peers) { + NSString *encoded = [cn1nbTransport encodePeer:peer]; + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, + (JAVA_LONG)[data length], (JAVA_LONG)[data length], + CN1_NEARBY_PAYLOAD_SUCCESS); + } + return; + } +#endif + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"this build does not include the nearby transport"); +} + +void com_codename1_impl_ios_IOSNative_nearbyCancelPayload___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT payloadId) { + // MultipeerConnectivity offers no cancellation for sendData, and the + // NSProgress a resource transfer returns is not retained here. Silently + // doing nothing is the same outcome an app gets from cancelling a + // byte payload that has already left, which is the common case. +} + +void com_codename1_impl_ios_IOSNative_nearbyDisconnect___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT endpointId) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport == nil || cn1nbTransport.session == nil) { + return; + } + // MCSession disconnects as a whole rather than per peer, so a + // one-peer session is the only case this can honour precisely. The + // delegate reports the drop either way, so the app is told the truth. + NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); + MCPeerID *peer = [cn1nbTransport peerForId:pid]; + if (peer != nil + && [cn1nbTransport.session.connectedPeers count] <= 1) { + [cn1nbTransport.session disconnect]; + } + } +#endif +} + +void com_codename1_impl_ios_IOSNative_nearbyStopAllTransport__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport == nil) { + return; + } + if (cn1nbTransport.advertiser != nil) { + [cn1nbTransport.advertiser stopAdvertisingPeer]; + cn1nbTransport.advertiser.delegate = nil; + cn1nbTransport.advertiser = nil; + } + if (cn1nbTransport.browser != nil) { + [cn1nbTransport.browser stopBrowsingForPeers]; + cn1nbTransport.browser.delegate = nil; + cn1nbTransport.browser = nil; + } + [cn1nbTransport.session disconnect]; + [cn1nbTransport.invitations removeAllObjects]; + } +#endif +} + +#else // CN1_INCLUDE_NEARBY + +// --------------------------------------------------------------------- +// Trampolines for a build that never touched com.codename1.nearby +// +// Every native declared in IOSNative.java has to resolve or the app will not +// link, and each one answers "unsupported" so the public API reports +// NOT_SUPPORTED and every operation fails fast. Nothing here imports Nearby +// Interaction, MultipeerConnectivity or AccessorySetupKit, so an app that +// never asks how far away anything is carries none of their symbols and owes +// none of their privacy strings. +// --------------------------------------------------------------------- + +#include "com_codename1_impl_ios_IOSNearbyCallbacks.h" + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyRangingSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyCompanionSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyTransportSupported___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyRangingAvailability___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return CN1_NEARBY_AVAIL_NOT_SUPPORTED; +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyCompanionAvailability___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return CN1_NEARBY_AVAIL_NOT_SUPPORTED; +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyTransportAvailability___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return CN1_NEARBY_AVAIL_NOT_SUPPORTED; +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_nearbyRangingCapabilities___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return 0; +} + +void com_codename1_impl_ios_IOSNative_nearbyRequestPermissions___int_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT permissionBits) { + com_codename1_impl_ios_IOSNearbyCallbacks_permissionResult___int_boolean( + CN1_THREAD_STATE_PASS_ARG requestId, JAVA_FALSE); +} + +void com_codename1_impl_ios_IOSNative_nearbyPrepareSession___int_int_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT sessionHandle, JAVA_BOOLEAN controller) { + com_codename1_impl_ios_IOSNearbyCallbacks_rangingFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyStartRanging___int_int_byte_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT sessionHandle, JAVA_OBJECT peerToken) { + com_codename1_impl_ios_IOSNearbyCallbacks_rangingFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyStartAccessoryRanging___int_int_byte_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT sessionHandle, JAVA_OBJECT accessoryData) { + com_codename1_impl_ios_IOSNearbyCallbacks_rangingFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyStopSession___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT sessionHandle) { +} + +void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_INT profile, JAVA_BOOLEAN singleDevice, + JAVA_OBJECT joinedFilters) { + com_codename1_impl_ios_IOSNearbyCallbacks_companionFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +JAVA_OBJECT +com_codename1_impl_ios_IOSNative_nearbyAssociations___R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} + +void com_codename1_impl_ios_IOSNative_nearbyDisassociate___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT associationId) { + com_codename1_impl_ios_IOSNearbyCallbacks_companionFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_nearbyStartObservingPresence___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT associationId) { + return JAVA_FALSE; +} + +void com_codename1_impl_ios_IOSNative_nearbyStopObservingPresence___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT associationId) { +} + +JAVA_INT com_codename1_impl_ios_IOSNative_nearbyMaxPayloadSize___R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return 0; +} + +void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_String_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT serviceId, JAVA_OBJECT localName, JAVA_INT strategy) { + com_codename1_impl_ios_IOSNearbyCallbacks_transportFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyStopAdvertising__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT serviceId, JAVA_INT strategy) { + com_codename1_impl_ios_IOSNearbyCallbacks_transportFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyStopDiscovery__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +void com_codename1_impl_ios_IOSNative_nearbyRequestConnection___int_java_lang_String_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT endpointId, JAVA_OBJECT localName) { + com_codename1_impl_ios_IOSNearbyCallbacks_transportFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT endpointId) { + com_codename1_impl_ios_IOSNearbyCallbacks_transportFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyRejectConnection___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT endpointId) { +} + +void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_int_int_byte_1ARRAY_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT joinedEndpointIds, JAVA_INT payloadId, + JAVA_INT payloadType, JAVA_OBJECT bytes, JAVA_OBJECT path) { + com_codename1_impl_ios_IOSNearbyCallbacks_transportFailed___int_int_java_lang_String( + CN1_THREAD_STATE_PASS_ARG requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_nearbyCancelPayload___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT payloadId) { +} + +void com_codename1_impl_ios_IOSNative_nearbyDisconnect___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT endpointId) { +} + +void com_codename1_impl_ios_IOSNative_nearbyStopAllTransport__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +#endif // CN1_INCLUDE_NEARBY diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 7a2c7f6df68..cfe088d48fa 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -275,6 +275,36 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); // ID and an app carrying it without cause fails codesigning for no reason. //#define CN1_INCLUDE_HOMEKIT +// CN1_INCLUDE_NEARBY gates the com.codename1.nearby native bridge +// (CN1Nearby.{h,m}: Nearby Interaction ranging, MultipeerConnectivity +// transport and AccessorySetupKit association). IPhoneBuilder uncomments this +// only when the classpath scanner saw com.codename1.nearby.*, so an app that +// never asks how far away anything is ships without those symbols and without +// the privacy strings they oblige. +//#define CN1_INCLUDE_NEARBY + +// The three halves are gated separately because they are available on +// different slices, and because an app that references one package must not +// link the frameworks the other two need. IPhoneBuilder uncomments each from +// its own scanner flag. +//#define CN1_NEARBY_RANGING +//#define CN1_NEARBY_TRANSPORT +//#define CN1_NEARBY_COMPANION + +// NearbyInteraction does not exist on tvOS, on the watchOS slice or under Mac +// Catalyst, and neither does AccessorySetupKit. MultipeerConnectivity is +// absent on watchOS. Undoing the defines here, in the header every nearby +// translation unit includes first, compiles those halves out rather than +// leaving each function to guard itself -- and the public API then reports +// them unsupported, which is the answer an app on an Apple TV should get. +#if TARGET_OS_TV || TARGET_OS_WATCH || TARGET_OS_MACCATALYST || TARGET_OS_OSX +#undef CN1_NEARBY_RANGING +#undef CN1_NEARBY_COMPANION +#endif +#if TARGET_OS_WATCH +#undef CN1_NEARBY_TRANSPORT +#endif + // CN1_INCLUDE_MATTER_SETUP gates the MatterSupport add-device flow, which is // much more expensive than the rest: it needs its own app-extension target, // the com.apple.developer.matter.allow-setup-payload entitlement, an app group diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 4a14451df8f..231707fe9cd 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -386,6 +386,24 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { return homeBridge; } + private IOSNearbyBridge nearbyBridge; + + @Override + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + // Only meaningful in builds that linked the nearby natives + // (CN1_INCLUDE_NEARBY, flipped by the builder when the app references + // com.codename1.nearby). Always returned rather than conditionally null, for the same + // reason getHomeBridge() is: the bridge's own isRangingSupported() / isCompanionSupported() + // / isTransportSupported() answer honestly through the natives, which stub to unsupported + // when the defines are off -- so an app built without any of it reports NOT_SUPPORTED + // without this getter having to know how the app was built. The three answer separately, + // which is what lets a tvOS build report a working transport and no ranging. + if (nearbyBridge == null) { + nearbyBridge = IOSNearbyCallbacks.getBridge(nativeInstance); + } + return nearbyBridge; + } + private IOSWearableBridge wearableBridge; @Override diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index b79db860fe6..ed1e558c066 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1893,4 +1893,137 @@ native int aesGcm(int encrypt, byte[] key, byte[] iv, /// private-key DER length. Returns 0 on success, negative on error. native int generateRsaKeyPair(int bits, byte[] outPub, byte[] outPriv, int[] lengths); + + // --- Nearby devices (Nearby Interaction, MultipeerConnectivity, ---------- + // AccessorySetupKit) ------------------------------------------------ + // Backs com.codename1.nearby. Compiled only when the builder flipped + // CN1_INCLUDE_NEARBY, and each of the three halves only when its own + // define is on -- an app that references one package must not link the + // frameworks the other two need. + // + // Structured values cross as the tab-delimited records + // com.codename1.impl.nearby.NearbyWire defines, joined with newlines when + // there is more than one. IOSNearbyBridge does the splitting. A record + // field can never contain a newline: NearbyWire.sanitize replaces one with + // a space before it is ever encoded. + // + // Answers never come back through a return value; they arrive later on + // IOSNearbyCallbacks. + + /** True when this build linked Nearby Interaction and the device has the radio. */ + native boolean nearbyRangingSupported(); + + /** True when this build linked AccessorySetupKit and the OS is new enough. */ + native boolean nearbyCompanionSupported(); + + /** True when this build linked MultipeerConnectivity. */ + native boolean nearbyTransportSupported(); + + /** The com.codename1.nearby.NearbyAvailability ordinal for ranging. */ + native int nearbyRangingAvailability(); + + /** The com.codename1.nearby.NearbyAvailability ordinal for association. */ + native int nearbyCompanionAvailability(); + + /** The com.codename1.nearby.NearbyAvailability ordinal for the transport. */ + native int nearbyTransportAvailability(); + + /** An OR of the NearbyBridge.CAPABILITY_ bits this device can produce. */ + native int nearbyRangingCapabilities(); + + /** + * Requests the permissions behind the given NearbyBridge.PERMISSION_ bits. + * Answers via IOSNearbyCallbacks.permissionResult. + */ + native void nearbyRequestPermissions(int requestId, int permissionBits); + + /** + * Allocates an NISession and publishes its discovery token. Answers via + * IOSNearbyCallbacks.sessionPrepared. + */ + native void nearbyPrepareSession(int requestId, int sessionHandle, + boolean controller); + + /** + * Runs the session against a peer token. Answers via + * IOSNearbyCallbacks.sessionStarted. + */ + native void nearbyStartRanging(int requestId, int sessionHandle, + byte[] peerToken); + + /** + * Runs the session against an accessory's configuration data. Answers via + * IOSNearbyCallbacks.accessoryConfiguration with the bytes to send back. + */ + native void nearbyStartAccessoryRanging(int requestId, int sessionHandle, + byte[] accessoryData); + + /** Invalidates a session and releases the radio. Idempotent. */ + native void nearbyStopSession(int sessionHandle); + + /** + * Shows the AccessorySetupKit picker. Answers via + * IOSNearbyCallbacks.associated. + * + * @param joinedFilters the encoded filters, newline-joined, never null + */ + native void nearbyAssociate(int requestId, int profile, + boolean singleDevice, String joinedFilters); + + /** Every association this app holds, as newline-joined encoded records. */ + native String nearbyAssociations(); + + /** Drops an association. Answers via IOSNearbyCallbacks.disassociated. */ + native void nearbyDisassociate(int requestId, String associationId); + + /** Starts watching an association; true when the platform accepted. */ + native boolean nearbyStartObservingPresence(String associationId); + + /** Stops watching an association. Idempotent. */ + native void nearbyStopObservingPresence(String associationId); + + /** The largest byte payload MultipeerConnectivity accepts in one send. */ + native int nearbyMaxPayloadSize(); + + /** Starts advertising. Answers via IOSNearbyCallbacks.transportOk. */ + native void nearbyStartAdvertising(int requestId, String serviceId, + String localName, int strategy); + + /** Stops advertising. Idempotent. */ + native void nearbyStopAdvertising(); + + /** Starts browsing. Answers via IOSNearbyCallbacks.transportOk. */ + native void nearbyStartDiscovery(int requestId, String serviceId, + int strategy); + + /** Stops browsing. Idempotent. */ + native void nearbyStopDiscovery(); + + /** Invites a peer. Answers via IOSNearbyCallbacks.transportOk. */ + native void nearbyRequestConnection(int requestId, String endpointId, + String localName); + + /** Accepts an invitation. Answers via IOSNearbyCallbacks.transportOk. */ + native void nearbyAcceptConnection(int requestId, String endpointId); + + /** Declines an invitation. */ + native void nearbyRejectConnection(String endpointId); + + /** + * Sends a payload. Answers via IOSNearbyCallbacks.transportOk once the + * payload is handed to the session. + * + * @param joinedEndpointIds the recipients, newline-joined + */ + native void nearbySendPayload(int requestId, String joinedEndpointIds, + int payloadId, int payloadType, byte[] bytes, String path); + + /** Cancels an in-flight payload. Idempotent. */ + native void nearbyCancelPayload(int payloadId); + + /** Disconnects one peer. Idempotent. */ + native void nearbyDisconnect(String endpointId); + + /** Stops advertising and browsing and drops every session. */ + native void nearbyStopAllTransport(); } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyBridge.java new file mode 100644 index 00000000000..e531c284d7c --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyBridge.java @@ -0,0 +1,223 @@ +/* + * 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.impl.ios; + +import com.codename1.nearby.spi.NearbyBridge; + +/// Carries `com.codename1.nearby` onto Nearby Interaction, +/// MultipeerConnectivity and AccessorySetupKit. +/// +/// Thin on purpose: everything here is a forward to [IOSNative], and the +/// interesting work -- session lifetimes, delegate queues, the accessory +/// handshake -- lives in `CN1Nearby.m` where the frameworks are. The two +/// things this layer does own are joining string arrays into the single +/// argument the native side takes, and splitting the batches it returns. +/// +/// The three halves report independently, so an Apple TV build (no Nearby +/// Interaction, no AccessorySetupKit, but MultipeerConnectivity present) says +/// so honestly rather than reporting the whole feature missing. +class IOSNearbyBridge implements NearbyBridge { + + private final IOSNative nativeInstance; + + IOSNearbyBridge(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + // Initializes the callback class, and with it the dead-code guard + // that keeps the native call targets from being optimized away. + IOSNearbyCallbacks.keepAlive(); + } + + // ------------------------------------------------------------------ + // Shared + // ------------------------------------------------------------------ + + public boolean isRangingSupported() { + return nativeInstance.nearbyRangingSupported(); + } + + public boolean isCompanionSupported() { + return nativeInstance.nearbyCompanionSupported(); + } + + public boolean isTransportSupported() { + return nativeInstance.nearbyTransportSupported(); + } + + public int getRangingAvailability() { + return nativeInstance.nearbyRangingAvailability(); + } + + public int getCompanionAvailability() { + return nativeInstance.nearbyCompanionAvailability(); + } + + public int getTransportAvailability() { + return nativeInstance.nearbyTransportAvailability(); + } + + public void requestPermissions(int requestId, int permissionBits) { + nativeInstance.nearbyRequestPermissions(requestId, permissionBits); + } + + // ------------------------------------------------------------------ + // Ranging + // ------------------------------------------------------------------ + + public int getRangingCapabilities() { + return nativeInstance.nearbyRangingCapabilities(); + } + + public void prepareRangingSession(int requestId, int sessionHandle, + boolean controller) { + nativeInstance.nearbyPrepareSession(requestId, sessionHandle, + controller); + } + + public void startRanging(int requestId, int sessionHandle, + byte[] peerToken) { + nativeInstance.nearbyStartRanging(requestId, sessionHandle, peerToken); + } + + public void startAccessoryRanging(int requestId, int sessionHandle, + byte[] accessoryData) { + nativeInstance.nearbyStartAccessoryRanging(requestId, sessionHandle, + accessoryData); + } + + public void stopRangingSession(int sessionHandle) { + nativeInstance.nearbyStopSession(sessionHandle); + } + + // ------------------------------------------------------------------ + // Companion + // ------------------------------------------------------------------ + + public void associate(int requestId, int profile, boolean singleDevice, + String[] filters) { + nativeInstance.nearbyAssociate(requestId, profile, singleDevice, + join(filters)); + } + + public String[] getAssociations() { + return IOSNearbyCallbacks.split(nativeInstance.nearbyAssociations()); + } + + public void disassociate(int requestId, String associationId) { + nativeInstance.nearbyDisassociate(requestId, associationId); + } + + public boolean startObservingPresence(String associationId) { + return nativeInstance.nearbyStartObservingPresence(associationId); + } + + public void stopObservingPresence(String associationId) { + nativeInstance.nearbyStopObservingPresence(associationId); + } + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + public int getMaxPayloadSize() { + return nativeInstance.nearbyMaxPayloadSize(); + } + + public void startAdvertising(int requestId, String serviceId, + String localName, int strategy) { + nativeInstance.nearbyStartAdvertising(requestId, serviceId, localName, + strategy); + } + + public void stopAdvertising() { + nativeInstance.nearbyStopAdvertising(); + } + + public void startDiscovery(int requestId, String serviceId, int strategy) { + nativeInstance.nearbyStartDiscovery(requestId, serviceId, strategy); + } + + public void stopDiscovery() { + nativeInstance.nearbyStopDiscovery(); + } + + public void requestConnection(int requestId, String endpointId, + String localName) { + nativeInstance.nearbyRequestConnection(requestId, endpointId, + localName); + } + + public void acceptConnection(int requestId, String endpointId) { + nativeInstance.nearbyAcceptConnection(requestId, endpointId); + } + + public void rejectConnection(String endpointId) { + nativeInstance.nearbyRejectConnection(endpointId); + } + + public void sendPayload(int requestId, String[] endpointIds, int payloadId, + int payloadType, byte[] bytes, String path) { + nativeInstance.nearbySendPayload(requestId, join(endpointIds), + payloadId, payloadType, bytes, path); + } + + public void cancelPayload(int payloadId) { + nativeInstance.nearbyCancelPayload(payloadId); + } + + public void disconnect(String endpointId) { + nativeInstance.nearbyDisconnect(endpointId); + } + + public void stopAllTransport() { + nativeInstance.nearbyStopAllTransport(); + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + /// Joins records with newlines, which is safe because a record field can + /// never contain one -- `NearbyWire.sanitize` turns a newline into a space + /// before anything is encoded. + /// + /// #### Parameters + /// + /// - `values`: the records, may be null + /// + /// #### Returns + /// + /// the joined batch, never null + private static String join(String[] values) { + if (values == null || values.length == 0) { + return ""; + } + StringBuilder b = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + if (i > 0) { + b.append('\n'); + } + b.append(values[i] == null ? "" : values[i]); + } + return b.toString(); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java new file mode 100644 index 00000000000..0ac537b8826 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java @@ -0,0 +1,332 @@ +/* + * 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.impl.ios; + +import com.codename1.nearby.companion.CompanionDevices; +import com.codename1.nearby.ranging.Ranging; +import com.codename1.nearby.ranging.RangingSession; +import com.codename1.nearby.ranging.RangingToken; +import com.codename1.nearby.transport.NearbyTransport; +import com.codename1.util.StringUtil; + +import java.util.List; + +/// Static callback surface invoked from `CN1Nearby` when Nearby Interaction, +/// MultipeerConnectivity or AccessorySetupKit answer. +/// +/// Mirrors [IOSHomeCallbacks]: the static initializer calls each entry point +/// once, guarded so it has no effect, purely to keep the ParparVM dead-code +/// eliminator from stripping targets that no Java code calls. Without that +/// guard the optimizer replaces them with empty stubs and every operation +/// hangs waiting for an answer that was compiled away -- a failure with +/// nothing in the log to explain it. +/// +/// Everything here forwards straight to the public facades, which own EDT +/// dispatch. That matters here specifically: `NISessionDelegate`, +/// `MCSessionDelegate` and the AccessorySetupKit event stream all call back on +/// their own queues, and under ParparVM none of those is the Codename One EDT. +final class IOSNearbyCallbacks { + + private static IOSNearbyBridge bridge; + private static boolean dceGuard; + + static { + // Keep the native callback targets reachable for the iOS VM + // optimizer. + dceGuard = true; + permissionResult(0, false); + sessionPrepared(0, 0, false, null); + sessionStarted(0, 0); + accessoryConfiguration(0, 0, null); + rangingFailed(0, 0, null); + rangingUpdate(0, false, 0, false, 0, false, 0, false, 0, 0, 0); + peerRemoved(0, 0); + sessionSuspended(0); + sessionResumed(0); + sessionInvalidated(0, 0, null); + associated(0, null); + disassociated(0); + companionFailed(0, 0, null); + presenceChanged(null, false); + transportOk(0); + transportFailed(0, 0, null); + endpointFound(null, false); + connectionRequested(null, null); + connectionResult(null, false, 0, null); + disconnected(null); + payloadReceived(null, 0, 0, null, null); + payloadProgress(null, 0, 0, 0, 0); + dceGuard = false; + } + + private IOSNearbyCallbacks() { + } + + /// Returns the singleton nearby bridge, creating it on first use. + /// + /// #### Parameters + /// + /// - `nativeInstance`: the port's native surface + /// + /// #### Returns + /// + /// the bridge, never `null` + static synchronized IOSNearbyBridge getBridge(IOSNative nativeInstance) { + if (bridge == null) { + bridge = new IOSNearbyBridge(nativeInstance); + } + return bridge; + } + + /// Reached from the bridge's constructor so this class is initialized -- + /// and its dead-code guard therefore runs -- before any native code can + /// call back into it. + static void keepAlive() { + // The static initializer is the work; this exists to trigger it from + // a caller the optimizer can see. + } + + // ---- Callbacks invoked from native code (do not rename) --------------- + + /// Called from native when a permission request closes. + static void permissionResult(int requestId, boolean granted) { + if (dceGuard) { + return; + } + Ranging.deliverPermissionResult(requestId, granted); + } + + /// Called from native once an NISession exists and has a discovery token. + static void sessionPrepared(int requestId, int sessionHandle, + boolean controller, byte[] tokenPayload) { + if (dceGuard) { + return; + } + Ranging.deliverSessionPrepared(requestId, sessionHandle, controller, + RangingToken.PLATFORM_APPLE_NI, tokenPayload); + } + + /// Called from native once a session is running against a peer. + static void sessionStarted(int requestId, int sessionHandle) { + if (dceGuard) { + return; + } + Ranging.deliverSessionStarted(requestId, sessionHandle); + } + + /// Called from native with the bytes to hand back to an accessory. + static void accessoryConfiguration(int requestId, int sessionHandle, + byte[] shareable) { + if (dceGuard) { + return; + } + Ranging.deliverAccessoryConfiguration(requestId, sessionHandle, + shareable); + } + + /// Called from native when a ranging request fails. + static void rangingFailed(int requestId, int errorOrdinal, String message) { + if (dceGuard) { + return; + } + Ranging.deliverRequestFailed(requestId, errorOrdinal, message); + } + + /// Called from native for every measurement. + /// + /// The direction arrives as three separate floats rather than an array so + /// the Objective-C side never has to allocate a Java array on a delegate + /// callback that fires several times a second. + static void rangingUpdate(int sessionHandle, boolean hasDistance, + double distanceMeters, boolean hasDirection, double azimuth, + boolean hasElevation, double elevation, boolean hasVector, + float x, float y, float z) { + if (dceGuard) { + return; + } + RangingSession.deliverUpdate(sessionHandle, hasDistance, + distanceMeters, hasDirection, azimuth, hasElevation, elevation, + hasVector ? new float[] {x, y, z} : null); + } + + /// Called from native when a peer stops being ranged. + static void peerRemoved(int sessionHandle, int reasonOrdinal) { + if (dceGuard) { + return; + } + RangingSession.deliverPeerRemoved(sessionHandle, reasonOrdinal); + } + + /// Called from native when the platform suspends a session. + static void sessionSuspended(int sessionHandle) { + if (dceGuard) { + return; + } + RangingSession.deliverSuspended(sessionHandle); + } + + /// Called from native when a suspended session resumes. + static void sessionResumed(int sessionHandle) { + if (dceGuard) { + return; + } + RangingSession.deliverResumed(sessionHandle); + } + + /// Called from native when a session dies for good. + static void sessionInvalidated(int sessionHandle, int errorOrdinal, + String message) { + if (dceGuard) { + return; + } + RangingSession.deliverInvalidated(sessionHandle, errorOrdinal, message); + } + + /// Called from native when the accessory picker returns a device. + static void associated(int requestId, String encodedDevice) { + if (dceGuard) { + return; + } + CompanionDevices.deliverAssociated(requestId, encodedDevice); + } + + /// Called from native when an association is dropped. + static void disassociated(int requestId) { + if (dceGuard) { + return; + } + CompanionDevices.deliverDisassociated(requestId); + } + + /// Called from native when an association request fails. + static void companionFailed(int requestId, int errorOrdinal, + String message) { + if (dceGuard) { + return; + } + CompanionDevices.deliverRequestFailed(requestId, errorOrdinal, message); + } + + /// Called from native when an observed accessory comes or goes. + static void presenceChanged(String encodedDevice, boolean present) { + if (dceGuard) { + return; + } + CompanionDevices.deliverPresenceChanged(encodedDevice, present); + } + + /// Called from native when a transport request succeeds. + static void transportOk(int requestId) { + if (dceGuard) { + return; + } + NearbyTransport.deliverRequestOk(requestId); + } + + /// Called from native when a transport request fails. + static void transportFailed(int requestId, int errorOrdinal, + String message) { + if (dceGuard) { + return; + } + NearbyTransport.deliverRequestFailed(requestId, errorOrdinal, message); + } + + /// Called from native when a peer appears or disappears. + static void endpointFound(String encodedEndpoint, boolean found) { + if (dceGuard) { + return; + } + NearbyTransport.deliverEndpointFound(encodedEndpoint, found); + } + + /// Called from native when a peer invites this device. + static void connectionRequested(String encodedEndpoint, + String authenticationToken) { + if (dceGuard) { + return; + } + NearbyTransport.deliverConnectionRequested(encodedEndpoint, + authenticationToken); + } + + /// Called from native when a connection attempt settles. + static void connectionResult(String encodedEndpoint, boolean connected, + int errorOrdinal, String message) { + if (dceGuard) { + return; + } + NearbyTransport.deliverConnectionResult(encodedEndpoint, connected, + errorOrdinal, message); + } + + /// Called from native when an open connection closes. + static void disconnected(String encodedEndpoint) { + if (dceGuard) { + return; + } + NearbyTransport.deliverDisconnected(encodedEndpoint); + } + + /// Called from native with a complete incoming payload. + static void payloadReceived(String encodedEndpoint, int payloadId, + int payloadType, byte[] bytes, String path) { + if (dceGuard) { + return; + } + NearbyTransport.deliverPayloadReceived(encodedEndpoint, payloadId, + payloadType, bytes, path); + } + + /// Called from native with progress on a payload. + static void payloadProgress(String encodedEndpoint, int payloadId, + long bytesTransferred, long totalBytes, int statusOrdinal) { + if (dceGuard) { + return; + } + NearbyTransport.deliverPayloadProgress(encodedEndpoint, payloadId, + bytesTransferred, totalBytes, statusOrdinal); + } + + /// Splits a newline-joined batch, the inverse of what the native side + /// does to keep the interface to one string. + /// + /// #### Parameters + /// + /// - `joined`: the batch, may be null or empty + /// + /// #### Returns + /// + /// the records, never null + static String[] split(String joined) { + if (joined == null || joined.length() == 0) { + return new String[0]; + } + List parts = StringUtil.tokenize(joined, '\n'); + String[] out = new String[parts.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = parts.get(i); + } + return out; + } +} From bddc5407a46f6b374b8a4e52a55811d66a6a5432 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:30:12 +0300 Subject: [PATCH 03/94] Nearby devices: the Android port Carries com.codename1.nearby onto CompanionDeviceManager, Jetpack UWB and Nearby Connections. Behaviour is unchanged for every existing app: the shell that ships in the port jar finds no backend and reports all three halves unsupported. The implementation is not in the port jar, and cannot be. That jar is compiled against an android.jar from 2017, and everything this needs is newer: CompanionDeviceService is API 31, AssociationInfo and getMyAssociations are API 33, and androidx.core.uwb and play-services-nearby are gradle dependencies the build only adds for an app that referenced the matching package. So com.codename1.impl.android.nearby is excluded from the port jar compile in the three mirrored places -- build.xml, nbproject/project.properties and maven/android/pom.xml -- ships as sources, and compiles inside the generated app where a modern compileSdk and those dependencies exist. That is what com.codename1.impl.android.ar and .cipher already do, and the reason the load is reflective and its failure is a shrug: for most apps the package is not there at all. The reflection is two levels deep, and the second level is what makes the package boundary real. An app that only associates accessories has neither gradle dependency, so the coordinator reaches the UWB and Nearby Connections classes reflectively too -- either can be absent without costing the app the other two halves. Ranging goes through androidx.core.uwb:uwb-rxjava3 rather than the base library. androidx.core.uwb is a Kotlin coroutines API whose prepareSession returns a Flow and whose session getters are suspend functions; consuming either from the port's Java means hand-writing a Continuation. The rxjava3 artifact is the same library's own Java-facing wrapper, so this stays ordinary Java rather than machinery to get subtly wrong. The Android token carries what the controlee has to join. Apple's Nearby Interaction negotiates channel and session parameters itself, so its token is one opaque blob; Android's controller picks the complex channel and the session id and the controlee has to be told both plus the address. So the token packs address, channel, preamble, session id and key -- the same shape RangingToken.forUwbAddress builds for an accessory, which is why accessory ranging and peer ranging are one code path here. Three smaller decisions. A GENERIC association asks for no device profile at all rather than a harmless-looking one, because a profile is a request for elevated privileges and shows the user a stronger prompt. The association is read back from the platform after the chooser returns rather than out of the result intent, because API 33 and later carry an id and a display name the intent extra does not -- and that id is what disassociate and presence observation take. And CN1CompanionDeviceService drops an event for an association the app has stopped watching: the platform keeps watching across process restarts, so it will deliver events nobody asked for any more. The port module builds clean and SpotBugs reports zero findings. The jar was inspected to confirm AndroidNearbyBridge.class is present and the nearby package ships as .java only, exactly as ar/AndroidARImpl.java does. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/Android/build.xml | 2 +- Ports/Android/nbproject/project.properties | 2 +- .../impl/android/AndroidImplementation.java | 16 + .../impl/android/AndroidNearbyBridge.java | 302 ++++++++ .../android/nearby/AndroidNearbyBackend.java | 660 ++++++++++++++++++ .../nearby/AndroidNearbyTransport.java | 470 +++++++++++++ .../android/nearby/AndroidUwbRanging.java | 501 +++++++++++++ .../nearby/CN1CompanionDeviceService.java | 113 +++ maven/android/pom.xml | 11 + 9 files changed, 2075 insertions(+), 2 deletions(-) create mode 100644 Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java create mode 100644 Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java create mode 100644 Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java create mode 100644 Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java create mode 100644 Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java diff --git a/Ports/Android/build.xml b/Ports/Android/build.xml index 0366644e2df..763699b995f 100644 --- a/Ports/Android/build.xml +++ b/Ports/Android/build.xml @@ -117,7 +117,7 @@ entry in nbproject/project.properties and in maven/android/pom.xml. --> + excludes="com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**"> diff --git a/Ports/Android/nbproject/project.properties b/Ports/Android/nbproject/project.properties index db63273a2a8..f08bf843043 100644 --- a/Ports/Android/nbproject/project.properties +++ b/Ports/Android/nbproject/project.properties @@ -30,7 +30,7 @@ endorsed.classpath= # not in cn1-binaries. They are compiled inside user app builds where the # Android builder adds only the dependencies and sources used by the app. # Mirrors the maven-compiler excludes in maven/android/pom.xml. -excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/** +excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/** file.reference.android-billing-4.0.0.jar=../../../cn1-binaries/android/android-billing-4.0.0.jar file.reference.android-support-v7-appcompat.jar=../../../cn1-binaries/android/android-support-v7-appcompat.jar file.reference.android-support-v7-cardview.jar=../../../cn1-binaries/android/android-support-v7-cardview.jar diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index f4bb036122b..2014995c09b 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13489,6 +13489,22 @@ public com.codename1.impl.ARImpl createARImpl() { } } + private AndroidNearbyBridge nearbyBridge; + + /// The nearby bridge, which finds its own implementation. + /// + /// Always returned rather than conditionally null: the shell answers every + /// capability query honestly whether or not the optional backend was + /// bundled, so the public API reports NOT_SUPPORTED without this getter + /// having to know how the app was built. + @Override + public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + if (nearbyBridge == null) { + nearbyBridge = new AndroidNearbyBridge(getActivity()); + } + return nearbyBridge; + } + @Override public com.codename1.impl.VisionImpl createVisionImpl() { return (com.codename1.impl.VisionImpl) createOptionalAiBackend( diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java b/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java new file mode 100644 index 00000000000..e0e2778c7a8 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java @@ -0,0 +1,302 @@ +/* + * 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.impl.android; + +import android.app.Activity; + +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.spi.NearbyBridge; + +/// The always-compiled half of the Android nearby bridge: a shell that finds +/// the real implementation, or reports the feature missing when there is +/// none. +/// +/// #### Why the work is not here +/// +/// The Android port jar is compiled against an SDK from 2017 and against no +/// optional dependency at all. Everything this feature needs is newer than +/// that -- `CompanionDeviceService` and `AssociationInfo` are API 31 and 33, +/// `androidx.core.uwb` and `play-services-nearby` are gradle dependencies the +/// build only adds for an app that referenced the matching package. So the +/// implementation lives in `com.codename1.impl.android.nearby`, which is +/// excluded from the port jar compile and compiled inside the generated app +/// instead, where a modern `compileSdk` and those dependencies exist. +/// +/// That is the same arrangement `com.codename1.impl.android.ar` and +/// `com.codename1.impl.android.cipher` use, and the reason the load below is +/// reflective and its failure is a shrug rather than an error: for most apps +/// the package is not there at all, because the builder deleted it. +public class AndroidNearbyBridge implements NearbyBridge { + + private final NearbyBridge delegate; + + /// Loads the optional backend, or `null` when the build did not include + /// it. + /// + /// #### Parameters + /// + /// - `activity`: the host activity, which the backend needs for the + /// association chooser + public AndroidNearbyBridge(Activity activity) { + NearbyBridge loaded = null; + try { + Class clazz = Class.forName( + "com.codename1.impl.android.nearby.AndroidNearbyBackend"); + loaded = (NearbyBridge) clazz.getConstructor(Activity.class) + .newInstance(activity); + } catch (Throwable t) { + // Expected for every app that never referenced com.codename1 + // .nearby: the builder deleted the package. Nothing to log. + loaded = null; + } + this.delegate = loaded; + } + + // ------------------------------------------------------------------ + // Shared + // ------------------------------------------------------------------ + + public boolean isRangingSupported() { + return delegate != null && delegate.isRangingSupported(); + } + + public boolean isCompanionSupported() { + return delegate != null && delegate.isCompanionSupported(); + } + + public boolean isTransportSupported() { + return delegate != null && delegate.isTransportSupported(); + } + + public int getRangingAvailability() { + return delegate == null ? NearbyAvailability.NOT_SUPPORTED.ordinal() + : delegate.getRangingAvailability(); + } + + public int getCompanionAvailability() { + return delegate == null ? NearbyAvailability.NOT_SUPPORTED.ordinal() + : delegate.getCompanionAvailability(); + } + + public int getTransportAvailability() { + return delegate == null ? NearbyAvailability.NOT_SUPPORTED.ordinal() + : delegate.getTransportAvailability(); + } + + public void requestPermissions(int requestId, int permissionBits) { + if (delegate != null) { + delegate.requestPermissions(requestId, permissionBits); + } else { + // Still answered, because a caller is holding a resource. + com.codename1.nearby.ranging.Ranging.deliverPermissionResult( + requestId, false); + } + } + + // ------------------------------------------------------------------ + // Ranging + // ------------------------------------------------------------------ + + public int getRangingCapabilities() { + return delegate == null ? 0 : delegate.getRangingCapabilities(); + } + + public void prepareRangingSession(int requestId, int sessionHandle, + boolean controller) { + if (delegate != null) { + delegate.prepareRangingSession(requestId, sessionHandle, + controller); + } else { + failRanging(requestId); + } + } + + public void startRanging(int requestId, int sessionHandle, + byte[] peerToken) { + if (delegate != null) { + delegate.startRanging(requestId, sessionHandle, peerToken); + } else { + failRanging(requestId); + } + } + + public void startAccessoryRanging(int requestId, int sessionHandle, + byte[] accessoryData) { + if (delegate != null) { + delegate.startAccessoryRanging(requestId, sessionHandle, + accessoryData); + } else { + failRanging(requestId); + } + } + + public void stopRangingSession(int sessionHandle) { + if (delegate != null) { + delegate.stopRangingSession(sessionHandle); + } + } + + // ------------------------------------------------------------------ + // Companion + // ------------------------------------------------------------------ + + public void associate(int requestId, int profile, boolean singleDevice, + String[] filters) { + if (delegate != null) { + delegate.associate(requestId, profile, singleDevice, filters); + } else { + com.codename1.nearby.companion.CompanionDevices + .deliverRequestFailed(requestId, + com.codename1.nearby.NearbyError.NOT_SUPPORTED + .ordinal(), null); + } + } + + public String[] getAssociations() { + return delegate == null ? new String[0] : delegate.getAssociations(); + } + + public void disassociate(int requestId, String associationId) { + if (delegate != null) { + delegate.disassociate(requestId, associationId); + } else { + com.codename1.nearby.companion.CompanionDevices + .deliverRequestFailed(requestId, + com.codename1.nearby.NearbyError.NOT_SUPPORTED + .ordinal(), null); + } + } + + public boolean startObservingPresence(String associationId) { + return delegate != null + && delegate.startObservingPresence(associationId); + } + + public void stopObservingPresence(String associationId) { + if (delegate != null) { + delegate.stopObservingPresence(associationId); + } + } + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + public int getMaxPayloadSize() { + return delegate == null ? 0 : delegate.getMaxPayloadSize(); + } + + public void startAdvertising(int requestId, String serviceId, + String localName, int strategy) { + if (delegate != null) { + delegate.startAdvertising(requestId, serviceId, localName, + strategy); + } else { + failTransport(requestId); + } + } + + public void stopAdvertising() { + if (delegate != null) { + delegate.stopAdvertising(); + } + } + + public void startDiscovery(int requestId, String serviceId, int strategy) { + if (delegate != null) { + delegate.startDiscovery(requestId, serviceId, strategy); + } else { + failTransport(requestId); + } + } + + public void stopDiscovery() { + if (delegate != null) { + delegate.stopDiscovery(); + } + } + + public void requestConnection(int requestId, String endpointId, + String localName) { + if (delegate != null) { + delegate.requestConnection(requestId, endpointId, localName); + } else { + failTransport(requestId); + } + } + + public void acceptConnection(int requestId, String endpointId) { + if (delegate != null) { + delegate.acceptConnection(requestId, endpointId); + } else { + failTransport(requestId); + } + } + + public void rejectConnection(String endpointId) { + if (delegate != null) { + delegate.rejectConnection(endpointId); + } + } + + public void sendPayload(int requestId, String[] endpointIds, int payloadId, + int payloadType, byte[] bytes, String path) { + if (delegate != null) { + delegate.sendPayload(requestId, endpointIds, payloadId, + payloadType, bytes, path); + } else { + failTransport(requestId); + } + } + + public void cancelPayload(int payloadId) { + if (delegate != null) { + delegate.cancelPayload(payloadId); + } + } + + public void disconnect(String endpointId) { + if (delegate != null) { + delegate.disconnect(endpointId); + } + } + + public void stopAllTransport() { + if (delegate != null) { + delegate.stopAllTransport(); + } + } + + private static void failRanging(int requestId) { + com.codename1.nearby.ranging.Ranging.deliverRequestFailed(requestId, + com.codename1.nearby.NearbyError.NOT_SUPPORTED.ordinal(), + null); + } + + private static void failTransport(int requestId) { + com.codename1.nearby.transport.NearbyTransport.deliverRequestFailed( + requestId, + com.codename1.nearby.NearbyError.NOT_SUPPORTED.ordinal(), + null); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java new file mode 100644 index 00000000000..5e33837813c --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -0,0 +1,660 @@ +/* + * 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.impl.android.nearby; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.bluetooth.BluetoothDevice; +import android.companion.AssociationInfo; +import android.companion.AssociationRequest; +import android.companion.BluetoothLeDeviceFilter; +import android.companion.CompanionDeviceManager; +import android.companion.WifiDeviceFilter; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelUuid; + +import com.codename1.impl.android.CodenameOneActivity; +import com.codename1.impl.android.IntentResultListener; +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.companion.CompanionDevices; +import com.codename1.nearby.spi.NearbyBridge; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/// The Android nearby implementation, compiled inside the generated app +/// rather than into the port jar. +/// +/// It owns the companion-device half directly -- `CompanionDeviceManager` is +/// a framework class and needs no dependency, only an SDK newer than the one +/// the port jar is built against -- and reaches the other two halves +/// reflectively, for the same reason this class is itself reached +/// reflectively: an app that only associates accessories has neither +/// `androidx.core.uwb` nor `play-services-nearby` on its classpath, and the +/// builder has deleted the classes that would import them. +public class AndroidNearbyBackend implements NearbyBridge { + + /// Request code for the association chooser. Picked high to stay clear of + /// the port's own IntentResultListener constants. + private static final int ASSOCIATE_REQUEST = 0x4E42; + + private final Activity activity; + private final NearbyBridge ranging; + private final NearbyBridge transport; + + private int pendingAssociateRequest; + + public AndroidNearbyBackend(Activity activity) { + this.activity = activity; + this.ranging = load("com.codename1.impl.android.nearby." + + "AndroidUwbRanging"); + this.transport = load("com.codename1.impl.android.nearby." + + "AndroidNearbyTransport"); + } + + private NearbyBridge load(String className) { + try { + Class clazz = Class.forName(className); + return (NearbyBridge) clazz.getConstructor(Context.class) + .newInstance(activity); + } catch (Throwable t) { + // The builder deletes the half an app did not reference, so this + // is the ordinary path rather than an error. + return null; + } + } + + // ------------------------------------------------------------------ + // Shared + // ------------------------------------------------------------------ + + public boolean isRangingSupported() { + return ranging != null && ranging.isRangingSupported(); + } + + public boolean isTransportSupported() { + return transport != null && transport.isTransportSupported(); + } + + public boolean isCompanionSupported() { + return Build.VERSION.SDK_INT >= 26 && manager() != null; + } + + public int getRangingAvailability() { + return ranging == null ? NearbyAvailability.NOT_SUPPORTED.ordinal() + : ranging.getRangingAvailability(); + } + + public int getTransportAvailability() { + return transport == null ? NearbyAvailability.NOT_SUPPORTED.ordinal() + : transport.getTransportAvailability(); + } + + public int getCompanionAvailability() { + return isCompanionSupported() + ? NearbyAvailability.AVAILABLE.ordinal() + : NearbyAvailability.NOT_SUPPORTED.ordinal(); + } + + public void requestPermissions(int requestId, int permissionBits) { + // Delegated to whichever half is present: the permissions differ, and + // the ranging half is the one that knows about UWB_RANGING. + if (ranging != null) { + ranging.requestPermissions(requestId, permissionBits); + return; + } + if (transport != null) { + transport.requestPermissions(requestId, permissionBits); + return; + } + // Association needs no runtime permission on any Android version -- + // consent is the chooser itself -- so an app that only associates is + // told yes rather than left waiting. + com.codename1.nearby.ranging.Ranging.deliverPermissionResult(requestId, + true); + } + + // ------------------------------------------------------------------ + // Ranging + // ------------------------------------------------------------------ + + public int getRangingCapabilities() { + return ranging == null ? 0 : ranging.getRangingCapabilities(); + } + + public void prepareRangingSession(int requestId, int sessionHandle, + boolean controller) { + if (ranging == null) { + failRanging(requestId); + return; + } + ranging.prepareRangingSession(requestId, sessionHandle, controller); + } + + public void startRanging(int requestId, int sessionHandle, + byte[] peerToken) { + if (ranging == null) { + failRanging(requestId); + return; + } + ranging.startRanging(requestId, sessionHandle, peerToken); + } + + public void startAccessoryRanging(int requestId, int sessionHandle, + byte[] accessoryData) { + if (ranging == null) { + failRanging(requestId); + return; + } + ranging.startAccessoryRanging(requestId, sessionHandle, accessoryData); + } + + public void stopRangingSession(int sessionHandle) { + if (ranging != null) { + ranging.stopRangingSession(sessionHandle); + } + } + + // ------------------------------------------------------------------ + // Companion + // ------------------------------------------------------------------ + + private CompanionDeviceManager manager() { + if (Build.VERSION.SDK_INT < 26 || activity == null) { + return null; + } + try { + return (CompanionDeviceManager) activity.getSystemService( + Context.COMPANION_DEVICE_SERVICE); + } catch (Throwable t) { + return null; + } + } + + @SuppressLint("MissingPermission") + public void associate(final int requestId, int profile, + boolean singleDevice, String[] filters) { + final CompanionDeviceManager cdm = manager(); + if (cdm == null) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.NOT_SUPPORTED.ordinal(), + "companion association needs Android 8 or later"); + return; + } + if (pendingAssociateRequest != 0) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.BUSY.ordinal(), + "an association chooser is already open"); + return; + } + AssociationRequest.Builder request = new AssociationRequest.Builder(); + request.setSingleDevice(singleDevice); + if (Build.VERSION.SDK_INT >= 31) { + String deviceProfile = profileFor(profile); + if (deviceProfile != null) { + request.setDeviceProfile(deviceProfile); + } + } + boolean anyFilter = false; + for (int i = 0; filters != null && i < filters.length; i++) { + if (addFilter(request, filters[i])) { + anyFilter = true; + } + } + if (!anyFilter) { + // An unfiltered request is legal and shows everything the radios + // can see. Left as is rather than refused: that is the same thing + // an empty filter list means in the portable API. + request.addDeviceFilter(new BluetoothLeDeviceFilter.Builder() + .build()); + } + pendingAssociateRequest = requestId; + listenForResult(requestId, cdm); + cdm.associate(request.build(), new CompanionDeviceManager.Callback() { + @Override + public void onDeviceFound(IntentSender chooserLauncher) { + launch(chooserLauncher, requestId); + } + + @Override + public void onFailure(CharSequence error) { + pendingAssociateRequest = 0; + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + error == null ? null : error.toString()); + } + }, new Handler(Looper.getMainLooper())); + } + + private void launch(IntentSender chooserLauncher, int requestId) { + try { + activity.startIntentSenderForResult(chooserLauncher, + ASSOCIATE_REQUEST, null, 0, 0, 0); + } catch (IntentSender.SendIntentException e) { + pendingAssociateRequest = 0; + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.UNKNOWN.ordinal(), e.getMessage()); + } + } + + private void listenForResult(final int requestId, + final CompanionDeviceManager cdm) { + if (!(activity instanceof CodenameOneActivity)) { + return; + } + final CodenameOneActivity host = (CodenameOneActivity) activity; + host.setIntentResultListener(new IntentResultListener() { + public void onActivityResult(int requestCode, int resultCode, + Intent data) { + if (requestCode != ASSOCIATE_REQUEST) { + return; + } + host.restoreIntentResultListener(); + pendingAssociateRequest = 0; + if (resultCode != Activity.RESULT_OK) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.USER_CANCELED.ordinal(), + "the user dismissed the chooser"); + return; + } + String encoded = newestAssociation(cdm, data); + if (encoded == null) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.UNKNOWN.ordinal(), + "the chooser returned no device"); + } else { + CompanionDevices.deliverAssociated(requestId, encoded); + } + } + }); + } + + /// The association the chooser just created. + /// + /// Read back from the platform rather than from the returned intent + /// wherever possible: on API 33 and later the association carries an id + /// and a display name the intent extra does not, and that id is what + /// `disassociate` and presence observation take. + @SuppressLint("MissingPermission") + private String newestAssociation(CompanionDeviceManager cdm, Intent data) { + if (Build.VERSION.SDK_INT >= 33) { + List all = cdm.getMyAssociations(); + if (all != null && !all.isEmpty()) { + return encode(all.get(all.size() - 1), true); + } + } + if (data != null) { + Object extra = data.getParcelableExtra( + CompanionDeviceManager.EXTRA_DEVICE); + if (extra instanceof BluetoothDevice) { + BluetoothDevice d = (BluetoothDevice) extra; + return encodeLegacy(d.getAddress(), d.getAddress(), true); + } + } + List legacy = cdm.getAssociations(); + if (legacy != null && !legacy.isEmpty()) { + String mac = legacy.get(legacy.size() - 1); + return encodeLegacy(mac, mac, true); + } + return null; + } + + @SuppressLint("MissingPermission") + public String[] getAssociations() { + CompanionDeviceManager cdm = manager(); + if (cdm == null) { + return new String[0]; + } + List out = new ArrayList(); + if (Build.VERSION.SDK_INT >= 33) { + List all = cdm.getMyAssociations(); + for (int i = 0; all != null && i < all.size(); i++) { + out.add(encode(all.get(i), false)); + } + } else { + List legacy = cdm.getAssociations(); + for (int i = 0; legacy != null && i < legacy.size(); i++) { + out.add(encodeLegacy(legacy.get(i), legacy.get(i), false)); + } + } + return out.toArray(new String[out.size()]); + } + + @SuppressLint("MissingPermission") + public void disassociate(int requestId, String associationId) { + CompanionDeviceManager cdm = manager(); + if (cdm == null || associationId == null) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.NOT_SUPPORTED.ordinal(), null); + return; + } + try { + if (Build.VERSION.SDK_INT >= 33) { + List all = cdm.getMyAssociations(); + for (int i = 0; all != null && i < all.size(); i++) { + if (idOf(all.get(i)).equals(associationId)) { + cdm.disassociate(all.get(i).getId()); + CompanionDevices.deliverDisassociated(requestId); + return; + } + } + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + "no such association"); + return; + } + cdm.disassociate(associationId); + CompanionDevices.deliverDisassociated(requestId); + } catch (Throwable t) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.UNKNOWN.ordinal(), t.getMessage()); + } + } + + @SuppressLint("MissingPermission") + public boolean startObservingPresence(String associationId) { + CompanionDeviceManager cdm = manager(); + if (cdm == null || associationId == null + || Build.VERSION.SDK_INT < 31) { + return false; + } + try { + // Takes the MAC address on every version that has it, which is + // what the encoded address field carries. + cdm.startObservingDevicePresence(addressOf(cdm, associationId)); + CN1CompanionDeviceService.register(associationId); + return true; + } catch (Throwable t) { + return false; + } + } + + @SuppressLint("MissingPermission") + public void stopObservingPresence(String associationId) { + CompanionDeviceManager cdm = manager(); + if (cdm == null || associationId == null + || Build.VERSION.SDK_INT < 31) { + return; + } + try { + cdm.stopObservingDevicePresence(addressOf(cdm, associationId)); + CN1CompanionDeviceService.unregister(associationId); + } catch (Throwable t) { + // Nothing to report: the caller asked to stop and it is stopped + // either way. + } + } + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + public int getMaxPayloadSize() { + return transport == null ? 0 : transport.getMaxPayloadSize(); + } + + public void startAdvertising(int requestId, String serviceId, + String localName, int strategy) { + if (transport == null) { + failTransport(requestId); + return; + } + transport.startAdvertising(requestId, serviceId, localName, strategy); + } + + public void stopAdvertising() { + if (transport != null) { + transport.stopAdvertising(); + } + } + + public void startDiscovery(int requestId, String serviceId, int strategy) { + if (transport == null) { + failTransport(requestId); + return; + } + transport.startDiscovery(requestId, serviceId, strategy); + } + + public void stopDiscovery() { + if (transport != null) { + transport.stopDiscovery(); + } + } + + public void requestConnection(int requestId, String endpointId, + String localName) { + if (transport == null) { + failTransport(requestId); + return; + } + transport.requestConnection(requestId, endpointId, localName); + } + + public void acceptConnection(int requestId, String endpointId) { + if (transport == null) { + failTransport(requestId); + return; + } + transport.acceptConnection(requestId, endpointId); + } + + public void rejectConnection(String endpointId) { + if (transport != null) { + transport.rejectConnection(endpointId); + } + } + + public void sendPayload(int requestId, String[] endpointIds, int payloadId, + int payloadType, byte[] bytes, String path) { + if (transport == null) { + failTransport(requestId); + return; + } + transport.sendPayload(requestId, endpointIds, payloadId, payloadType, + bytes, path); + } + + public void cancelPayload(int payloadId) { + if (transport != null) { + transport.cancelPayload(payloadId); + } + } + + public void disconnect(String endpointId) { + if (transport != null) { + transport.disconnect(endpointId); + } + } + + public void stopAllTransport() { + if (transport != null) { + transport.stopAllTransport(); + } + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private static String profileFor(int profile) { + // The ordinals of com.codename1.nearby.companion.CompanionProfile. + if (Build.VERSION.SDK_INT < 31) { + return null; + } + switch (profile) { + case 1: + return AssociationRequest.DEVICE_PROFILE_WATCH; + case 2: + return Build.VERSION.SDK_INT >= 33 + ? AssociationRequest.DEVICE_PROFILE_GLASSES : null; + case 3: + return Build.VERSION.SDK_INT >= 34 + ? AssociationRequest.DEVICE_PROFILE_COMPUTER : null; + default: + // GENERIC. Deliberately no profile at all rather than a + // harmless-looking one: a profile is a request for elevated + // privileges and shows the user a stronger prompt. + return null; + } + } + + private static boolean addFilter(AssociationRequest.Builder request, + String encoded) { + String[] fields = encoded == null ? null : encoded.split("\t", -1); + if (fields == null || fields.length < 2) { + return false; + } + int kind; + try { + kind = Integer.parseInt(fields[0]); + } catch (NumberFormatException e) { + return false; + } + String value = fields[1]; + // The kind constants of com.codename1.nearby.companion.DeviceFilter. + if (kind == 0) { + try { + request.addDeviceFilter(new BluetoothLeDeviceFilter.Builder() + .setScanFilter(new android.bluetooth.le.ScanFilter + .Builder() + .setServiceUuid(ParcelUuid.fromString( + expandUuid(value))) + .build()) + .build()); + return true; + } catch (Throwable t) { + return false; + } + } + if (kind == 1) { + try { + request.addDeviceFilter(new BluetoothLeDeviceFilter.Builder() + .setNamePattern(Pattern.compile(value)) + .build()); + return true; + } catch (Throwable t) { + return false; + } + } + if (kind == 2) { + request.addDeviceFilter( + new android.companion.BluetoothDeviceFilter.Builder() + .setAddress(value) + .build()); + return true; + } + if (kind == 3) { + request.addDeviceFilter(new WifiDeviceFilter.Builder() + .setNamePattern(Pattern.compile(Pattern.quote(value))) + .build()); + return true; + } + return false; + } + + /// Expands the 16-bit short form of a Bluetooth UUID into the full one, + /// which is what `ParcelUuid` requires. `"180D"` and the spelled-out + /// 128-bit form must both work, because the portable API documents both. + private static String expandUuid(String uuid) { + String u = uuid.trim(); + if (u.length() == 4) { + return "0000" + u + "-0000-1000-8000-00805F9B34FB"; + } + if (u.length() == 8) { + return u + "-0000-1000-8000-00805F9B34FB"; + } + return u; + } + + private static String idOf(AssociationInfo info) { + String mac = info.getDeviceMacAddressAsString(); + return mac != null ? mac : Integer.toString(info.getId()); + } + + private static String encode(AssociationInfo info, boolean present) { + String mac = info.getDeviceMacAddressAsString(); + CharSequence name = info.getDisplayName(); + return join(idOf(info), name == null ? "" : name.toString(), + mac == null ? "" : mac, present); + } + + private static String encodeLegacy(String id, String mac, + boolean present) { + return join(id, mac == null ? "" : mac, mac == null ? "" : mac, + present); + } + + /// Builds the record `com.codename1.impl.nearby.NearbyWire` decodes. + /// + /// The profile field is always zero: Android does not report back which + /// profile an association was made under, and guessing would be worse + /// than saying GENERIC. + private static String join(String id, String name, String address, + boolean present) { + return sanitize(id) + '\t' + sanitize(name) + '\t' + sanitize(address) + + "\t0\t" + (present ? '1' : '0'); + } + + private static String sanitize(String s) { + if (s == null) { + return ""; + } + return s.replace('\t', ' ').replace('\n', ' ').replace('\r', ' '); + } + + @SuppressLint("MissingPermission") + private static String addressOf(CompanionDeviceManager cdm, String id) { + if (Build.VERSION.SDK_INT >= 33) { + List all = cdm.getMyAssociations(); + for (int i = 0; all != null && i < all.size(); i++) { + if (idOf(all.get(i)).equals(id)) { + String mac = all.get(i).getDeviceMacAddressAsString(); + if (mac != null) { + return mac; + } + } + } + } + return id; + } + + private static void failRanging(int requestId) { + com.codename1.nearby.ranging.Ranging.deliverRequestFailed(requestId, + NearbyError.NOT_SUPPORTED.ordinal(), + "this build does not include precision ranging"); + } + + private static void failTransport(int requestId) { + com.codename1.nearby.transport.NearbyTransport.deliverRequestFailed( + requestId, NearbyError.NOT_SUPPORTED.ordinal(), + "this build does not include the nearby transport"); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java new file mode 100644 index 00000000000..0062dc02211 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -0,0 +1,470 @@ +/* + * 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.impl.android.nearby; + +import android.content.Context; + +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.spi.NearbyBridge; +import com.codename1.nearby.transport.NearbyTransport; +import com.codename1.nearby.transport.PayloadStatus; + +import com.google.android.gms.nearby.Nearby; +import com.google.android.gms.nearby.connection.AdvertisingOptions; +import com.google.android.gms.nearby.connection.ConnectionInfo; +import com.google.android.gms.nearby.connection.ConnectionLifecycleCallback; +import com.google.android.gms.nearby.connection.ConnectionResolution; +import com.google.android.gms.nearby.connection.ConnectionsClient; +import com.google.android.gms.nearby.connection.ConnectionsStatusCodes; +import com.google.android.gms.nearby.connection.DiscoveredEndpointInfo; +import com.google.android.gms.nearby.connection.DiscoveryOptions; +import com.google.android.gms.nearby.connection.EndpointDiscoveryCallback; +import com.google.android.gms.nearby.connection.Payload; +import com.google.android.gms.nearby.connection.PayloadCallback; +import com.google.android.gms.nearby.connection.PayloadTransferUpdate; +import com.google.android.gms.nearby.connection.Strategy; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; + +import java.io.File; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/// The nearby transport on Android, over Google's Nearby Connections. +/// +/// Compiled inside the generated app, because `play-services-nearby` is on the +/// classpath only for an app that referenced +/// `com.codename1.nearby.transport`. +public class AndroidNearbyTransport implements NearbyBridge { + + /// The Nearby Connections limit for a BYTES payload. + private static final int MAX_BYTES_PAYLOAD = 32 * 1024; + + private final Context context; + private final Map endpointNames = + Collections.synchronizedMap(new HashMap()); + private final Map payloadIds = + Collections.synchronizedMap(new HashMap()); + + private String serviceId = ""; + private String localName = ""; + + public AndroidNearbyTransport(Context context) { + this.context = context; + } + + private ConnectionsClient client() { + return Nearby.getConnectionsClient(context); + } + + // ------------------------------------------------------------------ + // Capability + // ------------------------------------------------------------------ + + public boolean isTransportSupported() { + return true; + } + + public int getTransportAvailability() { + return NearbyAvailability.AVAILABLE.ordinal(); + } + + public int getMaxPayloadSize() { + return MAX_BYTES_PAYLOAD; + } + + public boolean isRangingSupported() { + return false; + } + + public boolean isCompanionSupported() { + return false; + } + + public int getRangingAvailability() { + return NearbyAvailability.NOT_SUPPORTED.ordinal(); + } + + public int getCompanionAvailability() { + return NearbyAvailability.NOT_SUPPORTED.ordinal(); + } + + public int getRangingCapabilities() { + return 0; + } + + public void requestPermissions(int requestId, int permissionBits) { + // The manifest permissions are injected at build time and the runtime + // grants are asked for by the Codename One permission machinery when + // the first scan happens, so there is nothing to raise here. + com.codename1.nearby.ranging.Ranging.deliverPermissionResult(requestId, + true); + } + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + public void startAdvertising(final int requestId, String serviceId, + String localName, int strategy) { + this.serviceId = serviceId == null ? "" : serviceId; + this.localName = localName == null ? "" : localName; + AdvertisingOptions options = new AdvertisingOptions.Builder() + .setStrategy(strategyFor(strategy)) + .build(); + client().startAdvertising(this.localName, this.serviceId, + connectionCallback(), options) + .addOnSuccessListener(new OnSuccessListener() { + public void onSuccess(Void unused) { + NearbyTransport.deliverRequestOk(requestId); + } + }) + .addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_FAILED.ordinal(), + e.getMessage()); + } + }); + } + + public void stopAdvertising() { + client().stopAdvertising(); + } + + public void startDiscovery(final int requestId, String serviceId, + int strategy) { + this.serviceId = serviceId == null ? "" : serviceId; + DiscoveryOptions options = new DiscoveryOptions.Builder() + .setStrategy(strategyFor(strategy)) + .build(); + client().startDiscovery(this.serviceId, discoveryCallback(), options) + .addOnSuccessListener(new OnSuccessListener() { + public void onSuccess(Void unused) { + NearbyTransport.deliverRequestOk(requestId); + } + }) + .addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_FAILED.ordinal(), + e.getMessage()); + } + }); + } + + public void stopDiscovery() { + client().stopDiscovery(); + } + + public void requestConnection(final int requestId, String endpointId, + String localName) { + String name = localName == null || localName.length() == 0 + ? this.localName : localName; + client().requestConnection(name, endpointId, connectionCallback()) + .addOnSuccessListener(new OnSuccessListener() { + public void onSuccess(Void unused) { + NearbyTransport.deliverRequestOk(requestId); + } + }) + .addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + e.getMessage()); + } + }); + } + + public void acceptConnection(final int requestId, String endpointId) { + client().acceptConnection(endpointId, payloadCallback()) + .addOnSuccessListener(new OnSuccessListener() { + public void onSuccess(Void unused) { + NearbyTransport.deliverRequestOk(requestId); + } + }) + .addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_FAILED.ordinal(), + e.getMessage()); + } + }); + } + + public void rejectConnection(String endpointId) { + client().rejectConnection(endpointId); + } + + public void sendPayload(final int requestId, String[] endpointIds, + int payloadId, int payloadType, byte[] bytes, String path) { + if (endpointIds == null || endpointIds.length == 0) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + "no endpoints given"); + return; + } + Payload payload; + try { + if (payloadType == NearbyBridge.PAYLOAD_FILE) { + String p = path; + if (p != null && p.startsWith("file://")) { + p = p.substring(7); + } + payload = Payload.fromFile(new File(p)); + } else { + payload = Payload.fromBytes(bytes == null ? new byte[0] + : bytes); + } + } catch (Exception e) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.IO_ERROR.ordinal(), e.getMessage()); + return; + } + // Nearby Connections mints its own payload id, and progress arrives + // keyed on that one. Mapping it back is what lets the portable API + // report progress against the id the app was handed. + payloadIds.put(Long.valueOf(payload.getId()), + Integer.valueOf(payloadId)); + java.util.List targets = java.util.Arrays.asList(endpointIds); + client().sendPayload(targets, payload) + .addOnSuccessListener(new OnSuccessListener() { + public void onSuccess(Void unused) { + NearbyTransport.deliverRequestOk(requestId); + } + }) + .addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.IO_ERROR.ordinal(), e.getMessage()); + } + }); + } + + public void cancelPayload(int payloadId) { + synchronized (payloadIds) { + for (Map.Entry e : payloadIds.entrySet()) { + if (e.getValue().intValue() == payloadId) { + client().cancelPayload(e.getKey().longValue()); + return; + } + } + } + } + + public void disconnect(String endpointId) { + client().disconnectFromEndpoint(endpointId); + endpointNames.remove(endpointId); + } + + public void stopAllTransport() { + client().stopAllEndpoints(); + endpointNames.clear(); + payloadIds.clear(); + } + + // ------------------------------------------------------------------ + // Callbacks + // ------------------------------------------------------------------ + + private EndpointDiscoveryCallback discoveryCallback() { + return new EndpointDiscoveryCallback() { + @Override + public void onEndpointFound(String endpointId, + DiscoveredEndpointInfo info) { + endpointNames.put(endpointId, info.getEndpointName()); + NearbyTransport.deliverEndpointFound( + encode(endpointId, info.getEndpointName()), true); + } + + @Override + public void onEndpointLost(String endpointId) { + NearbyTransport.deliverEndpointFound( + encode(endpointId, nameOf(endpointId)), false); + endpointNames.remove(endpointId); + } + }; + } + + private ConnectionLifecycleCallback connectionCallback() { + return new ConnectionLifecycleCallback() { + @Override + public void onConnectionInitiated(String endpointId, + ConnectionInfo info) { + endpointNames.put(endpointId, info.getEndpointName()); + NearbyTransport.deliverConnectionRequested( + encode(endpointId, info.getEndpointName()), + info.getAuthenticationDigits()); + } + + @Override + public void onConnectionResult(String endpointId, + ConnectionResolution resolution) { + boolean ok = resolution.getStatus().getStatusCode() + == ConnectionsStatusCodes.STATUS_OK; + NearbyTransport.deliverConnectionResult( + encode(endpointId, nameOf(endpointId)), ok, + ok ? 0 : NearbyError.SESSION_FAILED.ordinal(), + ok ? null : resolution.getStatus().getStatusMessage()); + } + + @Override + public void onDisconnected(String endpointId) { + NearbyTransport.deliverDisconnected( + encode(endpointId, nameOf(endpointId))); + endpointNames.remove(endpointId); + } + }; + } + + private PayloadCallback payloadCallback() { + return new PayloadCallback() { + @Override + public void onPayloadReceived(String endpointId, Payload payload) { + if (payload.getType() == Payload.Type.BYTES) { + NearbyTransport.deliverPayloadReceived( + encode(endpointId, nameOf(endpointId)), + (int) payload.getId(), NearbyBridge.PAYLOAD_BYTES, + payload.asBytes(), null); + return; + } + if (payload.getType() == Payload.Type.FILE + && payload.asFile() != null) { + java.io.File f = null; + try { + f = payload.asFile().asJavaFile(); + } catch (Throwable t) { + // Older Play services return the file only through a + // ParcelFileDescriptor; nothing to hand the app then. + } + NearbyTransport.deliverPayloadReceived( + encode(endpointId, nameOf(endpointId)), + (int) payload.getId(), NearbyBridge.PAYLOAD_FILE, + null, f == null ? null + : "file://" + f.getAbsolutePath()); + } + } + + @Override + public void onPayloadTransferUpdate(String endpointId, + PayloadTransferUpdate update) { + Integer mapped = payloadIds.get( + Long.valueOf(update.getPayloadId())); + int id = mapped == null ? (int) update.getPayloadId() + : mapped.intValue(); + NearbyTransport.deliverPayloadProgress( + encode(endpointId, nameOf(endpointId)), id, + update.getBytesTransferred(), update.getTotalBytes(), + statusFor(update.getStatus()).ordinal()); + if (update.getStatus() + != PayloadTransferUpdate.Status.IN_PROGRESS) { + payloadIds.remove(Long.valueOf(update.getPayloadId())); + } + } + }; + } + + // ------------------------------------------------------------------ + // Unused halves + // ------------------------------------------------------------------ + + public void prepareRangingSession(int requestId, int sessionHandle, + boolean controller) { + } + + public void startRanging(int requestId, int sessionHandle, + byte[] peerToken) { + } + + public void startAccessoryRanging(int requestId, int sessionHandle, + byte[] accessoryData) { + } + + public void stopRangingSession(int sessionHandle) { + } + + public void associate(int requestId, int profile, boolean singleDevice, + String[] filters) { + } + + public String[] getAssociations() { + return new String[0]; + } + + public void disassociate(int requestId, String associationId) { + } + + public boolean startObservingPresence(String associationId) { + return false; + } + + public void stopObservingPresence(String associationId) { + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private String nameOf(String endpointId) { + String name = endpointNames.get(endpointId); + return name == null ? "" : name; + } + + private String encode(String endpointId, String name) { + return sanitize(endpointId) + '\t' + sanitize(name) + '\t' + + sanitize(serviceId); + } + + private static String sanitize(String s) { + if (s == null) { + return ""; + } + return s.replace('\t', ' ').replace('\n', ' ').replace('\r', ' '); + } + + private static Strategy strategyFor(int ordinal) { + // The ordinals of com.codename1.nearby.transport.TransportStrategy. + if (ordinal == 1) { + return Strategy.P2P_STAR; + } + if (ordinal == 2) { + return Strategy.P2P_POINT_TO_POINT; + } + return Strategy.P2P_CLUSTER; + } + + private static PayloadStatus statusFor(int status) { + if (status == PayloadTransferUpdate.Status.SUCCESS) { + return PayloadStatus.SUCCESS; + } + if (status == PayloadTransferUpdate.Status.FAILURE) { + return PayloadStatus.FAILURE; + } + if (status == PayloadTransferUpdate.Status.CANCELED) { + return PayloadStatus.CANCELED; + } + return PayloadStatus.IN_PROGRESS; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java new file mode 100644 index 00000000000..73adcda9ba2 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -0,0 +1,501 @@ +/* + * 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.impl.android.nearby; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import androidx.core.uwb.RangingCapabilities; +import androidx.core.uwb.RangingMeasurement; +import androidx.core.uwb.RangingParameters; +import androidx.core.uwb.RangingPosition; +import androidx.core.uwb.RangingResult; +import androidx.core.uwb.UwbAddress; +import androidx.core.uwb.UwbClientSessionScope; +import androidx.core.uwb.UwbComplexChannel; +import androidx.core.uwb.UwbControleeSessionScope; +import androidx.core.uwb.UwbControllerSessionScope; +import androidx.core.uwb.UwbDevice; +import androidx.core.uwb.UwbManager; +import androidx.core.uwb.rxjava3.UwbClientSessionScopeRx; +import androidx.core.uwb.rxjava3.UwbManagerRx; + +import com.codename1.nearby.NearbyAvailability; +import com.codename1.nearby.NearbyError; +import com.codename1.nearby.ranging.Ranging; +import com.codename1.nearby.ranging.RangingRemovalReason; +import com.codename1.nearby.ranging.RangingSession; +import com.codename1.nearby.ranging.RangingToken; +import com.codename1.nearby.spi.NearbyBridge; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/// Ultra-wideband ranging on Android, over Jetpack UWB. +/// +/// #### Why the RxJava3 wrapper and not the base API +/// +/// `androidx.core.uwb` is a Kotlin coroutines API: `prepareSession` returns a +/// `Flow` and every session getter is a suspend function. Consuming either +/// from the port's Java means hand-writing a `Continuation`, which is a lot of +/// machinery to get subtly wrong. `androidx.core.uwb:uwb-rxjava3` is the same +/// library's own Java-facing wrapper -- a `Single` for the session scope, an +/// `Observable` for the measurements -- so this file stays ordinary Java. +/// +/// Only the classes this file names are needed at runtime; the builder adds +/// both artifacts together. +/// +/// #### The token carries what the controlee has to join +/// +/// Apple's Nearby Interaction negotiates channel and session parameters +/// itself, so its token is one opaque blob. Android's does not: the controller +/// picks the complex channel and the session id, and the controlee has to be +/// told both plus the controller's address. So the token minted here packs +/// address, channel, preamble index, session id and session key -- the same +/// shape `RangingToken.forUwbAddress` builds for an accessory. +public class AndroidUwbRanging implements NearbyBridge { + + private static final int DEFAULT_CHANNEL = 9; + private static final int DEFAULT_PREAMBLE = 10; + + private final Context context; + private final Map sessions = + Collections.synchronizedMap(new HashMap()); + private final Random random = new Random(); + + private UwbManager manager; + + public AndroidUwbRanging(Context context) { + this.context = context; + } + + // ------------------------------------------------------------------ + // Capability + // ------------------------------------------------------------------ + + public boolean isRangingSupported() { + if (Build.VERSION.SDK_INT < 31) { + return false; + } + PackageManager pm = context.getPackageManager(); + // FEATURE_UWB rather than trying to create a UwbManager: creating one + // on a device without the radio throws, and a capability query must + // not. + return pm != null && pm.hasSystemFeature("android.hardware.uwb"); + } + + public int getRangingAvailability() { + return isRangingSupported() ? NearbyAvailability.AVAILABLE.ordinal() + : NearbyAvailability.NOT_SUPPORTED.ordinal(); + } + + public int getRangingCapabilities() { + if (!isRangingSupported()) { + return 0; + } + // Reported without opening a session where possible. Where the + // platform will only answer through a session scope, the conservative + // answer is distance alone: claiming direction a device cannot + // produce would have an app draw an arrow that never moves. + int bits = NearbyBridge.CAPABILITY_DISTANCE; + try { + UwbClientSessionScope scope = UwbManagerRx + .clientSessionScopeSingle(managerOrThrow()) + .blockingGet(); + RangingCapabilities caps = scope.getRangingCapabilities(); + if (caps.isAzimuthalAngleSupported()) { + bits |= NearbyBridge.CAPABILITY_DIRECTION; + } + if (caps.isElevationAngleSupported()) { + bits |= NearbyBridge.CAPABILITY_ELEVATION; + } + if (caps.isBackgroundRangingSupported()) { + bits |= NearbyBridge.CAPABILITY_BACKGROUND; + } + } catch (Throwable t) { + // Left at distance-only. + } + // An accessory is ranged here by joining the session it names, which + // is the same code path as a peer -- so if ranging works at all, + // accessory ranging does. + bits |= NearbyBridge.CAPABILITY_ACCESSORY; + return bits; + } + + public boolean isCompanionSupported() { + return false; + } + + public boolean isTransportSupported() { + return false; + } + + public int getCompanionAvailability() { + return NearbyAvailability.NOT_SUPPORTED.ordinal(); + } + + public int getTransportAvailability() { + return NearbyAvailability.NOT_SUPPORTED.ordinal(); + } + + public void requestPermissions(int requestId, int permissionBits) { + boolean granted = true; + if ((permissionBits & NearbyBridge.PERMISSION_RANGING) != 0 + && Build.VERSION.SDK_INT >= 31) { + granted = context.checkSelfPermission( + "android.permission.UWB_RANGING") + == PackageManager.PERMISSION_GRANTED; + } + Ranging.deliverPermissionResult(requestId, granted); + } + + // ------------------------------------------------------------------ + // Sessions + // ------------------------------------------------------------------ + + public void prepareRangingSession(final int requestId, + final int sessionHandle, final boolean controller) { + if (!isRangingSupported()) { + fail(requestId, NearbyError.NOT_SUPPORTED, + "this device has no ultra-wideband radio"); + return; + } + try { + Session session = new Session(sessionHandle, controller); + if (controller) { + UwbControllerSessionScope scope = UwbManagerRx + .controllerSessionScopeSingle(managerOrThrow()) + .blockingGet(); + session.scope = scope; + session.channel = scope.getUwbComplexChannel(); + } else { + UwbControleeSessionScope scope = UwbManagerRx + .controleeSessionScopeSingle(managerOrThrow()) + .blockingGet(); + session.scope = scope; + } + session.localAddress = session.scope.getLocalAddress(); + session.sessionId = random.nextInt(Integer.MAX_VALUE - 1) + 1; + session.sessionKey = new byte[8]; + random.nextBytes(session.sessionKey); + sessions.put(Integer.valueOf(sessionHandle), session); + Ranging.deliverSessionPrepared(requestId, sessionHandle, controller, + RangingToken.PLATFORM_ANDROID_UWB, session.token()); + } catch (Throwable t) { + fail(requestId, NearbyError.SESSION_FAILED, message(t)); + } + } + + public void startRanging(final int requestId, final int sessionHandle, + byte[] peerToken) { + final Session session = sessions.get(Integer.valueOf(sessionHandle)); + if (session == null) { + fail(requestId, NearbyError.SESSION_INVALIDATED, "no such session"); + return; + } + Peer peer; + try { + peer = Peer.decode(peerToken); + } catch (IllegalArgumentException e) { + fail(requestId, NearbyError.INVALID_TOKEN, e.getMessage()); + return; + } + run(requestId, session, peer); + } + + public void startAccessoryRanging(int requestId, int sessionHandle, + byte[] accessoryData) { + // An accessory on Android is not a protocol, it is a set of session + // parameters the accessory published out of band -- which is exactly + // what a token is. So the two paths are the same one, and the public + // API documents building the token with RangingToken.forUwbAddress. + startRanging(requestId, sessionHandle, accessoryData); + } + + private void run(final int requestId, final Session session, + final Peer peer) { + try { + List peers = new ArrayList(); + peers.add(new UwbDevice(new UwbAddress(peer.address))); + UwbComplexChannel channel = session.controller + ? session.channel + : new UwbComplexChannel(peer.channel, peer.preamble); + int sessionId = session.controller ? session.sessionId + : peer.sessionId; + byte[] key = session.controller ? session.sessionKey + : peer.sessionKey; + RangingParameters params = new RangingParameters( + RangingParameters.CONFIG_UNICAST_DS_TWR, + sessionId, + 0, + key, + null, + channel, + peers, + RangingParameters.RANGING_UPDATE_RATE_AUTOMATIC); + session.subscription = UwbClientSessionScopeRx + .rangingResultsObservable(session.scope, params) + .subscribeOn(Schedulers.io()) + .subscribe(new io.reactivex.rxjava3.functions.Consumer< + RangingResult>() { + public void accept(RangingResult result) { + deliver(session.handle, result); + } + }, new io.reactivex.rxjava3.functions.Consumer< + Throwable>() { + public void accept(Throwable error) { + RangingSession.deliverInvalidated(session.handle, + NearbyError.SESSION_INVALIDATED.ordinal(), + message(error)); + sessions.remove(Integer.valueOf(session.handle)); + } + }); + Ranging.deliverSessionStarted(requestId, session.handle); + } catch (Throwable t) { + fail(requestId, NearbyError.SESSION_FAILED, message(t)); + } + } + + private static void deliver(int handle, RangingResult result) { + if (result instanceof RangingResult.RangingResultPeerDisconnected) { + RangingSession.deliverPeerRemoved(handle, + RangingRemovalReason.TIMEOUT.ordinal()); + return; + } + if (!(result instanceof RangingResult.RangingResultPosition)) { + return; + } + RangingPosition position = + ((RangingResult.RangingResultPosition) result).getPosition(); + RangingMeasurement distance = position.getDistance(); + RangingMeasurement azimuth = position.getAzimuth(); + RangingMeasurement elevation = position.getElevation(); + RangingSession.deliverUpdate(handle, + distance != null, distance == null ? 0 : distance.getValue(), + azimuth != null, azimuth == null ? 0 : azimuth.getValue(), + elevation != null, elevation == null ? 0 + : elevation.getValue(), + // No vector: Android reports the angles and never the unit + // vector iOS produces, and synthesising one from two angles + // would invent a precision the platform did not report. + null); + } + + public void stopRangingSession(int sessionHandle) { + Session session = sessions.remove(Integer.valueOf(sessionHandle)); + if (session != null && session.subscription != null) { + session.subscription.dispose(); + } + } + + // ------------------------------------------------------------------ + // Unused halves + // ------------------------------------------------------------------ + + public void associate(int requestId, int profile, boolean singleDevice, + String[] filters) { + } + + public String[] getAssociations() { + return new String[0]; + } + + public void disassociate(int requestId, String associationId) { + } + + public boolean startObservingPresence(String associationId) { + return false; + } + + public void stopObservingPresence(String associationId) { + } + + public int getMaxPayloadSize() { + return 0; + } + + public void startAdvertising(int requestId, String serviceId, + String localName, int strategy) { + } + + public void stopAdvertising() { + } + + public void startDiscovery(int requestId, String serviceId, int strategy) { + } + + public void stopDiscovery() { + } + + public void requestConnection(int requestId, String endpointId, + String localName) { + } + + public void acceptConnection(int requestId, String endpointId) { + } + + public void rejectConnection(String endpointId) { + } + + public void sendPayload(int requestId, String[] endpointIds, int payloadId, + int payloadType, byte[] bytes, String path) { + } + + public void cancelPayload(int payloadId) { + } + + public void disconnect(String endpointId) { + } + + public void stopAllTransport() { + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private UwbManager managerOrThrow() { + if (manager == null) { + manager = UwbManager.Companion.createInstance(context); + } + return manager; + } + + private static void fail(int requestId, NearbyError error, String message) { + Ranging.deliverRequestFailed(requestId, error.ordinal(), message); + } + + private static String message(Throwable t) { + return t == null ? null + : (t.getMessage() != null ? t.getMessage() + : t.getClass().getName()); + } + + private static final class Session { + private final int handle; + private final boolean controller; + private UwbClientSessionScope scope; + private UwbAddress localAddress; + private UwbComplexChannel channel; + private int sessionId; + private byte[] sessionKey; + private Disposable subscription; + + private Session(int handle, boolean controller) { + this.handle = handle; + this.controller = controller; + } + + /// The payload half of the token, without the framing + /// `RangingToken.forPayload` adds. + private byte[] token() { + byte[] address = localAddress.getAddress(); + int channelNumber = channel == null ? DEFAULT_CHANNEL + : channel.getChannel(); + int preamble = channel == null ? DEFAULT_PREAMBLE + : channel.getPreambleIndex(); + byte[] out = new byte[4 + address.length + 12 + 4 + + sessionKey.length]; + int p = writeInt(out, 0, address.length); + System.arraycopy(address, 0, out, p, address.length); + p += address.length; + p = writeInt(out, p, channelNumber); + p = writeInt(out, p, preamble); + p = writeInt(out, p, sessionId); + p = writeInt(out, p, sessionKey.length); + System.arraycopy(sessionKey, 0, out, p, sessionKey.length); + return out; + } + } + + /// The decoded far side of a token. + private static final class Peer { + private byte[] address; + private int channel; + private int preamble; + private int sessionId; + private byte[] sessionKey; + + private static Peer decode(byte[] framed) { + if (framed == null || framed.length < 10 + || framed[0] != 'C' || framed[1] != 'N' || framed[2] != '1' + || framed[3] != 'R') { + throw new IllegalArgumentException( + "the peer token is not a Codename One token"); + } + if ((framed[5] & 0xff) != RangingToken.PLATFORM_ANDROID_UWB) { + throw new IllegalArgumentException( + "this token was minted by another platform"); + } + int length = readInt(framed, 6); + if (length < 0 || 10 + length > framed.length) { + throw new IllegalArgumentException("truncated ranging token"); + } + Peer peer = new Peer(); + int p = 10; + int addressLength = readInt(framed, p); + p += 4; + if (addressLength < 0 || p + addressLength > framed.length) { + throw new IllegalArgumentException("truncated ranging token"); + } + peer.address = new byte[addressLength]; + System.arraycopy(framed, p, peer.address, 0, addressLength); + p += addressLength; + peer.channel = readInt(framed, p); + p += 4; + peer.preamble = readInt(framed, p); + p += 4; + peer.sessionId = readInt(framed, p); + p += 4; + int keyLength = readInt(framed, p); + p += 4; + if (keyLength < 0 || p + keyLength > framed.length) { + throw new IllegalArgumentException("truncated ranging token"); + } + peer.sessionKey = new byte[keyLength]; + System.arraycopy(framed, p, peer.sessionKey, 0, keyLength); + return peer; + } + } + + private static int writeInt(byte[] b, int p, int v) { + b[p] = (byte) ((v >> 24) & 0xff); + b[p + 1] = (byte) ((v >> 16) & 0xff); + b[p + 2] = (byte) ((v >> 8) & 0xff); + b[p + 3] = (byte) (v & 0xff); + return p + 4; + } + + private static int readInt(byte[] b, int p) { + return ((b[p] & 0xff) << 24) | ((b[p + 1] & 0xff) << 16) + | ((b[p + 2] & 0xff) << 8) | (b[p + 3] & 0xff); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java new file mode 100644 index 00000000000..43e47dba9be --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -0,0 +1,113 @@ +/* + * 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.impl.android.nearby; + +import android.annotation.SuppressLint; +import android.companion.AssociationInfo; +import android.companion.CompanionDeviceService; +import android.os.Build; + +import com.codename1.nearby.companion.CompanionDevices; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/// The service the platform wakes when an associated device comes into or +/// goes out of range. +/// +/// This is what makes companion association worth using: the OS runs the +/// watching, and it may start this process to deliver the event, so an app +/// that registered a `PresenceListener` in `init()` hears about a device that +/// appeared while the app was not running. +/// +/// The builder writes the `` element that binds this, guarded by +/// `android.permission.BIND_COMPANION_DEVICE_SERVICE` and the +/// `CompanionDeviceService` intent filter, only for an app that observes +/// presence. +@SuppressLint("NewApi") +public class CN1CompanionDeviceService extends CompanionDeviceService { + + private static final Set OBSERVED = + Collections.synchronizedSet(new HashSet()); + + /// Records that the app asked to watch an association, so an event for + /// one it stopped watching is dropped rather than delivered. + /// + /// #### Parameters + /// + /// - `associationId`: the association being watched + public static void register(String associationId) { + if (associationId != null) { + OBSERVED.add(associationId); + } + } + + /// Forgets an association. + /// + /// #### Parameters + /// + /// - `associationId`: the association no longer watched + public static void unregister(String associationId) { + if (associationId != null) { + OBSERVED.remove(associationId); + } + } + + @Override + public void onDeviceAppeared(AssociationInfo associationInfo) { + deliver(associationInfo, true); + } + + @Override + public void onDeviceDisappeared(AssociationInfo associationInfo) { + deliver(associationInfo, false); + } + + private void deliver(AssociationInfo info, boolean present) { + if (info == null || Build.VERSION.SDK_INT < 31) { + return; + } + String mac = info.getDeviceMacAddressAsString(); + String id = mac != null ? mac : Integer.toString(info.getId()); + if (!OBSERVED.isEmpty() && !OBSERVED.contains(id)) { + // The platform keeps watching until told otherwise, and it + // outlives the process. An event for an association the app has + // since stopped watching is not the app's business. + return; + } + CharSequence name = info.getDisplayName(); + String encoded = sanitize(id) + '\t' + + sanitize(name == null ? "" : name.toString()) + '\t' + + sanitize(mac == null ? "" : mac) + "\t0\t" + + (present ? '1' : '0'); + CompanionDevices.deliverPresenceChanged(encoded, present); + } + + private static String sanitize(String s) { + if (s == null) { + return ""; + } + return s.replace('\t', ' ').replace('\n', ' ').replace('\r', ' '); + } +} diff --git a/maven/android/pom.xml b/maven/android/pom.xml index 8e1850b5f77..f00f95a88e1 100644 --- a/maven/android/pom.xml +++ b/maven/android/pom.xml @@ -100,6 +100,17 @@ dependency and deletes the package for everyone else. --> com/codename1/impl/android/cipher/** + + com/codename1/impl/android/nearby/** From cdd0f3b0284742e74a92d11812e6d4a3ebc09e7e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:42:52 +0300 Subject: [PATCH 04/94] Nearby devices: the builders, so referencing a package is the whole opt-in Turns a class reference into the native machinery it needs, on both platforms. An app that never names com.codename1.nearby is untouched. Three catalog entries rather than one, because the three packages cost three different things and the scanner matches on a prefix with no way to express an exclusion -- so the package boundary is the only opt-in a developer performs. Ranging links NearbyInteraction and adds androidx.core.uwb; transport links MultipeerConnectivity and turns on Nearby Connections; companion links AccessorySetupKit. An app that only wants to know how far away its tag is does not get a local-network prompt it cannot explain. The Android permissions are NOT in that table, and cannot be. UWB_RANGING exists only from API 31, the transport needs the Android 12 Bluetooth split with maxSdkVersion caps, and NEARBY_WIFI_DEVICES needs usesPermissionFlags from 33. NearbyManifestFragments owns all of it, following BluetoothManifestFragments down to the quote-delimited duplicate suppression -- which matters more here than there, because an app using both com.codename1.bluetooth and com.codename1.nearby.transport runs both injectors over one string. There is a test for exactly that collision. Ranging's minSdk is 23, not 31. androidx.core.uwb runs down to 23 and reports the feature absent below 31, so raising the whole app to 31 would have cost far more than the feature is worth. This corrects a claim I made before reading the AAR's own manifest. Presence observation is tracked separately from association, and it is a method call rather than a class reference -- an app that associates a device and one that also asks the platform to watch for it name exactly the same classes. Only the second earns the background companion permissions and the CompanionDeviceService element, because background privileges an app never uses are privileges a user is asked about for nothing. Two things the scanner cannot see, both handled by naming them rather than guessing. The device profile arrives as an enum constant, which is a field reference, and Executor.visitFieldInsn is an empty override -- so REQUEST_COMPANION_PROFILE_WATCH comes from android.nearby.watchProfile, defaulted off. And com.apple.developer.nearby-interaction is injected only when ios.nearby.background asks for it: an entitlement the App ID does not carry fails codesigning with an error naming the entitlement and not the reason it appeared, which is the trap com.apple.developer.homekit already sets. NSBonjourServices is derived through the same fold CN1Nearby.m applies at runtime, because iOS refuses a browse whose registered service type the plist did not declare and the refusal is a silent "no peers found". Both halves are now covered by one test, and the builder logs the derived type every time -- com.example.chat and com.example.charts both fold to com-example-cha, and two apps sharing a service type would discover each other's peers. WatchNativeBuilder's guard test earned its keep: it failed on this change and named the three frameworks it did not know how to classify. NearbyInteraction is present on watchOS and is still weak-linked there, because the watch slice undoes CN1_NEARBY_RANGING and never calls into it. 903 plugin tests and 37 catalog tests pass, and SpotBugs reports zero findings -- including one it found in this change, an indexOf(...) > 0 that happened to work only because both method names it matched start with a prefix. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 110 ++++++++ .../com/codename1/builders/IPhoneBuilder.java | 223 ++++++++++++++++ .../builders/NearbyManifestFragments.java | 210 +++++++++++++++ .../builders/WatchNativeBuilder.java | 11 + .../NearbyBonjourServiceTypeTest.java | 144 +++++++++++ .../builders/NearbyManifestFragmentsTest.java | 242 ++++++++++++++++++ .../build/shared/PlatformFeatureCatalog.java | 64 +++++ .../shared/PlatformFeatureCatalogTest.java | 90 +++++++ 8 files changed, 1094 insertions(+) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java 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 81807b0973b..7d4509871ee 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 @@ -647,6 +647,18 @@ static List readMediaPermissionNames(boolean blocked, private boolean usesHomeCommissioning; private String smartHomeQueriesFragment = ""; + // Nearby devices (com.codename1.nearby.*). Three flags rather than one, + // because the three packages cost three different dependency and + // permission sets and the package prefix is the only opt-in a developer + // performs. usesNearbyPresence is separate again: presence observation is + // what earns the background companion permissions, and asking for those + // without it is asking a user for background privileges with nothing to + // show for them. + private boolean usesNearbyRanging; + private boolean usesNearbyTransport; + private boolean usesNearbyCompanion; + private boolean usesNearbyPresence; + private boolean integrateMoPub = false; private static final boolean isMac; @@ -2058,6 +2070,15 @@ public void usesClass(String cls) { usesHealthWrite = true; } } + if (cls.indexOf("com/codename1/nearby/ranging/") == 0) { + usesNearbyRanging = true; + } + if (cls.indexOf("com/codename1/nearby/transport/") == 0) { + usesNearbyTransport = true; + } + if (cls.indexOf("com/codename1/nearby/companion/") == 0) { + usesNearbyCompanion = true; + } if (cls.indexOf("com/codename1/bluetooth/") == 0) { usesBluetooth = true; if (cls.indexOf("com/codename1/bluetooth/le/server/") == 0) { @@ -2136,6 +2157,17 @@ public void usesClassMethod(String cls, String method) { // cannot see it -- and without this the build skipped the // type validation and shipped a manifest with no per-type // permissions, leaving those calls unauthorized. + // Presence observation is a method call, not a class + // reference: an app that associates a device and one that + // also asks the platform to watch for it name exactly the + // same classes. Only the call tells them apart, and only + // the second should carry the background permissions. + if ("com/codename1/nearby/companion/CompanionDevices" + .equals(cls) + && method.contains("ObservingPresence")) { + usesNearbyCompanion = true; + usesNearbyPresence = true; + } if (cls.indexOf("com/codename1/health/HealthStore") == 0) { usesHealth = true; usesHealthStore = true; @@ -2528,6 +2560,43 @@ public void usesClassMethod(String cls, String method) { neverForLocation, bleRequired, targetSDKVersionInt); } + // Nearby devices (com.codename1.nearby.*). The permissions live in + // NearbyManifestFragments rather than in PlatformFeatureCatalog + // because they are version-conditional in three different ways -- + // UWB_RANGING is API 31 and later, the transport needs the Android 12 + // Bluetooth split with maxSdkVersion caps, and NEARBY_WIFI_DEVICES + // needs usesPermissionFlags from 33 -- and a flat list cannot say any + // of that. + // + // android.nearby.watchProfile is a hint rather than something the + // scanner works out: the profile arrives as an enum constant, which + // is a field reference, and Executor.visitFieldInsn is an empty + // override. Defaulted false because REQUEST_COMPANION_PROFILE_WATCH + // is a strong permission to ask for on a guess. + if (usesNearbyRanging || usesNearbyTransport || usesNearbyCompanion) { + boolean watchProfile = "true".equalsIgnoreCase( + request.getArg("android.nearby.watchProfile", "false")); + log("Nearby fragments version " + + NearbyManifestFragments.FRAGMENT_VERSION + + (usesNearbyRanging ? " ranging" : "") + + (usesNearbyTransport ? " transport" : "") + + (usesNearbyCompanion ? " companion" : "") + + (usesNearbyPresence ? " presence" : "")); + xPermissions = NearbyManifestFragments.inject(xPermissions, + usesNearbyRanging, usesNearbyTransport, + usesNearbyCompanion, usesNearbyPresence, watchProfile, + targetSDKVersionInt); + String presenceService = + NearbyManifestFragments.presenceService(usesNearbyPresence); + if (presenceService.length() > 0 + && !request.getArg("android.xapplication", "") + .contains("CN1CompanionDeviceService")) { + request.putArgument("android.xapplication", + request.getArg("android.xapplication", "") + + presenceService); + } + } + // Smart home (com.codename1.home.*). // // Deliberately no permissions. Play services runs the entire @@ -3015,6 +3084,15 @@ public void usesClassMethod(String cls, String method) { } playServicesVision = !request.getArg("android.playService.vision", "false").equals("false"); playServicesNearBy = !request.getArg("android.playService.nearby", "false").equals("false"); + // The nearby transport IS Nearby Connections, so referencing + // com.codename1.nearby.transport turns the same Play service on. + // Routed through this flag rather than through a PlatformFeatureCatalog + // androidGradle entry so the artifact keeps the version the builder's + // own Play-services table decides, instead of one pinned in a table + // that has no idea which Play services this build resolved. + if (usesNearbyTransport) { + playServicesNearBy = true; + } playServicesSafetyPanorama = !request.getArg("android.playService.panorama", "false").equals("false"); playServicesGames = !request.getArg("android.playService.games", "false").equals("false"); playServicesSafetyNet = !request.getArg("android.playService.safetynet", "false").equals("false"); @@ -3668,6 +3746,38 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { fb.delete(); } + // The nearby package compiles against a modern SDK plus, for two of + // its three files, gradle dependencies that only exist when the + // matching catalog entry matched. Each is deleted on its own rather + // than the package as a whole, so an app that uses one half keeps it + // and loses the other -- AndroidNearbyBackend reaches both + // reflectively and treats a missing one as unsupported. + File nearbyPackage = new File(srcDir, + "com/codename1/impl/android/nearby"); + if (!usesNearbyRanging && !usesNearbyTransport + && !usesNearbyCompanion) { + File[] nearbyFiles = nearbyPackage.listFiles(); + if (nearbyFiles != null) { + for (File f : nearbyFiles) { + f.delete(); + } + } + nearbyPackage.delete(); + } else { + if (!usesNearbyRanging) { + new File(nearbyPackage, "AndroidUwbRanging.java").delete(); + } + if (!usesNearbyTransport) { + new File(nearbyPackage, "AndroidNearbyTransport.java").delete(); + } + if (!usesNearbyPresence) { + // Nothing binds it, and it extends an API 31 class; leaving it + // costs a compile against a service the manifest never names. + new File(nearbyPackage, + "CN1CompanionDeviceService.java").delete(); + } + } + if (!arSupport) { // The ARCore-backed impl package compiles against com.google.ar // classes that only exist when the AR gradle dependency is added, 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 ce3318f169d..27d8e959504 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 @@ -213,6 +213,122 @@ private int reservedApplicationQueriesSchemes(BuildRequest request) { /// without looking at the injected fragment, so writing the hint as well would put /// the key in the plist twice -- and a plist with a duplicate key is not a plist that /// reliably keeps either value. + /// Uncomments one of the `CN1_NEARBY_*` defines in the shared header. + /// + /// Fails the build rather than warning when the marker is not there: a + /// define that silently stayed commented out produces an app whose nearby + /// API reports itself unsupported on a device that supports it, and + /// nothing in the build log would say why. + /// + /// #### Parameters + /// + /// - `buildinRes`: the directory holding CodenameOne_GLViewController.h + /// - `name`: the define to enable + private void enableNearbyDefine(File buildinRes, String name) + throws BuildException { + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define " + name, "#define " + name); + } catch (IOException ex) { + throw new BuildException("Failed to enable " + name, ex); + } + } + + /// The Bonjour service type MultipeerConnectivity will register under. + /// + /// Derived from `ios.nearby.serviceType` when the developer set one, and + /// otherwise from the package name -- and folded through the same rule + /// `cn1nbServiceType` in CN1Nearby.m applies, because the value declared + /// in the plist has to be the value the runtime registers or iOS refuses + /// the browse. MultipeerConnectivity allows 1 to 15 characters of + /// lowercase ASCII letters, digits and non-adjacent hyphens, and raises + /// on anything else. + /// + /// #### Parameters + /// + /// - `request`: the build request + /// + /// #### Returns + /// + /// a legal service type, never null or empty + static String bonjourServiceType(BuildRequest request) { + String declared = request.getArg("ios.nearby.serviceType", null); + String source = declared != null && declared.trim().length() > 0 + ? declared.trim() : request.getPackageName(); + if (source == null) { + source = ""; + } + StringBuilder out = new StringBuilder(); + String lower = source.toLowerCase(); + for (int i = 0; i < lower.length() && out.length() < 15; i++) { + char c = lower.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + out.append(c); + } else if (out.length() > 0 + && out.charAt(out.length() - 1) != '-') { + out.append('-'); + } + } + while (out.length() > 0 && out.charAt(out.length() - 1) == '-') { + out.setLength(out.length() - 1); + } + return out.length() == 0 ? "cn1-nearby" : out.toString(); + } + + /// Escapes the three characters that cannot sit in plist text content. + /// + /// Small and local rather than borrowed: the neighbouring builders each + /// keep a private one, and a service UUID from a build hint is arbitrary + /// text until something says otherwise. + /// + /// #### Parameters + /// + /// - `value`: the text + /// + /// #### Returns + /// + /// the escaped text + private static String escapeNearbyPlistText(String value) { + return value.replace("&", "&").replace("<", "<") + .replace(">", ">"); + } + + /// Appends a string array to `ios.plistInject`, unless the project already + /// declares that key. + /// + /// A project that set the key itself is left alone and told so, for the + /// reason [#declareApplicationQueriesSchemes] gives: a plist with the same + /// key twice is not a plist that reliably keeps either value. + /// + /// #### Parameters + /// + /// - `request`: the build request + /// - `key`: the plist key + /// - `values`: the array entries + /// - `why`: what the app loses if the entries are absent, for the log + private void declareNearbyPlistArray(BuildRequest request, String key, + String[] values, String why) { + String inject = request.getArg("ios.plistInject", ""); + if (WatchNativeBuilder.injectedPlistKeys(inject).contains(key)) { + log("ios.plistInject already declares " + key + ", so the nearby" + + " entries were not added for you -- " + why + "."); + return; + } + StringBuilder b = new StringBuilder(inject); + b.append("").append(key).append(""); + for (int i = 0; i < values.length; i++) { + String v = values[i] == null ? "" : values[i].trim(); + if (v.length() == 0) { + continue; + } + b.append("").append(escapeNearbyPlistText(v)) + .append(""); + } + b.append(""); + request.putArgument("ios.plistInject", b.toString()); + } + private void declareApplicationQueriesSchemes(BuildRequest request, String[] schemes, String why) { java.util.List alreadyInjected = @@ -400,6 +516,15 @@ private static boolean healthCapabilityRequested(BuildRequest request, String al private boolean usesCn1Camera; private boolean usesCn1Ar; + // Nearby devices (com.codename1.nearby.*). Three flags because the three + // packages link three different frameworks and oblige three different + // privacy strings: an app that only ranges must not link + // MultipeerConnectivity, because linking it obliges + // NSLocalNetworkUsageDescription and puts a local-network prompt in front + // of a user who never asked for one. + private boolean usesNearbyRanging; + private boolean usesNearbyTransport; + private boolean usesNearbyCompanion; private boolean usesCn1Vision; private boolean usesCn1Language; private boolean usesCn1Inference; @@ -1544,6 +1669,15 @@ public void usesClass(String cls) { // Augmented reality (com.codename1.ar.*). Gated on actual // usage so ARKit/SceneKit and the CN1AR natives are only // built for apps that reference the AR API. + if (cls.indexOf("com/codename1/nearby/ranging/") == 0) { + usesNearbyRanging = true; + } + if (cls.indexOf("com/codename1/nearby/transport/") == 0) { + usesNearbyTransport = true; + } + if (cls.indexOf("com/codename1/nearby/companion/") == 0) { + usesNearbyCompanion = true; + } if (!usesCn1Ar && cls.indexOf("com/codename1/ar/") == 0) { usesCn1Ar = true; } @@ -4189,6 +4323,95 @@ public void usesClassMethod(String cls, String method) { } } + // Nearby devices: uncomment CN1_INCLUDE_NEARBY and whichever of + // the three sub-defines the app earned, so CN1Nearby.m compiles + // in only the halves it asked for. The frameworks themselves come + // from the PlatformFeatureCatalog entries through the loop below; + // only the defines are decided here, because a define is not + // something a declarative table can express. + if (usesNearbyRanging || usesNearbyTransport + || usesNearbyCompanion) { + enableNearbyDefine(buildinRes, "CN1_INCLUDE_NEARBY"); + if (usesNearbyRanging) { + enableNearbyDefine(buildinRes, "CN1_NEARBY_RANGING"); + // Background ranging needs an entitlement Apple grants on + // the App ID. Injected only when the developer asks for + // it, because an entitlement the App ID does not carry + // fails codesigning with an error that names the + // entitlement and not the reason it appeared -- the same + // trap com.apple.developer.homekit sets, handled the same + // way. RangingCapabilities.isBackgroundRangingSupported() + // reports false regardless, so nothing promises it. + if ("true".equalsIgnoreCase(request.getArg( + "ios.nearby.background", "false"))) { + request.putArgument("ios.entitlements.com.apple" + + ".developer.nearby-interaction", "true"); + String modes = request.getArg("ios.background_modes", + ""); + if (!modes.contains("nearby-interaction")) { + request.putArgument("ios.background_modes", + modes.length() == 0 + ? "nearby-interaction" + : modes + ",nearby-interaction"); + } + } + } + if (usesNearbyTransport) { + enableNearbyDefine(buildinRes, "CN1_NEARBY_TRANSPORT"); + // iOS 14 refuses a MultipeerConnectivity browse whose + // Bonjour service types are not declared, and the refusal + // is a silent "no peers found" rather than an error. The + // service type is derived from the same id the app passes + // to startAdvertising, folded the way CN1Nearby.m folds + // it, so the two agree. + String serviceType = bonjourServiceType(request); + // Logged always, because the fold is lossy and silently + // so: com.example.chat and com.example.charts both become + // com-example-cha, and two apps sharing a service type + // discover each other's peers. A developer who sees this + // line can set ios.nearby.serviceType and stop guessing. + log("Nearby transport registers Bonjour service type _" + + serviceType + "._tcp / ._udp" + + (request.getArg("ios.nearby.serviceType", "") + .trim().length() > 0 + ? "" : " (derived from the package name;" + + " set ios.nearby.serviceType to" + + " choose it yourself)")); + declareNearbyPlistArray(request, "NSBonjourServices", + new String[] { + "_" + serviceType + "._tcp", + "_" + serviceType + "._udp" + }, + "MultipeerConnectivity cannot browse without it"); + } + if (usesNearbyCompanion) { + enableNearbyDefine(buildinRes, "CN1_NEARBY_COMPANION"); + declareNearbyPlistArray(request, + "NSAccessorySetupKitSupports", + new String[] {"Bluetooth"}, + "AccessorySetupKit will not show a picker" + + " without it"); + String services = request.getArg( + "ios.nearby.accessoryServices", "").trim(); + if (services.length() > 0) { + declareNearbyPlistArray(request, + "NSAccessorySetupBluetoothServices", + services.split("\\s*,\\s*"), + "AccessorySetupKit only discovers services" + + " the app declared up front"); + } else { + log("com.codename1.nearby.companion is used but" + + " ios.nearby.accessoryServices is not set." + + " AccessorySetupKit only discovers" + + " Bluetooth services listed in" + + " NSAccessorySetupBluetoothServices, so the" + + " picker will find nothing on iOS. Set the" + + " hint to a comma-separated list of service" + + " UUIDs."); + } + } + } + for (String framework : aiAcc.iosFrameworks()) { addLibs = appendFrameworks(addLibs, framework + ".framework"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java new file mode 100644 index 00000000000..e13911f405d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -0,0 +1,210 @@ +/* + * 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.builders; + +/** + * Builds the AndroidManifest permission, feature and service fragments + * injected when the bytecode scanner detects usage of the + * {@code com.codename1.nearby} packages. + * + *

Extracted into a pure static helper for the reasons + * {@link BluetoothManifestFragments} gives: the version-conditional nuances + * are unit-testable here, and the BuildDaemon copy of this class stays + * trivially diffable -- keep this file in sync with + * {@code com.codename1.build.daemon.NearbyManifestFragments}.

+ * + *

Why any of this is here rather than in {@code PlatformFeatureCatalog}: + * the catalog can name a permission but not qualify it. {@code UWB_RANGING} + * exists only from API 31, the transport needs the Android 12 Bluetooth split + * with {@code maxSdkVersion} caps, and {@code NEARBY_WIFI_DEVICES} needs + * {@code usesPermissionFlags="neverForLocation"} from API 33. None of those + * fit a flat list.

+ * + *

Duplicate suppression uses quote-delimited tokens + * ({@code "android.permission.BLUETOOTH\""}) rather than plain substring + * checks, for the reason the Bluetooth version documents: {@code + * BLUETOOTH_SCAN} contains {@code BLUETOOTH}, so a loose check would wrongly + * skip the legacy permission when the new one is present. This matters more + * here than there, because an app that uses both {@code + * com.codename1.bluetooth} and {@code com.codename1.nearby.transport} runs + * both injectors over the same string.

+ */ +final class NearbyManifestFragments { + + /** + * Bumped when the fragments change, so a build log names which version + * produced a manifest. + */ + static final int FRAGMENT_VERSION = 1; + + private NearbyManifestFragments() { + } + + /** + * Returns {@code xPermissions} with the nearby fragments prepended. + * + * @param xPermissions the current accumulated manifest fragment + * @param ranging {@code com.codename1.nearby.ranging} usage + * detected + * @param transport {@code com.codename1.nearby.transport} usage + * detected + * @param companion {@code com.codename1.nearby.companion} usage + * detected + * @param presence presence observation detected, which is what + * earns the background and foreground-service + * companion permissions + * @param watchProfile the app asks to associate a watch, which is the + * one device profile with a permission of its own + * @param targetSdkVersion the build's target SDK level + * @return the fragment with the nearby entries prepended + */ + static String inject(String xPermissions, boolean ranging, + boolean transport, boolean companion, boolean presence, + boolean watchProfile, int targetSdkVersion) { + String out = xPermissions == null ? "" : xPermissions; + boolean modern = targetSdkVersion >= 31; + boolean tiramisu = targetSdkVersion >= 33; + + if (ranging) { + // API 31 and later only. Declaring it below that is harmless but + // noisy, and an unknown permission in a manifest is the kind of + // thing a store review flags and a developer then has to explain. + if (modern) { + out = addPermission(out, "android.permission.UWB_RANGING", ""); + } + out = addFeature(out, "android.hardware.uwb", false); + } + + if (transport) { + // Nearby Connections drives Bluetooth, BLE and Wi-Fi and needs + // all of them. The legacy pair is capped at 30 because the + // Android 12 split replaces them; the new trio only exists from + // 31, so both halves are present and each is bounded. + String legacyCap = modern ? " android:maxSdkVersion=\"30\"" : ""; + out = addPermission(out, "android.permission.BLUETOOTH", + legacyCap); + out = addPermission(out, "android.permission.BLUETOOTH_ADMIN", + legacyCap); + if (modern) { + out = addPermission(out, "android.permission.BLUETOOTH_SCAN", + " android:usesPermissionFlags=\"neverForLocation\""); + out = addPermission(out, + "android.permission.BLUETOOTH_ADVERTISE", ""); + out = addPermission(out, "android.permission.BLUETOOTH_CONNECT", + ""); + } + out = addPermission(out, "android.permission.ACCESS_WIFI_STATE", + ""); + out = addPermission(out, "android.permission.CHANGE_WIFI_STATE", + ""); + if (tiramisu) { + out = addPermission(out, + "android.permission.NEARBY_WIFI_DEVICES", + " android:usesPermissionFlags=\"neverForLocation\""); + } + // Nearby Connections genuinely needs a location grant up to API + // 32 -- it is not a scan-results technicality there, the API + // refuses to start without it. Capped so 33 and later use + // NEARBY_WIFI_DEVICES instead and the app stops asking for + // location it does not use. + out = addPermission(out, "android.permission.ACCESS_FINE_LOCATION", + tiramisu ? " android:maxSdkVersion=\"32\"" : ""); + } + + if (companion) { + out = addFeature(out, "android.software.companion_device_setup", + false); + if (presence) { + // Only for an app that observes presence. These are what let + // the platform wake the app for a device it saw, and asking + // for them without that is asking for background privileges + // with no reason to show a user. + out = addPermission(out, + "android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND", + ""); + out = addPermission(out, + "android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND", + ""); + if (tiramisu) { + out = addPermission(out, "android.permission" + + ".REQUEST_COMPANION_START_FOREGROUND_SERVICES" + + "_FROM_BACKGROUND", ""); + } + } + if (modern && watchProfile) { + out = addPermission(out, + "android.permission.REQUEST_COMPANION_PROFILE_WATCH", + ""); + } + } + return out; + } + + /** + * The {@code } element that binds + * {@code CN1CompanionDeviceService}, or the empty string when the app + * never observes presence. + * + *

Goes into {@code android.xapplication} rather than + * {@code android.xpermissions}: it is an application child, not a + * manifest one.

+ * + * @param presence presence observation detected + * @return the element, or {@code ""} + */ + static String presenceService(boolean presence) { + if (!presence) { + return ""; + } + return " \n" + + " \n" + + " \n" + + " \n" + + "
\n"; + } + + 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/WatchNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java index e7e548e9e51..b2dc6b08322 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -131,6 +131,17 @@ class WatchNativeBuilder { // ARKit and SceneKit are absent on watchOS; they are linked on the iOS slice when the // app references com.codename1.ar, so weak-link them for the watch slice. + "ARKit.framework;SceneKit.framework;" + // The three com.codename1.nearby frameworks, linked on the iOS slice when the app + // references the matching package. + // + // MultipeerConnectivity and AccessorySetupKit are simply absent on watchOS. Nearby + // Interaction is PRESENT there and is still weak-linked, because the watch slice + // never calls into it: CodenameOne_GLViewController.h undoes CN1_NEARBY_RANGING for + // TARGET_OS_WATCH, so CN1Nearby.m compiles to its unsupported stubs on the watch and + // Ranging.isSupported() answers false. Linking a framework nothing references is + // merely untidy; leaving one out that something does reference fails the link. + + "NearbyInteraction.framework;MultipeerConnectivity.framework;" + + "AccessorySetupKit.framework;" // The CONDITIONAL ones -- added by IPhoneBuilder's API scan rather than by the // translator, so they appear only in projects that use the feature. That is why they // outlived two rounds of this list: a build that never touches Vision never links it, diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java new file mode 100644 index 00000000000..3252a1a22cb --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java @@ -0,0 +1,144 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The Bonjour service type MultipeerConnectivity registers under. + * + *

This is a parity test in disguise. The same fold is implemented twice -- + * here, to write {@code NSBonjourServices} into the Info.plist, and in + * {@code cn1nbServiceType} in CN1Nearby.m, to register the service at runtime + * -- and iOS refuses a browse whose registered type is not one the plist + * declared. The refusal is a silent "no peers found" rather than an error, so + * a divergence between the two would present as a transport that simply never + * works on iOS with nothing in any log to explain it.

+ * + *

The rule being enforced: 1 to 15 characters, lowercase ASCII letters, + * digits and hyphens, no leading or trailing hyphen and no two adjacent. + * MultipeerConnectivity raises on anything else, which on a device is a crash + * rather than an error an app can show.

+ */ +class NearbyBonjourServiceTypeTest { + + private static BuildRequest request(String packageName, String hint) { + BuildRequest r = new BuildRequest(); + r.setPackageName(packageName); + if (hint != null) { + r.putArgument("ios.nearby.serviceType", hint); + } + return r; + } + + private static void assertLegal(String type) { + assertTrue(type.length() >= 1 && type.length() <= 15, + "1 to 15 characters, got " + type.length() + " in " + type); + assertTrue(type.matches("[a-z0-9-]+"), + "lowercase letters, digits and hyphens only: " + type); + assertTrue(!type.startsWith("-") && !type.endsWith("-"), + "no leading or trailing hyphen: " + type); + assertTrue(type.indexOf("--") < 0, "no adjacent hyphens: " + type); + } + + @Test + void anExplicitHintIsUsedAsGiven() { + assertEquals("chat", IPhoneBuilder.bonjourServiceType( + request("com.example.app", "chat"))); + } + + @Test + void aReverseDnsPackageIsFoldedRatherThanRejected() { + // Legal on Android and illegal here, which is exactly the case a + // cross-platform app hits by writing the obvious thing. + String type = IPhoneBuilder.bonjourServiceType( + request("com.example.chat", null)); + assertLegal(type); + // Sixteen characters folded, fifteen allowed -- so even this + // unremarkable package name is truncated, which is why the builder + // logs the derived type and the guide tells you to set + // ios.nearby.serviceType yourself. + assertEquals("com-example-cha", type); + } + + @Test + void anOverlongPackageIsTruncatedToTheLimit() { + String type = IPhoneBuilder.bonjourServiceType( + request("com.example.someverylongapplicationname", null)); + assertLegal(type); + assertEquals(15, type.length()); + } + + @Test + void aTruncationThatLandsOnAHyphenDoesNotLeaveOne() { + // "ab.cdefghijklm.x" folds to "ab-cdefghijklm-" at fifteen, and a + // trailing hyphen is one of the things that makes the framework raise. + String type = IPhoneBuilder.bonjourServiceType( + request("ab.cdefghijklm.x", null)); + assertLegal(type); + } + + @Test + void runsOfIllegalCharactersCollapseToOneHyphen() { + String type = IPhoneBuilder.bonjourServiceType( + request("com...example___app", null)); + assertLegal(type); + assertEquals("com-example-app", type); + } + + @Test + void uppercaseIsLowered() { + assertEquals("mychat", IPhoneBuilder.bonjourServiceType( + request("com.example.app", "MyChat"))); + } + + @Test + void somethingWithNoUsableCharactersFallsBackRatherThanRaising() { + assertEquals("cn1-nearby", IPhoneBuilder.bonjourServiceType( + request("...", null))); + assertEquals("cn1-nearby", IPhoneBuilder.bonjourServiceType( + request(null, null))); + } + + @Test + void ablankHintFallsBackToThePackageRatherThanToTheDefault() { + assertEquals("com-example-app", IPhoneBuilder.bonjourServiceType( + request("com.example.app", " "))); + } + + @Test + void everyFoldIsLegal() { + String[] inputs = { + "a", "A", "com.example.app", "-leading", "trailing-", + "com.example.a-very-long-name-indeed", "1.2.3", "_", "--", + "MiXeD.CaSe.Name", "x.y", "com.example.APP" + }; + for (String in : inputs) { + assertLegal(IPhoneBuilder.bonjourServiceType(request(in, null))); + assertLegal(IPhoneBuilder.bonjourServiceType(request("p", in))); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java new file mode 100644 index 00000000000..d86d097cd20 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -0,0 +1,242 @@ +/* + * 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.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 manifest fragments injected for the + * {@code com.codename1.nearby} packages. + * + *

Three properties matter here. Each package pays only for itself, because + * the package prefix is the whole opt-in. The version-conditional permissions + * appear on the right side of their boundary, because that is what the flat + * catalog table could not express and the reason this class exists. And + * nothing is declared twice when an app also uses + * {@code com.codename1.bluetooth}, whose injector runs over the same + * string.

+ */ +class NearbyManifestFragmentsTest { + + 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 rangingPaysForRangingOnly() { + String out = NearbyManifestFragments.inject("", true, false, false, + false, false, 34); + assertTrue(out.contains("android.permission.UWB_RANGING")); + assertTrue(out.contains("android:name=\"android.hardware.uwb\"" + + " android:required=\"false\"")); + // Nothing from the other two packages. + assertFalse(out.contains("BLUETOOTH")); + assertFalse(out.contains("NEARBY_WIFI_DEVICES")); + assertFalse(out.contains("companion_device_setup")); + assertFalse(out.contains("REQUEST_COMPANION")); + } + + @Test + void uwbRangingIsNotDeclaredBelowTheApiThatHasIt() { + // The permission arrives in API 31. Declaring it on an older target + // is harmless and noisy, and a store review asks about it. + String out = NearbyManifestFragments.inject("", true, false, false, + false, false, 30); + assertFalse(out.contains("android.permission.UWB_RANGING")); + // The feature is still declared, because that is what keeps the app + // installable on a device without the radio. + assertTrue(out.contains("android.hardware.uwb")); + } + + @Test + void transportCarriesTheAndroid12SplitWithTheLegacyPairCapped() { + String out = NearbyManifestFragments.inject("", false, true, false, + false, false, 34); + 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.BLUETOOTH_SCAN\"" + + " android:usesPermissionFlags=\"neverForLocation\"")); + assertTrue(out.contains("android.permission.BLUETOOTH_ADVERTISE")); + assertTrue(out.contains("android.permission.BLUETOOTH_CONNECT")); + assertTrue(out.contains("android.permission.ACCESS_WIFI_STATE")); + assertTrue(out.contains("android.permission.CHANGE_WIFI_STATE")); + } + + @Test + void transportStopsAskingForLocationOnceNearbyWifiExists() { + String modern = NearbyManifestFragments.inject("", false, true, false, + false, false, 34); + assertTrue(modern.contains( + "android:name=\"android.permission.NEARBY_WIFI_DEVICES\"" + + " android:usesPermissionFlags=\"neverForLocation\"")); + assertTrue(modern.contains( + "android:name=\"android.permission.ACCESS_FINE_LOCATION\"" + + " android:maxSdkVersion=\"32\"")); + + // Below 33 there is no NEARBY_WIFI_DEVICES, and Nearby Connections + // genuinely refuses to start without a location grant -- so it must + // NOT be capped there. + String older = NearbyManifestFragments.inject("", false, true, false, + false, false, 31); + assertFalse(older.contains("NEARBY_WIFI_DEVICES")); + assertTrue(older.contains( + "android:name=\"android.permission.ACCESS_FINE_LOCATION\" />")); + } + + @Test + void transportOnALegacyTargetKeepsTheLegacyPairUncapped() { + String out = NearbyManifestFragments.inject("", false, true, false, + false, false, 30); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH\" />")); + assertFalse(out.contains("BLUETOOTH_SCAN")); + assertFalse(out.contains("BLUETOOTH_ADVERTISE")); + } + + @Test + void associatingWithoutWatchingCostsNoBackgroundPermission() { + String out = NearbyManifestFragments.inject("", false, false, true, + false, false, 34); + assertTrue(out.contains("android.software.companion_device_setup")); + // This is the point of tracking presence separately: background + // privileges an app never uses are privileges a user is asked about + // for nothing. + assertFalse(out.contains("REQUEST_COMPANION_RUN_IN_BACKGROUND")); + assertFalse(out.contains("REQUEST_COMPANION_USE_DATA_IN_BACKGROUND")); + assertFalse(out.contains("REQUEST_COMPANION_PROFILE_WATCH")); + } + + @Test + void watchingEarnsTheBackgroundPermissions() { + String out = NearbyManifestFragments.inject("", false, false, true, + true, false, 34); + assertTrue(out.contains( + "android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND")); + assertTrue(out.contains( + "android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND")); + assertTrue(out.contains("android.permission.REQUEST_COMPANION" + + "_START_FOREGROUND_SERVICES_FROM_BACKGROUND")); + } + + @Test + void theWatchProfilePermissionIsOptInAndModernOnly() { + assertFalse(NearbyManifestFragments.inject("", false, false, true, + false, false, 34) + .contains("REQUEST_COMPANION_PROFILE_WATCH")); + assertTrue(NearbyManifestFragments.inject("", false, false, true, + false, true, 34) + .contains("android.permission.REQUEST_COMPANION_PROFILE_WATCH")); + // The permission arrives with the profiles, in API 31. + assertFalse(NearbyManifestFragments.inject("", false, false, true, + false, true, 30) + .contains("REQUEST_COMPANION_PROFILE_WATCH")); + } + + @Test + void nothingIsDeclaredTwiceWhenBluetoothRanFirst() { + // The realistic collision: an app that uses com.codename1.bluetooth + // AND com.codename1.nearby.transport runs both injectors over one + // string, and both want the same six Bluetooth permissions. + String afterBluetooth = BluetoothManifestFragments.inject("", true, + true, true, false, true, false, 34); + String out = NearbyManifestFragments.inject(afterBluetooth, false, + true, false, false, false, 34); + assertEquals(1, count(out, + "android:name=\"android.permission.BLUETOOTH\"")); + assertEquals(1, count(out, + "android:name=\"android.permission.BLUETOOTH_SCAN\"")); + assertEquals(1, count(out, + "android:name=\"android.permission.BLUETOOTH_ADVERTISE\"")); + assertEquals(1, count(out, + "android:name=\"android.permission.BLUETOOTH_CONNECT\"")); + assertEquals(1, count(out, + "android:name=\"android.permission.ACCESS_FINE_LOCATION\"")); + } + + @Test + void aQuotedTokenIsWhatSuppressesADuplicate() { + // BLUETOOTH is a prefix of BLUETOOTH_SCAN. A substring check would + // see the scan permission and wrongly skip the legacy one. + String seeded = " \n"; + String out = NearbyManifestFragments.inject(seeded, false, true, false, + false, false, 34); + assertEquals(1, count(out, + "android:name=\"android.permission.BLUETOOTH_SCAN\"")); + assertEquals(1, count(out, + "android:name=\"android.permission.BLUETOOTH\"")); + } + + @Test + void aUserDeclaredPermissionIsNotDuplicated() { + String seeded = " \n"; + String out = NearbyManifestFragments.inject(seeded, true, false, false, + false, false, 34); + assertEquals(1, count(out, "android.permission.UWB_RANGING")); + } + + @Test + void theServiceElementOnlyExistsForAnAppThatWatches() { + assertEquals("", NearbyManifestFragments.presenceService(false)); + String service = NearbyManifestFragments.presenceService(true); + assertTrue(service.contains("com.codename1.impl.android.nearby" + + ".CN1CompanionDeviceService")); + // Both are required for the platform to bind it at all. + assertTrue(service.contains( + "android:permission=\"android.permission" + + ".BIND_COMPANION_DEVICE_SERVICE\"")); + assertTrue(service.contains( + "")); + assertTrue(service.contains("android:exported=\"true\"")); + } + + @Test + void nullInputIsTreatedAsEmpty() { + String out = NearbyManifestFragments.inject(null, true, false, false, + false, false, 34); + assertTrue(out.contains("android.permission.UWB_RANGING")); + } + + @Test + void usingNoneOfItChangesNothing() { + String seeded = " \n"; + assertEquals(seeded, NearbyManifestFragments.inject(seeded, false, + false, false, false, false, 34)); + } +} diff --git a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java index 02df44522ec..9ee961cc72c 100644 --- a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java +++ b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java @@ -632,6 +632,70 @@ public final class PlatformFeatureCatalog { .androidMinimumSdk(23) .description("Encrypted SQLite databases (SQLCipher)")); + // Nearby devices (com.codename1.nearby.*). Three entries, because + // the three packages cost three different things and the scanner + // matches on a prefix with no way to express an exclusion -- so the + // package boundary is the only opt-in a developer performs. + // + // NOTE the Android permissions are deliberately NOT listed on any of + // these. UWB_RANGING exists only from API 31, and the transport needs + // the Android 12 Bluetooth split with maxSdkVersion caps and + // usesPermissionFlags="neverForLocation" -- attributes this table + // cannot express. NearbyManifestFragments injects all of them + // instead, exactly as BluetoothManifestFragments does. + // + // The three CN1_NEARBY_* define flips likewise happen in + // IPhoneBuilder, which is also where the AccessorySetupKit plist + // arrays and the optional nearby-interaction entitlement live. + e.add(new Entry("com/codename1/nearby/ranging/") + .iosFrameworks("NearbyInteraction") + // Both keys. NSNearbyInteractionUsageDescription is the iOS 14 + // form and NSNearbyInteractionAllowOnceUsageDescription the + // iOS 15 one; iOS 14 checks the older key before letting a + // session start, so an app on the supported floor that carried + // only the newer one was terminated. The Bluetooth entry above + // carries both of its own keys for the same reason. + .iosPlist("NSNearbyInteractionAllowOnceUsageDescription", + "Measures how far away a nearby device is.") + .iosPlist("NSNearbyInteractionUsageDescription", + "Measures how far away a nearby device is.") + .androidGradle("androidx.core.uwb:uwb:1.0.0") + // The Java-facing wrapper. The base library is Kotlin + // coroutines -- prepareSession returns a Flow -- and the port + // is Java, so AndroidUwbRanging consumes the Observable this + // provides instead of hand-writing a Continuation. + .androidGradle("androidx.core.uwb:uwb-rxjava3:1.0.0") + // Declared optional, so the app still installs on the many + // devices with no UWB radio. Ranging.isSupported() is what an + // app branches on there. + .androidFeatures("android.hardware.uwb") + // The AAR's own floor. NOT 31, which is where UWB_RANGING and + // the platform UwbManager arrive: androidx.core.uwb runs down + // to 23 and reports the feature absent below 31, so raising + // the whole app's minSdk to 31 would cost far more than the + // feature is worth. + .androidMinimumSdk(23) + .description("Ultra-wideband precision ranging")); + + e.add(new Entry("com/codename1/nearby/transport/") + .iosFrameworks("MultipeerConnectivity") + .iosPlist("NSLocalNetworkUsageDescription", + "Finds and connects to nearby devices running this" + + " app.") + .description("Nearby device-to-device transport")); + + e.add(new Entry("com/codename1/nearby/companion/") + // AccessorySetupKit is iOS 18 and CoreBluetooth carries the + // CBUUID its discovery descriptor takes. Naming a framework + // newer than the deployment target is safe: its headers are + // availability-annotated, so clang weak-imports the symbols + // and the @available guards in CN1Nearby.m keep an older OS + // from touching them. + .iosFrameworks("AccessorySetupKit", "CoreBluetooth") + .androidFeatures("android.software.companion_device_setup") + .androidMinimumSdk(26) + .description("Companion-device association and presence")); + e.add(new Entry("com/codename1/ar/") .iosFrameworks("ARKit", "SceneKit") .iosPlist("NSCameraUsageDescription", diff --git a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java index bbfaa8e114b..c856db0b69a 100644 --- a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java +++ b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java @@ -630,4 +630,94 @@ void encryptedDatabaseUsagePullsInTheCipherAndRaisesTheMinimumSdk() { assertTrue(acc.minimumAndroidSdk() >= 23, "SQLCipher requires API 23; the accumulator reported " + acc.minimumAndroidSdk()); } + + // ------------------------------------------------------------------ + // Nearby devices + // ------------------------------------------------------------------ + + @Test + void rangingLinksNearbyInteractionAndCarriesBothPrivacyKeys() { + List hits = + PlatformFeatureCatalog.matchesFor( + "com/codename1/nearby/ranging/Ranging"); + assertEquals(1, hits.size(), "expected one entry to fire"); + PlatformFeatureCatalog.Entry e = hits.get(0); + assertTrue(e.iosFrameworks().contains("NearbyInteraction")); + Set keys = new LinkedHashSet(); + for (String[] entry : e.iosPlistEntries()) { + keys.add(entry[0]); + } + // Both, deliberately: iOS 14 checks the older key before letting a + // session start, so an app on the supported floor carrying only the + // newer one was terminated. + assertTrue(keys.contains("NSNearbyInteractionAllowOnceUsageDescription")); + assertTrue(keys.contains("NSNearbyInteractionUsageDescription")); + assertTrue(e.androidGradleDeps().contains("androidx.core.uwb:uwb:1.0.0")); + assertTrue(e.androidGradleDeps() + .contains("androidx.core.uwb:uwb-rxjava3:1.0.0"), + "the Java-facing wrapper is what the port actually consumes"); + assertTrue(e.androidFeatures().contains("android.hardware.uwb")); + // NOT 31. androidx.core.uwb runs down to 23 and reports the feature + // absent below 31, so raising the whole app would cost more than the + // feature is worth. + assertEquals(23, e.androidMinimumSdk()); + assertTrue(e.androidPermissions().isEmpty(), + "UWB_RANGING is version-conditional, so it belongs to" + + " NearbyManifestFragments and not to this table"); + } + + @Test + void transportLinksMultipeerAndAsksForTheLocalNetwork() { + List hits = + PlatformFeatureCatalog.matchesFor( + "com/codename1/nearby/transport/NearbyTransport"); + assertEquals(1, hits.size()); + PlatformFeatureCatalog.Entry e = hits.get(0); + assertTrue(e.iosFrameworks().contains("MultipeerConnectivity")); + assertEquals("NSLocalNetworkUsageDescription", + e.iosPlistEntries().get(0)[0]); + // Nearby Connections is added through the builder's own Play-services + // table, which knows which version this build resolved. + assertTrue(e.androidGradleDeps().isEmpty()); + } + + @Test + void companionLinksAccessorySetupKitAndCoreBluetooth() { + List hits = + PlatformFeatureCatalog.matchesFor( + "com/codename1/nearby/companion/CompanionDevices"); + assertEquals(1, hits.size()); + PlatformFeatureCatalog.Entry e = hits.get(0); + assertTrue(e.iosFrameworks().contains("AccessorySetupKit")); + // CBUUID is what ASDiscoveryDescriptor takes, so the framework that + // declares it has to be linked too. + assertTrue(e.iosFrameworks().contains("CoreBluetooth")); + assertTrue(e.androidFeatures() + .contains("android.software.companion_device_setup")); + assertEquals(26, e.androidMinimumSdk()); + } + + @Test + void eachNearbyPackagePaysOnlyForItself() { + // The whole reason there are three packages: the scanner matches on a + // prefix and cannot express an exclusion, so an app that only ranges + // must not be handed MultipeerConnectivity or the companion feature. + List ranging = + PlatformFeatureCatalog.matchesFor( + "com/codename1/nearby/ranging/RangingSession"); + for (PlatformFeatureCatalog.Entry e : ranging) { + assertFalse(e.iosFrameworks().contains("MultipeerConnectivity")); + assertFalse(e.iosFrameworks().contains("AccessorySetupKit")); + } + } + + @Test + void theSharedNearbyPackageCostsNothing() { + // com.codename1.nearby itself holds only value types, and referencing + // it must not pull a framework or a dependency in. + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/nearby/NearbyError").isEmpty()); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/nearby/spi/NearbyBridge").isEmpty()); + } } From 61677bc6d0b3d2c76fc463d0820bc5a5dab99a97 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:46:56 +0300 Subject: [PATCH 05/94] Nearby devices: use the AssociationInfo API that a normal app actually has AssociationInfo.getDeviceMacAddressAsString() does not exist on the public Android SDK -- the string-returning form is a hidden API. The public one is getDeviceMacAddress(), which returns an android.net.MacAddress whose toString() is the colon-separated lowercase form that startObservingDevicePresence and disassociate take. Nothing in this repository compiles com.codename1.impl.android.nearby: like com.codename1.impl.android.ar and .cipher before it, the package is excluded from the port jar and only compiles inside a generated app. So this was found by compiling those five files by hand against android-35 plus the real androidx.core.uwb, uwb-rxjava3 and play-services-nearby artifacts, which is the check that stands in for the app build here -- a contaminated shared ~/.m2 from another checkout is currently breaking the sample app's CSS step for unrelated reasons. All 23 classes compile clean now. Worth recording: the same gap covers ar/ and cipher/ and is not new, but it is real. A method that does not exist on the public SDK is the kind of thing that compiles nowhere in this repo and fails in every customer's build. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/nearby/AndroidNearbyBackend.java | 19 ++++++++++++++++--- .../nearby/CN1CompanionDeviceService.java | 3 ++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 5e33837813c..d1cd9e73e16 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -36,6 +36,7 @@ import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.net.MacAddress; import android.os.ParcelUuid; import com.codename1.impl.android.CodenameOneActivity; @@ -594,13 +595,25 @@ private static String expandUuid(String uuid) { return u; } + /// The association's MAC address as a string, or null when it has none. + /// + /// `AssociationInfo.getDeviceMacAddress()` returns an `android.net + /// .MacAddress`, not a string -- the string-returning form is a hidden + /// API that a normal app cannot call. Its `toString()` is the + /// colon-separated lowercase form, which is what + /// `startObservingDevicePresence` and `disassociate` take. + private static String macOf(AssociationInfo info) { + MacAddress address = info.getDeviceMacAddress(); + return address == null ? null : address.toString(); + } + private static String idOf(AssociationInfo info) { - String mac = info.getDeviceMacAddressAsString(); + String mac = macOf(info); return mac != null ? mac : Integer.toString(info.getId()); } private static String encode(AssociationInfo info, boolean present) { - String mac = info.getDeviceMacAddressAsString(); + String mac = macOf(info); CharSequence name = info.getDisplayName(); return join(idOf(info), name == null ? "" : name.toString(), mac == null ? "" : mac, present); @@ -636,7 +649,7 @@ private static String addressOf(CompanionDeviceManager cdm, String id) { List all = cdm.getMyAssociations(); for (int i = 0; all != null && i < all.size(); i++) { if (idOf(all.get(i)).equals(id)) { - String mac = all.get(i).getDeviceMacAddressAsString(); + String mac = macOf(all.get(i)); if (mac != null) { return mac; } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index 43e47dba9be..82a3689d206 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -88,7 +88,8 @@ private void deliver(AssociationInfo info, boolean present) { if (info == null || Build.VERSION.SDK_INT < 31) { return; } - String mac = info.getDeviceMacAddressAsString(); + android.net.MacAddress address = info.getDeviceMacAddress(); + String mac = address == null ? null : address.toString(); String id = mac != null ? mac : Integer.toString(info.getId()); if (!OBSERVED.isEmpty() && !OBSERVED.contains(id)) { // The platform keeps watching until told otherwise, and it From d945514dd2c4da65dbbb1ec7ef208d1cec8348bd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:58:06 +0300 Subject: [PATCH 06/94] Nearby devices: the developer guide chapter A capability matrix by platform, then quick-start, ranging, accessory ranging, companion association, transport, and how to develop the whole thing without hardware. Five compiled snippets rather than inline listings, so the code in the guide is code that builds. Three things the chapter says out loud rather than leaving a reader to discover: the transport does not cross ecosystems and here is what to use instead; ranging needs a channel the two devices already share, so it is designed to be used alongside com.codename1.bluetooth; and background ranging needs an entitlement Apple grants on the App ID, which is why nothing injects it for you. Writing the examples found two real API problems, which is the argument for writing them. com.codename1.nearby.transport.ConnectionRequest is renamed IncomingConnection. com.codename1.io.ConnectionRequest is one of the most widely used classes in the framework, and an app doing both networking and nearby transport -- which is most of them -- could not import both. The compiler said "reference to ConnectionRequest is ambiguous" on the very first snippet that used it. And the example called GattCharacteristic.writeValue, which does not exist; the method is write. A guide snippet that does not compile is a guide snippet that teaches the wrong thing, which is why they live under a compiled source root. Every prose gate passes: vale reports zero errors and zero warnings, LanguageTool reports zero matches over the rendered guide, asciidoctor renders at --failure-level WARN, the paragraph-capitalization check passes and the snippet validator finds nothing. British spellings were normalized to US across the whole feature, code and prose alike, because LanguageTool runs en-US and the neighbouring javadoc already says "meters". Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/nearby/LocalNearbyBridge.java | 4 +- .../com/codename1/nearby/package-info.java | 2 +- .../com/codename1/nearby/ranging/Ranging.java | 4 +- .../nearby/ranging/RangingSession.java | 2 +- .../codename1/nearby/ranging/RangingUnit.java | 14 +- .../nearby/ranging/RangingUpdate.java | 2 +- .../nearby/ranging/package-info.java | 4 +- ...onRequest.java => IncomingConnection.java} | 14 +- .../nearby/transport/NearbyTransport.java | 6 +- .../nearby/transport/TransportAdapter.java | 2 +- .../nearby/transport/TransportListener.java | 6 +- .../NearbyDevicesJava001Snippet.java | 108 ++++++++ .../NearbyDevicesJava002Snippet.java | 97 ++++++++ .../NearbyDevicesJava003Snippet.java | 88 +++++++ .../NearbyDevicesJava004Snippet.java | 94 +++++++ .../NearbyDevicesJava005Snippet.java | 102 ++++++++ docs/developer-guide/Nearby-Devices.asciidoc | 230 ++++++++++++++++++ docs/developer-guide/developer-guide.asciidoc | 2 + docs/developer-guide/languagetool-accept.txt | 16 ++ 19 files changed, 770 insertions(+), 27 deletions(-) rename CodenameOne/src/com/codename1/nearby/transport/{ConnectionRequest.java => IncomingConnection.java} (87%) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava001Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava002Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava003Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava004Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava005Snippet.java create mode 100644 docs/developer-guide/Nearby-Devices.asciidoc diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 0cba8c225cd..93195126184 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -205,7 +205,7 @@ public int[] getSessionHandles() { return out; } - /// The last distance a session reported, in metres, or -1 when it has + /// The last distance a session reported, in meters, or -1 when it has /// not started. /// /// #### Parameters @@ -214,7 +214,7 @@ public int[] getSessionHandles() { /// /// #### Returns /// - /// the distance in metres, or -1 + /// the distance in meters, or -1 public double getSimulatedDistance(int sessionHandle) { SimSession s = sessions.get(Integer.valueOf(sessionHandle)); return s == null || !s.running ? -1 : s.distance; diff --git a/CodenameOne/src/com/codename1/nearby/package-info.java b/CodenameOne/src/com/codename1/nearby/package-info.java index c61cd0c941b..b14a41fdcdb 100644 --- a/CodenameOne/src/com/codename1/nearby/package-info.java +++ b/CodenameOne/src/com/codename1/nearby/package-info.java @@ -32,7 +32,7 @@ /// Wi-Fi permissions that device-to-device transport costs. /// /// - [com.codename1.nearby.ranging] -- ultra-wideband precision ranging. -/// Distance to within about ten centimetres, and direction on hardware +/// Distance to within about ten centimeters, and direction on hardware /// that has the antennas for it. /// - [com.codename1.nearby.companion] -- the OS-managed association between /// this app and one particular accessory, which buys background presence diff --git a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java index 117a27990e1..937e1d7388b 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -38,8 +38,8 @@ /// This is ultra-wideband ranging -- Apple's Nearby Interaction on iOS and /// Jetpack UWB on Android -- which measures distance by timing a radio /// round trip rather than by guessing from signal strength. Where an RSSI -/// estimate off a Bluetooth advertisement is worth a few metres on a good -/// day, UWB is worth about ten centimetres, and on hardware with multiple +/// estimate off a Bluetooth advertisement is worth a few meters on a good +/// day, UWB is worth about ten centimeters, and on hardware with multiple /// antennas it also reports which way the peer is. /// /// #### The shape of a session diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java index de19fca4112..1fe068fcfb1 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java @@ -224,7 +224,7 @@ public void removeRangingListener(RangingListener l) { /// /// - `sessionHandle`: the session the measurement belongs to /// - `hasDistance`: whether a distance was measured - /// - `distanceMeters`: the distance in metres + /// - `distanceMeters`: the distance in meters /// - `hasDirection`: whether an azimuth was measured /// - `azimuth`: the horizontal angle in degrees /// - `hasElevation`: whether an elevation was measured diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java index 96c87ac16f1..c309a2d262f 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java @@ -26,16 +26,16 @@ /// /// There is deliberately no zero-argument distance getter on /// [RangingUpdate]: the caller always names the unit. Both platforms report -/// metres natively, so a bare `getDistance()` would have been correct on +/// meters natively, so a bare `getDistance()` would have been correct on /// every device and still wrong in every app that displayed it as feet. public enum RangingUnit { - /// Metres, the unit both platforms measure in. + /// Meters, the unit both platforms measure in. METERS(1.0), /// International feet, 0.3048 m exactly. FEET(0.3048), - /// Centimetres. + /// Centimeters. CENTIMETERS(0.01), /// International inches, 0.0254 m exactly. @@ -47,11 +47,11 @@ private RangingUnit(double metersPerUnit) { this.metersPerUnit = metersPerUnit; } - /// Converts a distance expressed in metres into this unit. + /// Converts a distance expressed in meters into this unit. /// /// #### Parameters /// - /// - `meters`: the distance in metres + /// - `meters`: the distance in meters /// /// #### Returns /// @@ -60,7 +60,7 @@ public double fromMeters(double meters) { return meters / metersPerUnit; } - /// Converts a distance expressed in this unit into metres. + /// Converts a distance expressed in this unit into meters. /// /// #### Parameters /// @@ -68,7 +68,7 @@ public double fromMeters(double meters) { /// /// #### Returns /// - /// the same distance in metres + /// the same distance in meters public double toMeters(double value) { return value * metersPerUnit; } diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java index d60a75830d6..2f562ad92a5 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java @@ -58,7 +58,7 @@ public final class RangingUpdate { /// #### Parameters /// /// - `hasDistance`: whether this update carries a distance - /// - `distanceMeters`: the distance in metres, ignored when + /// - `distanceMeters`: the distance in meters, ignored when /// `hasDistance` is false /// - `hasDirection`: whether this update carries an azimuth /// - `azimuth`: horizontal angle in degrees, ignored when `hasDirection` diff --git a/CodenameOne/src/com/codename1/nearby/ranging/package-info.java b/CodenameOne/src/com/codename1/nearby/ranging/package-info.java index 23047fd536f..fc3be58efa2 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/package-info.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/package-info.java @@ -24,8 +24,8 @@ /// which direction. /// /// UWB measures distance by timing a radio round trip, which is worth about -/// ten centimetres. That is a different kind of answer from a Bluetooth -/// signal-strength estimate, which is worth a few metres on a good day and +/// ten centimeters. That is a different kind of answer from a Bluetooth +/// signal-strength estimate, which is worth a few meters on a good day and /// swings wildly when someone puts a hand over the phone -- so this is what /// makes "unlock as I walk up to the door" and "point me at my bag" work at /// all. diff --git a/CodenameOne/src/com/codename1/nearby/transport/ConnectionRequest.java b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java similarity index 87% rename from CodenameOne/src/com/codename1/nearby/transport/ConnectionRequest.java rename to CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java index 4caf7dfb566..31944b59d27 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/ConnectionRequest.java +++ b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java @@ -28,6 +28,12 @@ /// An incoming request from another device that wants to connect, delivered /// to [TransportListener#connectionRequested]. /// +/// Named `IncomingConnection` rather than the obvious `ConnectionRequest` +/// because `com.codename1.io.ConnectionRequest` is one of the most widely used +/// classes in the framework, and an app doing both networking and nearby +/// transport -- which is most of them -- would have had to qualify one of the +/// two at every mention. +/// /// Answer it with [#accept()] or [#reject()]. A request that is never /// answered times out on the far side, so answer every one -- and answer it /// promptly, because both platforms hold radio resources open meanwhile. @@ -35,11 +41,11 @@ /// #### Show the token /// /// [#getAuthenticationToken()] is a short string both devices compute from -/// the connection, and it is the only defence against a device in the middle +/// the connection, and it is the only defense against a device in the middle /// pretending to be the one the user meant. Showing it on both screens and /// asking "do these match?" is what makes the pairing trustworthy; skipping /// that step is a choice to trust whoever answered first. -public final class ConnectionRequest { +public final class IncomingConnection { private final Endpoint endpoint; private final String authenticationToken; @@ -53,7 +59,7 @@ public final class ConnectionRequest { /// /// - `endpoint`: who is asking /// - `authenticationToken`: the short comparison string, never null - public ConnectionRequest(Endpoint endpoint, String authenticationToken) { + public IncomingConnection(Endpoint endpoint, String authenticationToken) { this.endpoint = endpoint; this.authenticationToken = authenticationToken == null ? "" : authenticationToken; @@ -105,7 +111,7 @@ public void reject() { } public String toString() { - return "ConnectionRequest[" + endpoint + ", token=" + return "IncomingConnection[" + endpoint + ", token=" + authenticationToken + "]"; } } diff --git a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java index 726ad51aa42..5c26c9ddf84 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -66,7 +66,7 @@ /// public void endpointFound(Endpoint e) { /// NearbyTransport.requestConnection(e, "Shai's phone"); /// } -/// public void connectionRequested(ConnectionRequest r) { +/// public void connectionRequested(IncomingConnection r) { /// // show r.getAuthenticationToken() on both screens before this /// r.accept(); /// } @@ -487,8 +487,8 @@ public static void deliverConnectionRequested(String encodedEndpoint, } NearbyRequests.onEdt(new Runnable() { public void run() { - ConnectionRequest r = - new ConnectionRequest(e, authenticationToken); + IncomingConnection r = + new IncomingConnection(e, authenticationToken); TransportListener[] ls = snapshot(); for (int i = 0; i < ls.length; i++) { ls[i].connectionRequested(r); diff --git a/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java b/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java index 6bca025643f..d3f0d56b2db 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java +++ b/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java @@ -34,7 +34,7 @@ public void endpointFound(Endpoint endpoint) { public void endpointLost(Endpoint endpoint) { } - public void connectionRequested(ConnectionRequest request) { + public void connectionRequested(IncomingConnection request) { } public void connected(Endpoint endpoint) { diff --git a/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java b/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java index 45981c21040..2a7755925b7 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java +++ b/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java @@ -45,14 +45,14 @@ public interface TransportListener { /// - `endpoint`: the peer that disappeared void endpointLost(Endpoint endpoint); - /// A peer wants to connect. Call [ConnectionRequest#accept()] or - /// [ConnectionRequest#reject()]; a request that is never answered times + /// A peer wants to connect. Call [IncomingConnection#accept()] or + /// [IncomingConnection#reject()]; a request that is never answered times /// out on the far side. /// /// #### Parameters /// /// - `request`: the request to answer - void connectionRequested(ConnectionRequest request); + void connectionRequested(IncomingConnection request); /// A connection is open in both directions and payloads may be sent. /// diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava001Snippet.java new file mode 100644 index 00000000000..94d134f4852 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava001Snippet.java @@ -0,0 +1,108 @@ +/* + * 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.home.*; +import com.codename1.home.commissioning.*; +import com.codename1.nearby.*; +import com.codename1.nearby.ranging.*; +import com.codename1.nearby.companion.*; +import com.codename1.nearby.transport.*; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.util.AsyncResource; + +class NearbyDevicesJava001Snippet { + + Label label; + GattCharacteristic characteristic; + RangingListener listener; + byte[] theirToken = new byte[0]; + byte[] configurationFromTheAccessory = new byte[0]; + byte[] address = new byte[2]; + byte[] sessionKey = new byte[8]; + byte[] data = new byte[0]; + int channel; + int preambleIndex; + int sessionId; + RangingSession session; + Arrow arrow = new Arrow(); + static class Arrow { void setAngle(double d) { } } + void process(byte[] b) { } + + void snippet() throws Exception { + // tag::nearby-devices-java-001[] + if (!Ranging.isSupported()) { + return; // no ultra-wideband radio on this device + } + Ranging.prepareSession(RangingRole.CONTROLLER).onResult((session, err) -> { + if (err != null) { + return; + } + // 1. publish our token however the two apps already talk + characteristic.write(session.getLocalToken().toByteArray()); + + // 2. when theirs arrives, start measuring + session.addRangingListener(new RangingAdapter() { + public void updated(RangingUpdate u) { + if (u.hasDistance()) { + label.setText(Math.round(u.getDistance(RangingUnit.CENTIMETERS)) + " cm"); + } + if (u.hasDirection()) { + arrow.setAngle(u.getAzimuth()); + } + } + }); + session.start(RangingToken.fromByteArray(theirToken)); + }); + // end::nearby-devices-java-001[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava002Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava002Snippet.java new file mode 100644 index 00000000000..0ddc72114e9 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava002Snippet.java @@ -0,0 +1,97 @@ +/* + * 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.home.*; +import com.codename1.home.commissioning.*; +import com.codename1.nearby.*; +import com.codename1.nearby.ranging.*; +import com.codename1.nearby.companion.*; +import com.codename1.nearby.transport.*; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.util.AsyncResource; + +class NearbyDevicesJava002Snippet { + + Label label; + GattCharacteristic characteristic; + RangingListener listener; + byte[] theirToken = new byte[0]; + byte[] configurationFromTheAccessory = new byte[0]; + byte[] address = new byte[2]; + byte[] sessionKey = new byte[8]; + byte[] data = new byte[0]; + int channel; + int preambleIndex; + int sessionId; + RangingSession session; + Arrow arrow = new Arrow(); + static class Arrow { void setAngle(double d) { } } + void process(byte[] b) { } + + void snippet() throws Exception { + // tag::nearby-devices-java-002[] + Ranging.prepareSession(RangingRole.CONTROLLER).onResult((session, err) -> { + if (err != null) { + return; + } + session.addRangingListener(listener); + session.startAccessory(configurationFromTheAccessory) + .onResult((shareable, failure) -> { + if (failure == null) { + characteristic.write(shareable); // forward it back + } + }); + }); + // end::nearby-devices-java-002[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava003Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava003Snippet.java new file mode 100644 index 00000000000..88967ec142e --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava003Snippet.java @@ -0,0 +1,88 @@ +/* + * 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.home.*; +import com.codename1.home.commissioning.*; +import com.codename1.nearby.*; +import com.codename1.nearby.ranging.*; +import com.codename1.nearby.companion.*; +import com.codename1.nearby.transport.*; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.util.AsyncResource; + +class NearbyDevicesJava003Snippet { + + Label label; + GattCharacteristic characteristic; + RangingListener listener; + byte[] theirToken = new byte[0]; + byte[] configurationFromTheAccessory = new byte[0]; + byte[] address = new byte[2]; + byte[] sessionKey = new byte[8]; + byte[] data = new byte[0]; + int channel; + int preambleIndex; + int sessionId; + RangingSession session; + Arrow arrow = new Arrow(); + static class Arrow { void setAngle(double d) { } } + void process(byte[] b) { } + + void snippet() throws Exception { + // tag::nearby-devices-java-003[] + RangingToken tag = RangingToken.forUwbAddress(address, channel, preambleIndex, + sessionId, sessionKey); + session.start(tag); + // end::nearby-devices-java-003[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava004Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava004Snippet.java new file mode 100644 index 00000000000..8418fe1b3e7 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava004Snippet.java @@ -0,0 +1,94 @@ +/* + * 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.home.*; +import com.codename1.home.commissioning.*; +import com.codename1.nearby.*; +import com.codename1.nearby.ranging.*; +import com.codename1.nearby.companion.*; +import com.codename1.nearby.transport.*; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.util.AsyncResource; + +class NearbyDevicesJava004Snippet { + + Label label; + GattCharacteristic characteristic; + RangingListener listener; + byte[] theirToken = new byte[0]; + byte[] configurationFromTheAccessory = new byte[0]; + byte[] address = new byte[2]; + byte[] sessionKey = new byte[8]; + byte[] data = new byte[0]; + int channel; + int preambleIndex; + int sessionId; + RangingSession session; + Arrow arrow = new Arrow(); + static class Arrow { void setAngle(double d) { } } + void process(byte[] b) { } + + void snippet() throws Exception { + // tag::nearby-devices-java-004[] + AssociationRequest request = new AssociationRequest.Builder() + .addFilter(DeviceFilter.bleService("180D")) + .build(); + CompanionDevices.associate(request).onResult((device, err) -> { + if (err == null) { + Preferences.set("sensor", device.getId()); + CompanionDevices.startObservingPresence(device.getId()); + } + }); + // end::nearby-devices-java-004[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava005Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava005Snippet.java new file mode 100644 index 00000000000..013a999fb9b --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava005Snippet.java @@ -0,0 +1,102 @@ +/* + * 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.home.*; +import com.codename1.home.commissioning.*; +import com.codename1.nearby.*; +import com.codename1.nearby.ranging.*; +import com.codename1.nearby.companion.*; +import com.codename1.nearby.transport.*; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.util.AsyncResource; + +class NearbyDevicesJava005Snippet { + + Label label; + GattCharacteristic characteristic; + RangingListener listener; + byte[] theirToken = new byte[0]; + byte[] configurationFromTheAccessory = new byte[0]; + byte[] address = new byte[2]; + byte[] sessionKey = new byte[8]; + byte[] data = new byte[0]; + int channel; + int preambleIndex; + int sessionId; + RangingSession session; + Arrow arrow = new Arrow(); + static class Arrow { void setAngle(double d) { } } + void process(byte[] b) { } + + void snippet() throws Exception { + // tag::nearby-devices-java-005[] + NearbyTransport.addTransportListener(new TransportAdapter() { + public void endpointFound(Endpoint e) { + NearbyTransport.requestConnection(e, "Shai's phone"); + } + public void connectionRequested(IncomingConnection r) { + // show r.getAuthenticationToken() on both screens first + r.accept(); + } + public void connected(Endpoint e) { + NearbyTransport.send(e, Payload.fromBytes(data)); + } + public void payloadReceived(Endpoint e, Payload p) { + process(p.getBytes()); + } + }); + NearbyTransport.startAdvertising("chat", "Shai's phone", TransportStrategy.CLUSTER); + NearbyTransport.startDiscovery("chat", TransportStrategy.CLUSTER); + // end::nearby-devices-java-005[] + } +} diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc new file mode 100644 index 00000000000..7b4bab90216 --- /dev/null +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -0,0 +1,230 @@ +== Nearby Devices + +Codename One answers three questions about the surrounding devices, under +`com.codename1.nearby`: how far away one is and in which direction +(`com.codename1.nearby.ranging`), which one is yours +(`com.codename1.nearby.companion`), and how to send it something +(`com.codename1.nearby.transport`). + +They're three packages rather than one because referencing a package is the +whole opt-in. The build server decides what native machinery an app gets by +scanning bytecode for these prefixes, so an app that only wants to know how far +away its keyring tag is pays for ranging alone -- no Play Services dependency, +no local network prompt, no companion permissions. Referencing +`com.codename1.nearby` itself costs nothing; it holds only the shared value +types. + +[options="header"] +|=== +| Capability | iOS | Android | Simulator and desktop | JavaScript +| Precision ranging, peer to peer | yes (U1 chip, iPhone 11 and later) | yes (UWB hardware) | simulated | simulated +| Ranging an accessory | yes (Nearby Interaction Accessory Protocol) | yes (join the session it names) | simulated | simulated +| Direction as well as distance | where the hardware provides it | where the hardware provides it | simulated | simulated +| Companion association | yes (iOS 18 and later) | yes (Android 8 and later) | simulated | simulated +| Presence notifications | -- | yes (Android 12 and later) | simulated | simulated +| Device-to-device transport | Apple devices only | Android devices only | simulated loopback | simulated loopback +|=== + +Branch on the capability queries -- `Ranging.isSupported()`, +`Ranging.getCapabilities()`, `CompanionDevices.isSupported()`, +`NearbyTransport.isSupported()` -- rather than on platform detection. Ranging in +particular is absent on plenty of current phones, so treat it as an enhancement +to a feature that also works without it rather than as the feature itself. + +Every callback in this family arrives on the EDT. + +=== Three Things Worth Knowing Before You Design Around This + +*The transport doesn't cross ecosystems.* Underneath are Google's Nearby +Connections on Android and Apple's MultipeerConnectivity on iOS, which share no +wire protocol. An iPhone and an Android phone will never discover each other +here, however the app is written. Nothing in the API hides that, because an API +that looked portable and never found the peer would be worse than an honest +limitation. When both ends aren't the same platform, two things that do +work across the divide are already in the framework: +`com.codename1.bluetooth.le.L2capChannel` for a raw byte stream over BLE, and +`com.codename1.io.bonjour` plus ordinary sockets when both devices share a +Wi-Fi network. + +*Ranging needs `com.codename1.bluetooth`, or something like it.* Both platforms +require the two devices to swap a token over a channel they already share +before any radio ranging can begin. A GATT characteristic is the usual channel. +The two APIs are designed to be used together. + +*Background ranging is opt-in and needs Apple's permission.* On iOS it requires +the `com.apple.developer.nearby-interaction` entitlement, which has to be +enabled on the App ID before it will sign. Codename One never injects it on its +own, because an entitlement the App ID doesn't carry fails codesigning with an +error naming the entitlement and not the reason it appeared. Set +`ios.nearby.background=true` once the capability is enabled, and note that +`RangingCapabilities.isBackgroundRangingSupported()` reports `false` until then. + +=== Ranging: How Far, And Which Way + +Ultra-wideband measures distance by timing a radio round trip, which is worth +about ten centimeters. That's a different kind of answer from a Bluetooth +signal-strength estimate, which is worth a few meters on a good day and swings +when someone puts a hand over the phone. + +A session is prepared, then started. There's no honest one-call form, because +the token exchange has to happen between the two: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava001Snippet.java[tag=nearby-devices-java-001,indent=0] +---- + +One session ranges one peer. That's a hard limit of Apple's +`NINearbyPeerConfiguration` rather than a simplification, so an app tracking +several peers prepares several sessions. + +Pick a role even though iOS ignores it: Android needs exactly one controller, +and choosing costs nothing on the other side. + +Every field of a `RangingUpdate` except the timestamp is optional, and they drop +out independently. A peer directly behind the phone commonly reports a distance +with no direction, and a peer at the edge of range reports neither -- so guard +each read with its `has` method rather than assuming a sentinel. There's no +zero-argument `getDistance()`: meters read as feet is the accident that +convention exists to prevent. + +Azimuth is degrees in the range -180 to 180, zero straight ahead and positive to +the right; elevation is -90 to 90, positive above the device. Android reports +both angles natively. iOS reports a unit direction vector instead and the port +derives the angles from it, so the same code reads the same on both; +`getDirectionVector()` still hands back the untouched vector where there is one. + +A peer that walks away produces `peerRemoved` and the session stays alive, ready +to resume if it comes back -- gray the UI out rather than tearing it down. A +session that dies for good produces `invalidated` and can't be restarted. + +=== Ranging An Accessory + +A third-party ultra-wideband tag isn't a phone, and the two platforms disagree +about what talking to one means. + +On iOS there is a defined handshake. The accessory publishes a blob of +configuration data over its own channel, and the session answers with bytes that +have to travel back before the accessory begins ranging: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava002Snippet.java[tag=nearby-devices-java-002,indent=0] +---- + +Android has no equivalent protocol. There, an accessory simply names the channel +and session to join, so build a token from what it published and call `start`: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava003Snippet.java[tag=nearby-devices-java-003,indent=0] +---- + +`startAccessory` fails with `NearbyError.NOT_SUPPORTED` on Android, and a token +built by `forUwbAddress` is rejected on iOS. A token is opaque and never +portable between the two platforms; `RangingToken.fromByteArray` says so rather +than handing garbage to a native call. + +=== Companion Devices: Which One Is Yours + +Associating isn't pairing. It's the app telling the operating system that a +particular accessory belongs to it, through a chooser the OS draws and the user +picks from, and getting +privileges back that an ordinary Bluetooth scan doesn't carry: the OS watches +for the device instead of the app, scanning stops needing location permission on +Android, and the user sees one honest prompt naming one device. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava004Snippet.java[tag=nearby-devices-java-004,indent=0] +---- + +An association outlives the app: it survives restarts and reboots, and ends only +when the app drops it, the user revokes it in system settings, or the app is +uninstalled. Persist `CompanionDevice.getId()` and look the device up again on +the next launch instead of asking the user to pick it twice. +`CompanionDevice.getAddress()` is the same handle +`BluetoothLE.getPeripheral(String)` takes, which is what makes an association +useful rather than decorative. + +Ask for `CompanionProfile.GENERIC` unless the device is a watch, a +head-mounted display or a computer. A profile is a request for elevated +privileges as much as a description, and the specific ones cost the user a +stronger prompt. The build scanner can't see which profile a request asks for, +because it arrives as an enum constant; set +`android.nearby.watchProfile=true` if you use `CompanionProfile.WATCH`, so the +manifest carries the permission that profile needs. + +Two platform differences to design around. Presence notifications are Android +only: AccessorySetupKit reports an accessory being added to or removed from the +app's set, which isn't the same event as it coming into range, so +`startObservingPresence` answers `false` on iOS and an app that needs live +proximity there should scan with `com.codename1.bluetooth`. And AccessorySetupKit +only ever discovers Bluetooth services an app declared up front, so set +`ios.nearby.accessoryServices` to a comma-separated list of the service UUIDs +your accessories advertise -- without it the picker finds nothing on iOS, and +the build log says so. + +=== Transport: Sending Something + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NearbyDevicesJava005Snippet.java[tag=nearby-devices-java-005,indent=0] +---- + +`getAuthenticationToken()` is a short string both devices compute from the +connection, and it's the only defense against a device in the middle pretending +to be the one the user meant. Showing it on both screens and asking whether they +match is what makes the pairing trustworthy; skipping that step is a choice to +trust whoever answered first. + +Answer every `connectionRequested` without delay. A request that's never answered +holds radio resources open on both sides until the far end times out. + +Keep the service id short. On iOS it also becomes the Bonjour service type, +which the platform restricts to fifteen characters of lowercase letters, digits +and hyphens -- so a reverse-DNS string that's legal on Android is +folded to fit, and `com.example.chat` and `com.example.charts` fold to the same +thing. Set `ios.nearby.serviceType` yourself rather than letting two apps +discover each other's peers. The build log names the service type it registered. + +Byte payloads are capped at `NearbyTransport.getMaxPayloadSize()`, a few +kilobytes on both platforms; anything larger goes as a file payload, which +streams and reports progress. Call `NearbyTransport.stop()` when the feature's +UI closes -- both platforms keep the radios busy until something says stop. + +=== Developing Without Hardware + +The simulator, the desktop ports and the JavaScript port carry a working +implementation rather than a stub, and report +`NearbyAvailability.LOCAL_ONLY` so an app can tell the developer its peers are +not real. Almost none of a ranging feature is about radios -- laying out the +screen, animating an arrow, deciding what to show while the direction drops out, +handling the peer walking away -- and a port that answered `NOT_SUPPORTED` would +make every line of it testable only on a pair of phones. + +Two things it does that a mock wouldn't. It never completes inline, because +code written against an implementation that answers instantly races the moment +it meets one that doesn't. And its peers move, along a bounded random walk, +because a constant 1.5 m would let an app ship with a distance label that +flickers unreadably against real hardware. + +What it won't do behind your back is drop a peer or suspend a session at +random. Those are real events an app must handle, but a simulation that fired +them unpredictably would make every test using it flaky, so they're controls +the simulator drives instead. + +=== Build Hints + +[options="header"] +|=== +| Hint | Default | What it does +| `ios.nearby.serviceType` | derived from the package name | The Bonjour service type the transport registers. Fifteen characters of lowercase letters, digits and hyphens. +| `ios.nearby.accessoryServices` | unset | Comma-separated Bluetooth service UUIDs the association picker may discover. Required for the picker to find anything on iOS. +| `ios.nearby.background` | `false` | Requests the `com.apple.developer.nearby-interaction` entitlement and the matching background mode. Enable the capability on the App ID first. +| `android.nearby.watchProfile` | `false` | Declares the watch companion profile permission, for an app that associates with `CompanionProfile.WATCH`. +|=== + +Everything else is automatic. Referencing a package links its frameworks, +injects its privacy strings and adds its permissions; referencing none of them +changes nothing about the app. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 2a45b01e83a..8aa4a18af74 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -155,6 +155,8 @@ include::Near-Field-Communication.asciidoc[] include::Bluetooth.asciidoc[] +include::Nearby-Devices.asciidoc[] + include::Health.asciidoc[] include::Smart-Home.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 4f0144ce536..fd68730e9e6 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -665,3 +665,19 @@ unretraceable # The value a thermostat is aiming for, as the HVAC industry and both # platforms name it. [Ss]etpoints? + +# ----------------------------------------------------------------------------- +# Nearby devices (Nearby-Devices.asciidoc) terminology. +# ----------------------------------------------------------------------------- +# The radio technology and its hyphenated long form. +UWB +ultra-wideband +# Apple's accessory-pairing framework, named in prose rather than in a code +# span because the chapter discusses what it can and cannot discover. +AccessorySetupKit +# Apple's zero-configuration networking. Named because the transport's service +# id becomes a Bonjour service type on iOS. +Bonjour +# The Apple verb for signing a build. Not "code signing" here: this is the +# spelling in the error an unavailable entitlement produces. +codesigning From ae3bdf5afd38b98b1d5156d03f1703f60aa454c1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:07:54 +0300 Subject: [PATCH 07/94] Nearby devices: guard the reflective casts with instanceof scripts/check-cast-semantics.sh flagged the cast in AndroidNearbyBridge: it sat inside a catch(Throwable), which under ParparVM is a handler that never runs -- a failed CHECKCAST does not throw there, it hands the wrong object to the next instruction. Android is not ParparVM, so nothing was going to break on a device. The gate is repo-wide anyway, and rightly: a rule that holds everywhere is one rule, and a rule with a per-port exemption is a rule nobody can apply without first working out which port they are in. The baseline is a ratchet of existing debt rather than an allow-list, so new code does not add to it. Both reflective loads now test with instanceof and branch. The one in AndroidNearbyBackend is fixed the same way even though the checker cannot see it -- that file is in the package excluded from the port jar, so it compiles nowhere the checker looks. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/android/AndroidNearbyBridge.java | 14 ++++++++++---- .../impl/android/nearby/AndroidNearbyBackend.java | 10 ++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java b/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java index e0e2778c7a8..173d2c124ab 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java @@ -58,18 +58,24 @@ public class AndroidNearbyBridge implements NearbyBridge { /// - `activity`: the host activity, which the backend needs for the /// association chooser public AndroidNearbyBridge(Activity activity) { - NearbyBridge loaded = null; + Object instance = null; try { Class clazz = Class.forName( "com.codename1.impl.android.nearby.AndroidNearbyBackend"); - loaded = (NearbyBridge) clazz.getConstructor(Activity.class) + instance = clazz.getConstructor(Activity.class) .newInstance(activity); } catch (Throwable t) { // Expected for every app that never referenced com.codename1 // .nearby: the builder deleted the package. Nothing to log. - loaded = null; + instance = null; } - this.delegate = loaded; + // Tested rather than cast inside the catch. A failed cast does not + // throw under ParparVM, so a `catch` around one is a handler that + // never runs -- and scripts/check-cast-semantics.sh rejects the + // shape repo-wide, on Android sources too, so the rule stays one + // rule rather than a per-port exception. + this.delegate = instance instanceof NearbyBridge + ? (NearbyBridge) instance : null; } // ------------------------------------------------------------------ diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index d1cd9e73e16..8e25d8ff18a 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -81,15 +81,21 @@ public AndroidNearbyBackend(Activity activity) { } private NearbyBridge load(String className) { + Object instance = null; try { Class clazz = Class.forName(className); - return (NearbyBridge) clazz.getConstructor(Context.class) + instance = clazz.getConstructor(Context.class) .newInstance(activity); } catch (Throwable t) { // The builder deletes the half an app did not reference, so this // is the ordinary path rather than an error. - return null; + instance = null; } + // Guarded with instanceof rather than cast inside the catch: a failed + // cast does not throw under ParparVM, so catching one is a handler + // that never runs. + return instance instanceof NearbyBridge ? (NearbyBridge) instance + : null; } // ------------------------------------------------------------------ From e03ebc385fe02e25d27483f3fa3e69151afdc6e7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:16:45 +0300 Subject: [PATCH 08/94] Ignore a maven repository private to this checkout Several CodenameOne checkouts live on this machine and all install com.codenameone:*:8.0-SNAPSHOT. Sharing ~/.m2 or /tmp/cn1-local-repo lets a build in another checkout overwrite this one's core jar mid-build, and the symptom is a "cannot find symbol" on a class this branch just added -- which reads like a corrupt incremental build rather than a collision. It cost real time twice in this branch alone. Build with -Dmaven.repo.local=$(pwd)/.m2-repo instead. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 7665845fb72..7e03930d3cd 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,12 @@ scripts/fidelity-app/common/src/main/resources/*ThemeDev.res # build time (common/pom.xml copy-native-themes); never commit the duplicate. scripts/fidelity-app/common/src/main/resources/iOSModernTheme.res scripts/fidelity-app/common/src/main/resources/AndroidMaterialTheme.res + +# Maven repository private to THIS checkout. +# +# Several CodenameOne checkouts live on the same machine and all install +# com.codenameone:*:8.0-SNAPSHOT. Sharing ~/.m2 or /tmp/cn1-local-repo means a +# build in another checkout silently overwrites this one's core jar mid-build, +# and the symptom is "cannot find symbol" on a class this branch just added. +# Build with -Dmaven.repo.local=$(pwd)/.m2-repo instead. +/.m2-repo/ From dc1ebd0b532a366ae2cd3c7da689fd7366fb08b6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:41:38 +0300 Subject: [PATCH 09/94] Nearby devices: address review, and satisfy the PMD gate PMD is a zero-forbidden-findings gate and this change arrived with 102. Fixed rather than excluded: @Override on every method that overrides one (including the ones whose declaration spans two lines, which a single-line pass misses), for-each in place of the indexed loops, and the redundant `private` on an enum constructor. Which methods genuinely override was decided by javac rather than by eye -- annotate everything, compile, and remove whatever the compiler rejects -- so the result is exactly the set it agrees with. PMD and SpotBugs both report zero now, over 5267 green tests. Five review findings, all real, all fixed with a regression test where one was possible. **Transport permission requests never settled.** NearbyTransport parked its resource in the transport's own pending map, while every bridge answers through Ranging.deliverPermissionResult, which searched only the ranging map. The id was dropped and the caller waited forever -- precisely what the SPI documentation calls worse than an outright failure. Permissions now live in one shared map on NearbyRequests, which is what request ids coming from a single counter always allowed. **A failed start wedged a ranging session for good.** The flag that makes a concurrent start answer BUSY was set before the bridge call and cleared only on success, so a rejected token left the session answering BUSY to every retry -- and retrying after a bad token exchange is the obvious thing to do. In-flight starts are now tracked by request id, because the failure path only ever learns the id. **The declared Bonjour type did not match the one registered at runtime.** The plist carried a type folded from the package name while the native folded the serviceId the app passed to startAdvertising, so iOS browsed a type it had never been told about and answered with silence. The plist is now the authority: ios.nearby.serviceType takes a comma-separated list of the service ids the app uses, all of them are declared, and CN1Nearby.m checks its folded argument against NSBonjourServices and fails with a message naming the hint rather than browsing into the void. **Deleting CN1CompanionDeviceService broke every ranging-only build.** AndroidNearbyBackend calls its register/unregister unconditionally, the whole package is excluded from the port jar, and no other definition exists, so javac had nothing to resolve. It is retained; the manifest still names it only when presence is used. **The Android transport answered "granted" without asking.** Nearby Connections refuses to start without its runtime grants and nothing on the advertise/discover path checked them, so requestPermissions resolved while the first real operation failed for a permission the user never saw. It now checks and requests, following AndroidBluetooth down to running the blocking check on the EDT. Chasing the last of those turned up one more the review did not: the device-profile gates had GLASSES and COMPUTER swapped. GLASSES is API 34 and COMPUTER is 33, not the order the enum happens to declare them in, and passing the platform a profile string it does not know throws. Both were checked against the SDK's own api-versions.xml, as was the companion foreground-service permission -- API 31, not 33, which is the same file's answer to the daemon-side review comment. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/nearby/LocalNearbyBridge.java | 53 +++++++- .../codename1/impl/nearby/NearbyRequests.java | 54 +++++++++ .../nearby/companion/CompanionDevice.java | 3 + .../nearby/companion/CompanionDevices.java | 11 +- .../nearby/companion/DeviceFilter.java | 1 + .../com/codename1/nearby/ranging/Ranging.java | 72 +++++++++-- .../nearby/ranging/RangingAdapter.java | 5 + .../nearby/ranging/RangingCapabilities.java | 1 + .../nearby/ranging/RangingSession.java | 38 ++++-- .../nearby/ranging/RangingToken.java | 7 +- .../codename1/nearby/ranging/RangingUnit.java | 2 +- .../nearby/ranging/RangingUpdate.java | 1 + .../codename1/nearby/transport/Endpoint.java | 3 + .../nearby/transport/IncomingConnection.java | 1 + .../nearby/transport/NearbyTransport.java | 54 ++++++--- .../codename1/nearby/transport/Payload.java | 1 + .../transport/PayloadTransferUpdate.java | 1 + .../nearby/transport/TransportAdapter.java | 8 ++ .../android/nearby/AndroidNearbyBackend.java | 9 +- .../nearby/AndroidNearbyTransport.java | 70 ++++++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 65 ++++++++++ .../builders/AndroidGradleBuilder.java | 15 ++- .../com/codename1/builders/IPhoneBuilder.java | 114 ++++++++++++------ .../builders/NearbyManifestFragments.java | 8 +- .../NearbyBonjourServiceTypeTest.java | 85 +++++++++++-- .../builders/NearbyManifestFragmentsTest.java | 17 +++ .../com/codename1/nearby/LocalNearbyTest.java | 38 ++++++ 27 files changed, 627 insertions(+), 110 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 93195126184..4ec72205f7b 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -238,30 +238,37 @@ public void setSimulatedDistance(int sessionHandle, double meters) { // Shared // ------------------------------------------------------------------ + @Override public boolean isRangingSupported() { return true; } + @Override public boolean isCompanionSupported() { return true; } + @Override public boolean isTransportSupported() { return true; } + @Override public int getRangingAvailability() { return NearbyAvailability.LOCAL_ONLY.ordinal(); } + @Override public int getCompanionAvailability() { return NearbyAvailability.LOCAL_ONLY.ordinal(); } + @Override public int getTransportAvailability() { return NearbyAvailability.LOCAL_ONLY.ordinal(); } + @Override public void requestPermissions(final int requestId, int permissionBits) { // Nothing to ask a desktop for, but the answer still has to arrive // asynchronously: an app whose permission callback runs inline here @@ -273,11 +280,13 @@ public void requestPermissions(final int requestId, int permissionBits) { // Ranging // ------------------------------------------------------------------ + @Override public int getRangingCapabilities() { return CAPABILITY_DISTANCE | CAPABILITY_DIRECTION | CAPABILITY_ELEVATION | CAPABILITY_ACCESSORY; } + @Override public void prepareRangingSession(final int requestId, final int sessionHandle, final boolean controller) { final SimSession s = new SimSession(sessionHandle, controller, @@ -286,6 +295,7 @@ public void prepareRangingSession(final int requestId, answer(new SessionPrepared(requestId, sessionHandle, controller, s)); } + @Override public void startRanging(final int requestId, final int sessionHandle, final byte[] peerToken) { final SimSession s = sessions.get(Integer.valueOf(sessionHandle)); @@ -310,6 +320,7 @@ public void startRanging(final int requestId, final int sessionHandle, return; } answer(new Runnable() { + @Override public void run() { s.running = true; Ranging.deliverSessionStarted(requestId, sessionHandle); @@ -318,6 +329,7 @@ public void run() { }); } + @Override public void startAccessoryRanging(final int requestId, final int sessionHandle, byte[] accessoryData) { final SimSession s = sessions.get(Integer.valueOf(sessionHandle)); @@ -327,6 +339,7 @@ public void startAccessoryRanging(final int requestId, return; } answer(new Runnable() { + @Override public void run() { s.running = true; // A real accessory handshake sends configuration back; the @@ -340,6 +353,7 @@ public void run() { }); } + @Override public void stopRangingSession(int sessionHandle) { SimSession s = sessions.remove(Integer.valueOf(sessionHandle)); if (s != null) { @@ -351,9 +365,11 @@ public void stopRangingSession(int sessionHandle) { // Companion // ------------------------------------------------------------------ + @Override public void associate(final int requestId, final int profile, boolean singleDevice, final String[] filters) { answer(new Runnable() { + @Override public void run() { Candidate c = firstMatch(filters); if (c == null) { @@ -374,6 +390,7 @@ public void run() { }); } + @Override public String[] getAssociations() { String[] out = new String[associations.size()]; int i = 0; @@ -383,8 +400,10 @@ public String[] getAssociations() { return out; } + @Override public void disassociate(final int requestId, final String associationId) { answer(new Runnable() { + @Override public void run() { observed.remove(associationId); if (associations.remove(associationId) == null) { @@ -398,6 +417,7 @@ public void run() { }); } + @Override public boolean startObservingPresence(String associationId) { if (!associations.containsKey(associationId)) { return false; @@ -406,6 +426,7 @@ public boolean startObservingPresence(String associationId) { return true; } + @Override public void stopObservingPresence(String associationId) { observed.remove(associationId); } @@ -433,6 +454,7 @@ public void setPresent(String associationId, boolean present) { // Transport // ------------------------------------------------------------------ + @Override public int getMaxPayloadSize() { // What Nearby Connections allows for a BYTES payload. Matching the // tighter of the two real limits means an app that fits here fits @@ -440,20 +462,24 @@ public int getMaxPayloadSize() { return 32 * 1024; } + @Override public void startAdvertising(final int requestId, String serviceId, String localName, int strategy) { advertising = true; answerOk(requestId); } + @Override public void stopAdvertising() { advertising = false; } + @Override public void startDiscovery(final int requestId, final String serviceId, int strategy) { discovering = true; answer(new Runnable() { + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); for (SimEndpoint e : endpoints) { @@ -464,10 +490,12 @@ public void run() { }); } + @Override public void stopDiscovery() { discovering = false; } + @Override public void requestConnection(final int requestId, final String endpointId, String localName) { final SimEndpoint e = findEndpoint(endpointId); @@ -477,11 +505,13 @@ public void requestConnection(final int requestId, final String endpointId, return; } answer(new Runnable() { + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); // The simulated peer always accepts, one hop later, so the // app sees the two-step shape the real platforms have. answer(new Runnable() { + @Override public void run() { connected.add(endpointId); NearbyTransport.deliverConnectionResult(e.encode(), @@ -492,6 +522,7 @@ public void run() { }); } + @Override public void acceptConnection(final int requestId, String endpointId) { if (!connected.contains(endpointId)) { connected.add(endpointId); @@ -499,19 +530,22 @@ public void acceptConnection(final int requestId, String endpointId) { answerOk(requestId); } + @Override public void rejectConnection(String endpointId) { connected.remove(endpointId); } + @Override public void sendPayload(final int requestId, final String[] endpointIds, final int payloadId, final int payloadType, final byte[] bytes, final String path) { answer(new Runnable() { + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); - for (int i = 0; i < endpointIds.length; i++) { - final SimEndpoint e = findEndpoint(endpointIds[i]); - if (e == null || !connected.contains(endpointIds[i])) { + for (String endpointId : endpointIds) { + final SimEndpoint e = findEndpoint(endpointId); + if (e == null || !connected.contains(endpointId)) { continue; } long total = payloadType == PAYLOAD_BYTES && bytes != null @@ -528,9 +562,11 @@ public void run() { }); } + @Override public void cancelPayload(int payloadId) { } + @Override public void disconnect(String endpointId) { if (connected.remove(endpointId)) { SimEndpoint e = findEndpoint(endpointId); @@ -540,6 +576,7 @@ public void disconnect(String endpointId) { } } + @Override public void stopAllTransport() { advertising = false; discovering = false; @@ -585,6 +622,7 @@ private void tick(final SimSession s) { return; } later(TICK_MILLIS, new Runnable() { + @Override public void run() { tick(s); } @@ -607,8 +645,8 @@ private Candidate firstMatch(String[] filters) { if (filters == null || filters.length == 0) { return candidates.get(0); } - for (int i = 0; i < filters.length; i++) { - String[] f = NearbyWire.split(filters[i]); + for (String filter : filters) { + String[] f = NearbyWire.split(filter); int kind = NearbyWire.integer(f, 0, -1); String value = NearbyWire.field(f, 1); for (Candidate c : candidates) { @@ -665,6 +703,7 @@ private PermissionAnswer(int requestId) { this.requestId = requestId; } + @Override public void run() { Ranging.deliverPermissionResult(requestId, true); } @@ -684,6 +723,7 @@ private SessionPrepared(int requestId, int sessionHandle, this.session = session; } + @Override public void run() { Ranging.deliverSessionPrepared(requestId, sessionHandle, controller, RangingToken.PLATFORM_SIMULATED, @@ -703,6 +743,7 @@ private RangingFailure(int requestId, NearbyError error, this.message = message; } + @Override public void run() { Ranging.deliverRequestFailed(requestId, error.ordinal(), message); } @@ -715,6 +756,7 @@ private TransportOk(int requestId) { this.requestId = requestId; } + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); } @@ -732,6 +774,7 @@ private TransportFailure(int requestId, NearbyError error, this.message = message; } + @Override public void run() { NearbyTransport.deliverRequestFailed(requestId, error.ordinal(), message); diff --git a/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java b/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java index e6ca758be88..1a25758adad 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java +++ b/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java @@ -22,6 +22,8 @@ */ package com.codename1.impl.nearby; +import com.codename1.impl.async.EdtResult; +import com.codename1.impl.async.PendingMap; import com.codename1.nearby.spi.NearbyBridge; import com.codename1.ui.Display; @@ -38,6 +40,22 @@ public final class NearbyRequests { private static NearbyBridge testBridge; + /// Permission requests in flight, from EVERY facade. + /// + /// One map rather than one per facade, because there is one answer path: + /// a port reports the outcome through + /// `com.codename1.nearby.ranging.Ranging#deliverPermissionResult` + /// whichever entry point asked. A per-facade map meant a request opened by + /// `NearbyTransport.requestPermissions` was looked for in the ranging map, + /// not found, and dropped -- leaving the caller holding a resource that + /// never settled, which is precisely the failure the SPI documentation + /// calls worse than an outright error. + /// + /// Safe to share because request ids come from one counter, so an id is in + /// at most one map and an answer cannot be matched to the wrong operation. + private static final PendingMap PERMISSIONS = + new PendingMap(); + private NearbyRequests() { } @@ -104,6 +122,42 @@ public static int nextId() { return NEXT_ID.getAndIncrement(); } + /// Registers a permission request and returns the resource its answer + /// will complete. + /// + /// #### Parameters + /// + /// - `requestId`: the id the port will answer with + /// + /// #### Returns + /// + /// the resource to hand to the caller + public static EdtResult openPermissionRequest(int requestId) { + return PERMISSIONS.open(requestId); + } + + /// Claims a permission request's resource, removing it. + /// + /// #### Parameters + /// + /// - `requestId`: the id being answered + /// + /// #### Returns + /// + /// the resource, or null when nothing is waiting on that id + public static EdtResult takePermissionRequest(int requestId) { + return PERMISSIONS.take(requestId); + } + + /// Fails every permission request in flight. + /// + /// #### Parameters + /// + /// - `failure`: what to fail them with + public static void failPermissionRequests(Throwable failure) { + PERMISSIONS.failAll(failure); + } + /// Runs something on the EDT, immediately when already there. /// /// Ports call the `deliver...` entry points from whatever thread the diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java index 02fecac15c9..bc1d2b96f2e 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java @@ -97,6 +97,7 @@ public boolean isPresent() { return present; } + @Override public boolean equals(Object o) { if (this == o) { return true; @@ -108,10 +109,12 @@ public boolean equals(Object o) { return id == null ? d.id == null : id.equals(d.id); } + @Override public int hashCode() { return id == null ? 0 : id.hashCode(); } + @Override public String toString() { return "CompanionDevice[" + id + ", " + displayName + ", profile=" + profile + ", present=" + present + "]"; diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index 985e5875dd4..9f82c1ef4be 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -169,8 +169,8 @@ public static List getAssociations() { } List out = new ArrayList(rows.length); - for (int i = 0; i < rows.length; i++) { - CompanionDevice d = NearbyWire.decodeCompanionDevice(rows[i]); + for (String row : rows) { + CompanionDevice d = NearbyWire.decodeCompanionDevice(row); if (d != null) { out.add(d); } @@ -364,17 +364,18 @@ public static void deliverPresenceChanged(String encodedDevice, return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { PresenceListener[] ls; synchronized (LISTENERS) { ls = LISTENERS.toArray( new PresenceListener[LISTENERS.size()]); } - for (int i = 0; i < ls.length; i++) { + for (PresenceListener l : ls) { if (present) { - ls[i].deviceAppeared(d); + l.deviceAppeared(d); } else { - ls[i].deviceDisappeared(d); + l.deviceDisappeared(d); } } } diff --git a/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java b/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java index f2d999839da..d3b677ea7aa 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java +++ b/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java @@ -130,6 +130,7 @@ public String getValue() { return value; } + @Override public String toString() { return "DeviceFilter[kind=" + kind + ", value=" + value + "]"; } diff --git a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java index 937e1d7388b..0ce98406fa8 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -97,12 +97,12 @@ /// fails with [NearbyError#NOT_SUPPORTED]. public final class Ranging { - private static final PendingMap PENDING_PERMISSIONS = - new PendingMap(); private static final PendingMap PENDING_SESSIONS = new PendingMap(); private static final PendingMap PENDING_ACCESSORY = new PendingMap(); + private static final java.util.Map STARTING = + new java.util.HashMap(); private Ranging() { } @@ -174,12 +174,12 @@ public static AsyncResource requestPermissions( } int bits = 0; if (permissions != null) { - for (int i = 0; i < permissions.length; i++) { - bits |= permissionBit(permissions[i]); + for (NearbyPermission permission : permissions) { + bits |= permissionBit(permission); } } int id = NearbyRequests.nextId(); - EdtResult out = PENDING_PERMISSIONS.open(id); + EdtResult out = NearbyRequests.openPermissionRequest(id); b.requestPermissions(id, bits); return out; } @@ -218,7 +218,9 @@ public static AsyncResource prepareSession( // Port entry points // ------------------------------------------------------------------ - /// Answers [#requestPermissions]. + /// Answers a permission request from ANY entry point in this family -- + /// ranging or transport. Ports report every permission outcome here, and + /// the pending map it reads is shared for that reason. /// /// @hidden not part of the public API; called by ports. /// @@ -228,7 +230,7 @@ public static AsyncResource prepareSession( /// - `granted`: whether every requested permission was granted public static void deliverPermissionResult(int requestId, boolean granted) { - EdtResult r = PENDING_PERMISSIONS.take(requestId); + EdtResult r = NearbyRequests.takePermissionRequest(requestId); if (r != null) { r.complete(Boolean.valueOf(granted)); } @@ -275,6 +277,7 @@ public static void deliverSessionPrepared(int requestId, /// - `sessionHandle`: the session that started public static void deliverSessionStarted(int requestId, int sessionHandle) { + untrackStarting(requestId); EdtResult r = PENDING_SESSIONS.take(requestId); if (r != null) { RangingSession s = RangingSession.lookup(sessionHandle); @@ -300,6 +303,7 @@ public static void deliverSessionStarted(int requestId, /// empty where the platform needs no handshake public static void deliverAccessoryConfiguration(int requestId, int sessionHandle, byte[] shareableConfiguration) { + untrackStarting(requestId); EdtResult r = PENDING_ACCESSORY.take(requestId); if (r != null) { RangingSession s = RangingSession.lookup(sessionHandle); @@ -329,15 +333,22 @@ public static void deliverRequestFailed(int requestId, int errorOrdinal, NearbyException ex = toException(errorOrdinal, message); EdtResult s = PENDING_SESSIONS.take(requestId); if (s != null) { + // A start that failed leaves the session prepared but idle, and + // it has to be told so: the flag that makes a concurrent start + // answer BUSY is set before the bridge call and cleared only on + // success, so without this the session answers BUSY to every + // retry forever. + releaseStarting(requestId); s.error(ex); return; } EdtResult a = PENDING_ACCESSORY.take(requestId); if (a != null) { + releaseStarting(requestId); a.error(ex); return; } - EdtResult p = PENDING_PERMISSIONS.take(requestId); + EdtResult p = NearbyRequests.takePermissionRequest(requestId); if (p != null) { p.error(ex); } @@ -356,15 +367,58 @@ public static void deliverRequestFailed(int requestId, int errorOrdinal, public static void resetForTest() { NearbyException reset = new NearbyException(NearbyError.UNKNOWN, "the nearby framework was reset"); - PENDING_PERMISSIONS.failAll(reset); + NearbyRequests.failPermissionRequests(reset); PENDING_SESSIONS.failAll(reset); PENDING_ACCESSORY.failAll(reset); + synchronized (STARTING) { + STARTING.clear(); + } } // ------------------------------------------------------------------ // Internals shared with RangingSession // ------------------------------------------------------------------ + /// Clears the in-progress flag of whichever session issued this request. + /// + /// Tracked by request id rather than by handle because the failure path + /// only ever learns the id: `NearbyBridge` answers a failed start through + /// `deliverRequestFailed(requestId, ...)`, which names no session. + private static void releaseStarting(int requestId) { + RangingSession s; + synchronized (STARTING) { + s = STARTING.remove(Integer.valueOf(requestId)); + } + if (s != null) { + s.markStartFailed(); + } + } + + /// The session behind each in-flight start, so a failure can find it. + /// + /// @hidden not part of the public API. + /// + /// #### Parameters + /// + /// - `requestId`: the id of the start being issued + /// - `session`: the session issuing it + static void trackStarting(int requestId, RangingSession session) { + synchronized (STARTING) { + STARTING.put(Integer.valueOf(requestId), session); + } + } + + /// Forgets a start that has settled. + /// + /// #### Parameters + /// + /// - `requestId`: the id of the start that finished + static void untrackStarting(int requestId) { + synchronized (STARTING) { + STARTING.remove(Integer.valueOf(requestId)); + } + } + static PendingMap pendingSessions() { return PENDING_SESSIONS; } diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java index 9d4bca3505b..6e355dbbb4d 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.java @@ -38,18 +38,23 @@ /// ``` public class RangingAdapter implements RangingListener { + @Override public void updated(RangingUpdate update) { } + @Override public void peerRemoved(RangingRemovalReason reason) { } + @Override public void suspended() { } + @Override public void resumed() { } + @Override public void invalidated(NearbyException error) { } } diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java index 45534394012..7f58f754924 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java @@ -105,6 +105,7 @@ public boolean isBackgroundRangingSupported() { return backgroundRanging; } + @Override public String toString() { return "RangingCapabilities[distance=" + distance + ", direction=" + direction diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java index 1fe068fcfb1..4ee0d3c21b9 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java @@ -117,6 +117,7 @@ public AsyncResource start(RangingToken peerToken) { int id = NearbyRequests.nextId(); EdtResult out = Ranging.pendingSessions().open(id); starting = true; + Ranging.trackStarting(id, this); b.startRanging(id, handle, peerToken.toByteArray()); return out; } @@ -161,6 +162,7 @@ public AsyncResource startAccessory( int id = NearbyRequests.nextId(); EdtResult out = Ranging.pendingAccessory().open(id); starting = true; + Ranging.trackStarting(id, this); b.startAccessoryRanging(id, handle, accessoryConfigurationData); return out; } @@ -241,11 +243,12 @@ public static void deliverUpdate(int sessionHandle, boolean hasDistance, hasDirection, azimuth, hasElevation, elevation, vector, System.currentTimeMillis()); NearbyRequests.onEdt(new Runnable() { + @Override public void run() { s.running = true; RangingListener[] ls = s.snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].updated(u); + for (RangingListener l : ls) { + l.updated(u); } } }); @@ -270,10 +273,11 @@ public static void deliverPeerRemoved(int sessionHandle, reasonOrdinal >= 0 && reasonOrdinal < all.length ? all[reasonOrdinal] : RangingRemovalReason.UNKNOWN; NearbyRequests.onEdt(new Runnable() { + @Override public void run() { RangingListener[] ls = s.snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].peerRemoved(reason); + for (RangingListener l : ls) { + l.peerRemoved(reason); } } }); @@ -292,11 +296,12 @@ public static void deliverSuspended(int sessionHandle) { return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { s.running = false; RangingListener[] ls = s.snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].suspended(); + for (RangingListener l : ls) { + l.suspended(); } } }); @@ -315,11 +320,12 @@ public static void deliverResumed(int sessionHandle) { return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { s.running = true; RangingListener[] ls = s.snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].resumed(); + for (RangingListener l : ls) { + l.resumed(); } } }); @@ -347,6 +353,7 @@ public static void deliverInvalidated(int sessionHandle, int errorOrdinal, } final NearbyException ex = Ranging.toException(errorOrdinal, message); NearbyRequests.onEdt(new Runnable() { + @Override public void run() { s.running = false; s.closed = true; @@ -354,8 +361,8 @@ public void run() { synchronized (s.listeners) { s.listeners.clear(); } - for (int i = 0; i < ls.length; i++) { - ls[i].invalidated(ex); + for (RangingListener l : ls) { + l.invalidated(ex); } } }); @@ -401,6 +408,17 @@ void markRunning() { starting = false; } + /// Clears the in-progress flag after a start that failed. + /// + /// Without this a session whose [#start] was rejected -- a corrupt token, + /// a peer that had already gone -- stayed `starting` forever, so every + /// retry answered `BUSY` and the prepared session was unusable for good. + /// The obvious retry after a bad token exchange is exactly the case that + /// hit it. + void markStartFailed() { + starting = false; + } + private RangingListener[] snapshot() { synchronized (listeners) { return listeners.toArray(new RangingListener[listeners.size()]); diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java index a847e9b7fec..f0c56fe8aa2 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java @@ -205,6 +205,7 @@ public static RangingToken forPayload(int platform, byte[] payload) { payload == null ? new byte[0] : payload); } + @Override public boolean equals(Object o) { if (this == o) { return true; @@ -224,14 +225,16 @@ public boolean equals(Object o) { return true; } + @Override public int hashCode() { int h = platform; - for (int i = 0; i < payload.length; i++) { - h = h * 31 + payload[i]; + for (byte b : payload) { + h = h * 31 + b; } return h; } + @Override public String toString() { return "RangingToken[platform=" + platform + ", " + payload.length + " bytes]"; diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java index c309a2d262f..e0a1b87a40c 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUnit.java @@ -43,7 +43,7 @@ public enum RangingUnit { private final double metersPerUnit; - private RangingUnit(double metersPerUnit) { + RangingUnit(double metersPerUnit) { this.metersPerUnit = metersPerUnit; } diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java index 2f562ad92a5..8a346246974 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java @@ -163,6 +163,7 @@ public long getTimestamp() { return timestamp; } + @Override public String toString() { StringBuilder b = new StringBuilder("RangingUpdate["); if (hasDistance) { diff --git a/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java b/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java index 0c5c39c2e2a..75de161951e 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java +++ b/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java @@ -64,6 +64,7 @@ public String getServiceId() { return serviceId; } + @Override public boolean equals(Object o) { if (this == o) { return true; @@ -75,10 +76,12 @@ public boolean equals(Object o) { return id == null ? e.id == null : id.equals(e.id); } + @Override public int hashCode() { return id == null ? 0 : id.hashCode(); } + @Override public String toString() { return "Endpoint[" + id + ", " + name + "]"; } diff --git a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java index 31944b59d27..47429482a95 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java +++ b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java @@ -110,6 +110,7 @@ public void reject() { } } + @Override public String toString() { return "IncomingConnection[" + endpoint + ", token=" + authenticationToken + "]"; diff --git a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java index 5c26c9ddf84..3385fe0f489 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -149,12 +149,16 @@ public static AsyncResource requestPermissions( } int bits = 0; if (permissions != null) { - for (int i = 0; i < permissions.length; i++) { - bits |= bitFor(permissions[i]); + for (NearbyPermission permission : permissions) { + bits |= bitFor(permission); } } int id = NearbyRequests.nextId(); - EdtResult out = PENDING.open(id); + // The SHARED permission map, not this class's own: a port answers + // every permission request through Ranging.deliverPermissionResult + // whichever entry point asked, so a request parked here would never + // be found and the caller would wait forever. + EdtResult out = NearbyRequests.openPermissionRequest(id); b.requestPermissions(id, bits); return out; } @@ -435,9 +439,17 @@ public static void deliverRequestOk(int requestId) { /// - `message`: a human-readable detail, may be null public static void deliverRequestFailed(int requestId, int errorOrdinal, String message) { + NearbyException ex = NearbyWire.decodeError(errorOrdinal, message); EdtResult r = PENDING.take(requestId); if (r != null) { - r.error(NearbyWire.decodeError(errorOrdinal, message)); + r.error(ex); + return; + } + // A permission request lives in the shared map, so a port that fails + // one through this entry point still finds its caller. + EdtResult p = NearbyRequests.takePermissionRequest(requestId); + if (p != null) { + p.error(ex); } } @@ -457,13 +469,14 @@ public static void deliverEndpointFound(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { TransportListener[] ls = snapshot(); - for (int i = 0; i < ls.length; i++) { + for (TransportListener l : ls) { if (found) { - ls[i].endpointFound(e); + l.endpointFound(e); } else { - ls[i].endpointLost(e); + l.endpointLost(e); } } } @@ -486,12 +499,13 @@ public static void deliverConnectionRequested(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { IncomingConnection r = new IncomingConnection(e, authenticationToken); TransportListener[] ls = snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].connectionRequested(r); + for (TransportListener l : ls) { + l.connectionRequested(r); } if (!r.isAnswered()) { // Nobody was listening, so nobody will ever answer. The @@ -523,13 +537,14 @@ public static void deliverConnectionResult(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { TransportListener[] ls = snapshot(); - for (int i = 0; i < ls.length; i++) { + for (TransportListener l : ls) { if (connected) { - ls[i].connected(e); + l.connected(e); } else { - ls[i].connectionFailed(e, + l.connectionFailed(e, NearbyWire.decodeError(errorOrdinal, message)); } } @@ -551,10 +566,11 @@ public static void deliverDisconnected(String encodedEndpoint) { return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { TransportListener[] ls = snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].disconnected(e); + for (TransportListener l : ls) { + l.disconnected(e); } } }); @@ -581,14 +597,15 @@ public static void deliverPayloadReceived(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { Payload p = Payload.received(payloadId, payloadType == NearbyBridge.PAYLOAD_FILE ? Payload.TYPE_FILE : Payload.TYPE_BYTES, bytes, path); TransportListener[] ls = snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].payloadReceived(e, p); + for (TransportListener l : ls) { + l.payloadReceived(e, p); } } }); @@ -614,6 +631,7 @@ public static void deliverPayloadProgress(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { + @Override public void run() { PayloadStatus[] all = PayloadStatus.values(); PayloadStatus s = statusOrdinal >= 0 @@ -622,8 +640,8 @@ public void run() { PayloadTransferUpdate u = new PayloadTransferUpdate(payloadId, bytesTransferred, totalBytes, s); TransportListener[] ls = snapshot(); - for (int i = 0; i < ls.length; i++) { - ls[i].payloadProgress(e, u); + for (TransportListener l : ls) { + l.payloadProgress(e, u); } } }); diff --git a/CodenameOne/src/com/codename1/nearby/transport/Payload.java b/CodenameOne/src/com/codename1/nearby/transport/Payload.java index 54f80ea12b6..3454c8f7dbe 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/Payload.java +++ b/CodenameOne/src/com/codename1/nearby/transport/Payload.java @@ -128,6 +128,7 @@ public String getPath() { return path; } + @Override public String toString() { return "Payload[" + id + ", " + (type == TYPE_FILE ? "file " + path diff --git a/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java b/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java index d5015254173..c264dfd698d 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java +++ b/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java @@ -73,6 +73,7 @@ public PayloadStatus getStatus() { return status; } + @Override public String toString() { return "PayloadTransferUpdate[" + payloadId + ", " + bytesTransferred + "/" + totalBytes + ", " + status + "]"; diff --git a/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java b/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java index d3f0d56b2db..b2219a2ba91 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java +++ b/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.java @@ -28,27 +28,35 @@ /// interested in two events overrides two methods. public class TransportAdapter implements TransportListener { + @Override public void endpointFound(Endpoint endpoint) { } + @Override public void endpointLost(Endpoint endpoint) { } + @Override public void connectionRequested(IncomingConnection request) { } + @Override public void connected(Endpoint endpoint) { } + @Override public void connectionFailed(Endpoint endpoint, NearbyException error) { } + @Override public void disconnected(Endpoint endpoint) { } + @Override public void payloadReceived(Endpoint endpoint, Payload payload) { } + @Override public void payloadProgress(Endpoint endpoint, PayloadTransferUpdate update) { } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 8e25d8ff18a..e24579483cd 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -520,10 +520,15 @@ private static String profileFor(int profile) { case 1: return AssociationRequest.DEVICE_PROFILE_WATCH; case 2: - return Build.VERSION.SDK_INT >= 33 + // GLASSES is API 34 and COMPUTER is 33 -- not the other way + // round, which is the order the enum happens to declare them + // in. Passing the platform a profile string it does not know + // throws, so these two gates were checked against the SDK's + // own api-versions.xml rather than guessed from the ordinal. + return Build.VERSION.SDK_INT >= 34 ? AssociationRequest.DEVICE_PROFILE_GLASSES : null; case 3: - return Build.VERSION.SDK_INT >= 34 + return Build.VERSION.SDK_INT >= 33 ? AssociationRequest.DEVICE_PROFILE_COMPUTER : null; default: // GENERIC. Deliberately no profile at all rather than a diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 0062dc02211..eb15b98615d 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -23,6 +23,11 @@ package com.codename1.impl.android.nearby; import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.ui.Display; import com.codename1.nearby.NearbyAvailability; import com.codename1.nearby.NearbyError; @@ -48,6 +53,7 @@ import com.google.android.gms.tasks.OnSuccessListener; import java.io.File; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -115,12 +121,64 @@ public int getRangingCapabilities() { return 0; } - public void requestPermissions(int requestId, int permissionBits) { - // The manifest permissions are injected at build time and the runtime - // grants are asked for by the Codename One permission machinery when - // the first scan happens, so there is nothing to raise here. - com.codename1.nearby.ranging.Ranging.deliverPermissionResult(requestId, - true); + public void requestPermissions(final int requestId, int permissionBits) { + // Actually ask. Nearby Connections drives Bluetooth, BLE and Wi-Fi and + // refuses to start without the runtime grants, and nothing on the + // startAdvertising/startDiscovery path checks them -- so answering a + // blanket true here made requestPermissions resolve while the first + // real operation failed for a permission the user was never asked + // about. AndroidBluetooth.requestPermissions is the shape this + // follows, down to running the blocking check on the EDT. + final ArrayList perms = new ArrayList(); + if (Build.VERSION.SDK_INT >= 31) { + add(perms, "android.permission.BLUETOOTH_SCAN"); + add(perms, "android.permission.BLUETOOTH_ADVERTISE"); + add(perms, "android.permission.BLUETOOTH_CONNECT"); + } + if (Build.VERSION.SDK_INT >= 33) { + add(perms, "android.permission.NEARBY_WIFI_DEVICES"); + } else { + // Below 33 Nearby Connections genuinely needs a location grant -- + // it is not a scan-results technicality there, the API refuses to + // start without one. + add(perms, "android.permission.ACCESS_FINE_LOCATION"); + } + if (perms.isEmpty()) { + com.codename1.nearby.ranging.Ranging.deliverPermissionResult( + requestId, true); + return; + } + // checkForPermission blocks through invokeAndBlock and must run on the + // EDT. + Display.getInstance().callSerially(requestRunnable(requestId, perms)); + } + + /// Adds a permission the app has not already been granted. + private void add(ArrayList perms, String permission) { + if (context.checkSelfPermission(permission) + != PackageManager.PERMISSION_GRANTED) { + perms.add(permission); + } + } + + /// Static so the Runnable carries no synthetic outer reference, which + /// SpotBugs reports as SIC_INNER_SHOULD_BE_STATIC_ANON. + private static Runnable requestRunnable(final int requestId, + final ArrayList perms) { + return new Runnable() { + @Override + public void run() { + boolean all = true; + for (int i = 0; i < perms.size(); i++) { + all = AndroidImplementation.checkForPermission( + perms.get(i), + "This is required to find and connect to nearby" + + " devices") && all; + } + com.codename1.nearby.ranging.Ranging.deliverPermissionResult( + requestId, all); + } + }; } // ------------------------------------------------------------------ diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 10f884ebc01..a72acf4ea30 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -336,6 +336,42 @@ @interface CN1NearbyTransport : NSObject "chat" + NSString *name = (NSString *)entry; + if ([name hasPrefix:@"_"]) { + name = [name substringFromIndex:1]; + } + NSRange dot = [name rangeOfString:@"." options:NSBackwardsSearch]; + if (dot.location != NSNotFound) { + name = [name substringToIndex:dot.location]; + } + if ([name length] > 0 && ![out containsObject:name]) { + [out addObject:name]; + } + } + return out; +} + static NSString *cn1nbServiceType(NSString *serviceId) { NSMutableString *out = [NSMutableString stringWithCapacity:15]; NSString *lower = [serviceId lowercaseString]; @@ -545,6 +581,25 @@ - (void)browser:(MCNearbyServiceBrowser *)browser @end +/// True when the folded form of `serviceId` is one the Info.plist declared. +static BOOL cn1nbServiceTypeIsDeclared(NSString *serviceId) { + NSArray *declared = cn1nbDeclaredServiceTypes(); + return [declared containsObject:cn1nbServiceType(serviceId)]; +} + +/// The message an undeclared service type fails with. Names the hint to set, +/// because the developer cannot otherwise tell why discovery found nothing. +static NSString *cn1nbUndeclaredServiceMessage(NSString *serviceId) { + return [NSString stringWithFormat: + @"iOS only browses Bonjour service types declared in the app's " + @"Info.plist, and \"_%@._tcp\" is not one of them (declared: %@). " + @"Add \"%@\" to the ios.nearby.serviceType build hint, which " + @"accepts a comma-separated list.", + cn1nbServiceType(serviceId), + [cn1nbDeclaredServiceTypes() componentsJoinedByString:@", "], + serviceId == nil ? @"" : serviceId]; +} + static CN1NearbyTransport *cn1nbTransportInit(NSString *serviceId, NSString *localName) { if (cn1nbTransport == nil) { @@ -1148,6 +1203,11 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str #ifdef CN1_NEARBY_HAS_MPC @autoreleasepool { NSString *sid = toNSString(CN1_THREAD_STATE_PASS_ARG serviceId); + if (!cn1nbServiceTypeIsDeclared(sid)) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, + cn1nbUndeclaredServiceMessage(sid)); + return; + } NSString *name = toNSString(CN1_THREAD_STATE_PASS_ARG localName); CN1NearbyTransport *t = cn1nbTransportInit(sid, name); if (t.advertiser != nil) { @@ -1187,6 +1247,11 @@ void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_Strin #ifdef CN1_NEARBY_HAS_MPC @autoreleasepool { NSString *sid = toNSString(CN1_THREAD_STATE_PASS_ARG serviceId); + if (!cn1nbServiceTypeIsDeclared(sid)) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, + cn1nbUndeclaredServiceMessage(sid)); + return; + } CN1NearbyTransport *t = cn1nbTransportInit(sid, nil); if (t.browser != nil) { [t.browser stopBrowsingForPeers]; 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 7d4509871ee..63c990d481b 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 @@ -3770,12 +3770,15 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { if (!usesNearbyTransport) { new File(nearbyPackage, "AndroidNearbyTransport.java").delete(); } - if (!usesNearbyPresence) { - // Nothing binds it, and it extends an API 31 class; leaving it - // costs a compile against a service the manifest never names. - new File(nearbyPackage, - "CN1CompanionDeviceService.java").delete(); - } + // CN1CompanionDeviceService is deliberately NOT deleted for an + // app that skips presence observation. AndroidNearbyBackend calls + // its register/unregister unconditionally, the whole nearby + // package is excluded from the port jar, and no other definition + // exists -- so removing it made javac fail in the generated app + // for every ranging-only or transport-only build. It is a + // framework-only class that compiles against any modern SDK and + // costs an unused class in the dex; the manifest still names it + // only when presence is used, so nothing binds it otherwise. } if (!arSupport) { 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 27d8e959504..0f880d6f092 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 @@ -235,15 +235,28 @@ private void enableNearbyDefine(File buildinRes, String name) } } - /// The Bonjour service type MultipeerConnectivity will register under. + /// Every Bonjour service type this app may register, folded to what + /// MultipeerConnectivity will accept. /// - /// Derived from `ios.nearby.serviceType` when the developer set one, and - /// otherwise from the package name -- and folded through the same rule - /// `cn1nbServiceType` in CN1Nearby.m applies, because the value declared - /// in the plist has to be the value the runtime registers or iOS refuses - /// the browse. MultipeerConnectivity allows 1 to 15 characters of - /// lowercase ASCII letters, digits and non-adjacent hyphens, and raises - /// on anything else. + /// iOS 14 and later browse only the types declared in `NSBonjourServices`, + /// and a type that is missing produces a silent "no peers found" rather + /// than an error -- so what goes in the plist has to be a superset of what + /// the app passes to `startAdvertising`, and the build cannot see those + /// strings. + /// + /// `ios.nearby.serviceType` is therefore a comma-separated list of the + /// service ids the app uses, each folded here through exactly the rule + /// `cn1nbServiceType` in CN1Nearby.m applies. The runtime folds its own + /// argument the same way and checks the result against this list, failing + /// with an actionable message rather than browsing into the void. + /// + /// With no hint the package name is the only guess available, which is + /// right for an app whose service id is its package name and wrong for the + /// documented `startAdvertising("chat", ...)` -- so the caller logs the + /// derived value. + /// + /// MultipeerConnectivity allows 1 to 15 characters of lowercase ASCII + /// letters, digits and non-adjacent hyphens, and raises on anything else. /// /// #### Parameters /// @@ -251,16 +264,44 @@ private void enableNearbyDefine(File buildinRes, String name) /// /// #### Returns /// - /// a legal service type, never null or empty - static String bonjourServiceType(BuildRequest request) { + /// the folded service types, never empty and without duplicates + static java.util.List bonjourServiceTypes(BuildRequest request) { String declared = request.getArg("ios.nearby.serviceType", null); String source = declared != null && declared.trim().length() > 0 ? declared.trim() : request.getPackageName(); if (source == null) { source = ""; } + java.util.List out = new ArrayList(); + for (String entry : source.split(",")) { + String folded = foldBonjourServiceType(entry); + if (folded.length() > 0 && !out.contains(folded)) { + out.add(folded); + } + } + if (out.isEmpty()) { + out.add("cn1-nearby"); + } + return out; + } + + /// Folds one service id into a legal Bonjour service type. Must stay + /// identical to `cn1nbServiceType` in CN1Nearby.m; the two are compared by + /// NearbyBonjourServiceTypeTest. + /// + /// #### Parameters + /// + /// - `serviceId`: the id to fold + /// + /// #### Returns + /// + /// the folded type, or the empty string when nothing usable remains + static String foldBonjourServiceType(String serviceId) { + if (serviceId == null) { + return ""; + } StringBuilder out = new StringBuilder(); - String lower = source.toLowerCase(); + String lower = serviceId.toLowerCase(); for (int i = 0; i < lower.length() && out.length() < 15; i++) { char c = lower.charAt(i); if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { @@ -273,7 +314,7 @@ static String bonjourServiceType(BuildRequest request) { while (out.length() > 0 && out.charAt(out.length() - 1) == '-') { out.setLength(out.length() - 1); } - return out.length() == 0 ? "cn1-nearby" : out.toString(); + return out.toString(); } /// Escapes the three characters that cannot sit in plist text content. @@ -287,7 +328,7 @@ static String bonjourServiceType(BuildRequest request) { /// - `value`: the text /// /// #### Returns - /// + /// /// the escaped text private static String escapeNearbyPlistText(String value) { return value.replace("&", "&").replace("<", "<") @@ -4360,28 +4401,33 @@ public void usesClassMethod(String cls, String method) { enableNearbyDefine(buildinRes, "CN1_NEARBY_TRANSPORT"); // iOS 14 refuses a MultipeerConnectivity browse whose // Bonjour service types are not declared, and the refusal - // is a silent "no peers found" rather than an error. The - // service type is derived from the same id the app passes - // to startAdvertising, folded the way CN1Nearby.m folds - // it, so the two agree. - String serviceType = bonjourServiceType(request); - // Logged always, because the fold is lossy and silently - // so: com.example.chat and com.example.charts both become - // com-example-cha, and two apps sharing a service type - // discover each other's peers. A developer who sees this - // line can set ios.nearby.serviceType and stop guessing. - log("Nearby transport registers Bonjour service type _" - + serviceType + "._tcp / ._udp" - + (request.getArg("ios.nearby.serviceType", "") - .trim().length() > 0 - ? "" : " (derived from the package name;" - + " set ios.nearby.serviceType to" - + " choose it yourself)")); + // is a silent "no peers found" rather than an error. + java.util.List serviceTypes = + bonjourServiceTypes(request); + boolean hintSet = request + .getArg("ios.nearby.serviceType", "").trim() + .length() > 0; + // Logged always. iOS browses only what is declared here, + // and the runtime now REFUSES an undeclared type rather + // than browsing into the void -- so a developer whose + // service id is not on this line gets a build log that + // says what to add, instead of an app that finds no peers. + log("Nearby transport declares Bonjour service type(s) " + + serviceTypes + + (hintSet ? "" + : " (derived from the package name; set" + + " ios.nearby.serviceType to a" + + " comma-separated list of the service" + + " ids this app passes to" + + " startAdvertising)")); + String[] bonjour = new String[serviceTypes.size() * 2]; + for (int i = 0; i < serviceTypes.size(); i++) { + bonjour[i * 2] = "_" + serviceTypes.get(i) + "._tcp"; + bonjour[i * 2 + 1] = "_" + serviceTypes.get(i) + + "._udp"; + } declareNearbyPlistArray(request, "NSBonjourServices", - new String[] { - "_" + serviceType + "._tcp", - "_" + serviceType + "._udp" - }, + bonjour, "MultipeerConnectivity cannot browse without it"); } if (usesNearbyCompanion) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index e13911f405d..98a48fed0b3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -145,7 +145,13 @@ static String inject(String xPermissions, boolean ranging, out = addPermission(out, "android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND", ""); - if (tiramisu) { + // API 31, not 33. Gating this on the Tiramisu boundary + // left an Android 12/12L app unable to use the + // companion-device exemption when the platform woke its + // CN1CompanionDeviceService -- which is the whole point of + // observing presence. Verified against the SDK's own + // api-versions.xml, not inferred from the neighbours. + if (modern) { out = addPermission(out, "android.permission" + ".REQUEST_COMPANION_START_FOREGROUND_SERVICES" + "_FROM_BACKGROUND", ""); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java index 3252a1a22cb..768dbbbba18 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java @@ -24,6 +24,8 @@ import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -54,6 +56,13 @@ private static BuildRequest request(String packageName, String hint) { return r; } + /** The single folded type, asserting the hint produced exactly one. */ + private static String only(BuildRequest request) { + List all = IPhoneBuilder.bonjourServiceTypes(request); + assertEquals(1, all.size(), "expected one service type, got " + all); + return all.get(0); + } + private static void assertLegal(String type) { assertTrue(type.length() >= 1 && type.length() <= 15, "1 to 15 characters, got " + type.length() + " in " + type); @@ -66,7 +75,7 @@ private static void assertLegal(String type) { @Test void anExplicitHintIsUsedAsGiven() { - assertEquals("chat", IPhoneBuilder.bonjourServiceType( + assertEquals("chat", only( request("com.example.app", "chat"))); } @@ -74,7 +83,7 @@ void anExplicitHintIsUsedAsGiven() { void aReverseDnsPackageIsFoldedRatherThanRejected() { // Legal on Android and illegal here, which is exactly the case a // cross-platform app hits by writing the obvious thing. - String type = IPhoneBuilder.bonjourServiceType( + String type = only( request("com.example.chat", null)); assertLegal(type); // Sixteen characters folded, fifteen allowed -- so even this @@ -86,7 +95,7 @@ void aReverseDnsPackageIsFoldedRatherThanRejected() { @Test void anOverlongPackageIsTruncatedToTheLimit() { - String type = IPhoneBuilder.bonjourServiceType( + String type = only( request("com.example.someverylongapplicationname", null)); assertLegal(type); assertEquals(15, type.length()); @@ -96,14 +105,14 @@ void anOverlongPackageIsTruncatedToTheLimit() { void aTruncationThatLandsOnAHyphenDoesNotLeaveOne() { // "ab.cdefghijklm.x" folds to "ab-cdefghijklm-" at fifteen, and a // trailing hyphen is one of the things that makes the framework raise. - String type = IPhoneBuilder.bonjourServiceType( + String type = only( request("ab.cdefghijklm.x", null)); assertLegal(type); } @Test void runsOfIllegalCharactersCollapseToOneHyphen() { - String type = IPhoneBuilder.bonjourServiceType( + String type = only( request("com...example___app", null)); assertLegal(type); assertEquals("com-example-app", type); @@ -111,21 +120,21 @@ void runsOfIllegalCharactersCollapseToOneHyphen() { @Test void uppercaseIsLowered() { - assertEquals("mychat", IPhoneBuilder.bonjourServiceType( + assertEquals("mychat", only( request("com.example.app", "MyChat"))); } @Test void somethingWithNoUsableCharactersFallsBackRatherThanRaising() { - assertEquals("cn1-nearby", IPhoneBuilder.bonjourServiceType( + assertEquals("cn1-nearby", only( request("...", null))); - assertEquals("cn1-nearby", IPhoneBuilder.bonjourServiceType( + assertEquals("cn1-nearby", only( request(null, null))); } @Test void ablankHintFallsBackToThePackageRatherThanToTheDefault() { - assertEquals("com-example-app", IPhoneBuilder.bonjourServiceType( + assertEquals("com-example-app", only( request("com.example.app", " "))); } @@ -137,8 +146,62 @@ void everyFoldIsLegal() { "MiXeD.CaSe.Name", "x.y", "com.example.APP" }; for (String in : inputs) { - assertLegal(IPhoneBuilder.bonjourServiceType(request(in, null))); - assertLegal(IPhoneBuilder.bonjourServiceType(request("p", in))); + assertLegal(only(request(in, null))); + assertLegal(only(request("p", in))); + } + } + + @Test + void aCommaSeparatedHintDeclaresEveryServiceTheAppUses() { + // The point of the list: iOS browses only what the plist declared, and + // the build cannot see the strings an app passes to startAdvertising. + List types = IPhoneBuilder.bonjourServiceTypes( + request("com.example.app", "chat, files , telemetry")); + assertEquals(3, types.size()); + assertEquals("chat", types.get(0)); + assertEquals("files", types.get(1)); + assertEquals("telemetry", types.get(2)); + for (String t : types) { + assertLegal(t); + } + } + + @Test + void idsThatFoldToTheSameTypeAreDeclaredOnce() { + List types = IPhoneBuilder.bonjourServiceTypes( + request("com.example.app", "chat,chat,Chat")); + assertEquals(1, types.size()); + assertEquals("chat", types.get(0)); + } + + @Test + void theFoldIsTheSameOneTheRuntimeApplies() { + // CN1Nearby.m folds the service id an app passes at runtime and then + // checks the result against NSBonjourServices. If these two folds ever + // disagree the app browses a type the plist does not declare, and iOS + // answers with silence rather than an error -- so the build-side fold + // is exposed on its own and pinned here. + assertEquals("chat", IPhoneBuilder.foldBonjourServiceType("chat")); + assertEquals("com-example-cha", + IPhoneBuilder.foldBonjourServiceType("com.example.chat")); + assertEquals("mychat", IPhoneBuilder.foldBonjourServiceType("MyChat")); + assertEquals("", IPhoneBuilder.foldBonjourServiceType("...")); + assertEquals("", IPhoneBuilder.foldBonjourServiceType(null)); + } + + @Test + void everyDeclaredTypeIsLegalWhateverTheHintSays() { + String[] hints = { + "a,b,c", "com.example.app,chat", "-,--,x", "A,B", + "com.example.a-very-long-name-indeed,y", ",,,", "1.2.3" + }; + for (String hint : hints) { + List types = IPhoneBuilder.bonjourServiceTypes( + request("com.example.app", hint)); + assertTrue(!types.isEmpty(), "never empty for hint " + hint); + for (String t : types) { + assertLegal(t); + } } } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index d86d097cd20..dcd430bd4da 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -152,6 +152,23 @@ void watchingEarnsTheBackgroundPermissions() { + "_START_FOREGROUND_SERVICES_FROM_BACKGROUND")); } + @Test + void theForegroundServiceExemptionArrivesWithApi31NotApi33() { + // It is a companion permission since API 31. Gating it on 33 left an + // Android 12/12L app unable to start a foreground service when the + // platform woke its CompanionDeviceService, which is what observing + // presence is for. + String twelve = NearbyManifestFragments.inject("", false, false, true, + true, false, 31); + assertTrue(twelve.contains("android.permission.REQUEST_COMPANION" + + "_START_FOREGROUND_SERVICES_FROM_BACKGROUND")); + // Still absent below the API that has it. + String eleven = NearbyManifestFragments.inject("", false, false, true, + true, false, 30); + assertFalse(eleven.contains( + "REQUEST_COMPANION_START_FOREGROUND_SERVICES")); + } + @Test void theWatchProfilePermissionIsOptInAndModernOnly() { assertFalse(NearbyManifestFragments.inject("", false, false, true, diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 135d6286c59..bc8f3369f1d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -652,6 +652,44 @@ public void endpointFound(Endpoint e) { assertEquals(after, seen.get()); } + @Test + void transportPermissionsSettleRatherThanHanging() { + // Regression: NearbyTransport.requestPermissions parked its resource + // in the transport's own pending map while every bridge answers + // through Ranging.deliverPermissionResult, which only searched the + // ranging map. The id was dropped and the caller waited forever -- + // the exact failure the SPI documentation calls worse than an error. + assertTrue(value(NearbyTransport.requestPermissions( + NearbyPermission.DISCOVERY, NearbyPermission.CONNECT)) + .booleanValue()); + } + + @Test + void aFailedStartLeavesTheSessionUsable() { + // Regression: the flag that makes a concurrent start answer BUSY was + // set before the bridge call and cleared only on success, so a + // rejected token wedged the session permanently -- and retrying after + // a bad token exchange is the obvious thing to do. + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + assertFailedWith(NearbyError.INVALID_TOKEN, s.start( + RangingToken.forPayload(RangingToken.PLATFORM_APPLE_NI, + new byte[] {1, 2, 3}))); + // The retry must reach the bridge, not bounce off BUSY. + RangingSession started = value(s.start(peerToken())); + assertSame(s, started); + assertTrue(s.isRunning()); + } + + @Test + void aFailedAccessoryStartAlsoLeavesTheSessionUsable() { + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + assertFailedWith(NearbyError.INVALID_TOKEN, + s.startAccessory(new byte[0])); + assertNotNull(value(s.startAccessory(new byte[] {1, 2, 3}))); + } + // ------------------------------------------------------------------ // helpers // ------------------------------------------------------------------ From cc58488e2c2e86efebecf8045508b796d5cdc286 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:49:22 +0300 Subject: [PATCH 10/94] Nearby devices: document ios.nearby.serviceType as the list it now is The hint became a comma-separated list of the service ids an app passes to startAdvertising, because iOS browses only the Bonjour types declared in the Info.plist and the build cannot see those strings. The runtime now refuses an undeclared id with a message naming it, rather than browsing into the void, so the guide says to list them and says what happens when one is missing. vale, LanguageTool, asciidoctor, the paragraph check and the snippet validator all still report clean. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/Nearby-Devices.asciidoc | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index 7b4bab90216..baf0cf2f133 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -181,12 +181,20 @@ trust whoever answered first. Answer every `connectionRequested` without delay. A request that's never answered holds radio resources open on both sides until the far end times out. -Keep the service id short. On iOS it also becomes the Bonjour service type, -which the platform restricts to fifteen characters of lowercase letters, digits -and hyphens -- so a reverse-DNS string that's legal on Android is -folded to fit, and `com.example.chat` and `com.example.charts` fold to the same -thing. Set `ios.nearby.serviceType` yourself rather than letting two apps -discover each other's peers. The build log names the service type it registered. +*On iOS, list your service ids at build time.* The service id becomes a Bonjour +service type there, and iOS browses only the types an app declared in its +`Info.plist` -- a type that isn't declared produces no peers and no error. The +build can't see the strings you pass to `startAdvertising`, so name them in +`ios.nearby.serviceType` as a comma-separated list. Miss one and the call fails +with a message telling you which id to add, which beats an app that finds +nothing and says nothing. + +Keep each id short. The platform restricts the folded type to fifteen +characters of lowercase letters, digits and hyphens, so a reverse-DNS string +that's legal on Android is folded to fit -- and `com.example.chat` and +`com.example.charts` fold to the same thing, which would have two unrelated +apps discovering each other's peers. The build log names every type it +declared. Byte payloads are capped at `NearbyTransport.getMaxPayloadSize()`, a few kilobytes on both platforms; anything larger goes as a file payload, which @@ -219,7 +227,7 @@ the simulator drives instead. [options="header"] |=== | Hint | Default | What it does -| `ios.nearby.serviceType` | derived from the package name | The Bonjour service type the transport registers. Fifteen characters of lowercase letters, digits and hyphens. +| `ios.nearby.serviceType` | derived from the package name | Comma-separated list of the service ids the app passes to `startAdvertising`. Each is folded to a Bonjour service type and declared; the runtime refuses an id that isn't on the list. | `ios.nearby.accessoryServices` | unset | Comma-separated Bluetooth service UUIDs the association picker may discover. Required for the picker to find anything on iOS. | `ios.nearby.background` | `false` | Requests the `com.apple.developer.nearby-interaction` entitlement and the matching background mode. Enable the capability on the App ID first. | `android.nearby.watchProfile` | `false` | Declares the watch companion profile permission, for an app that associates with `CompanionProfile.WATCH`. From 0766c4f55d5b948a0284b29283082abc959127ae Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:06:54 +0300 Subject: [PATCH 11/94] Nearby devices: second review round Five more findings, all real. **Android permissions went to whichever backend happened to be loaded.** An app using ranging and transport had its discovery, advertise and connect bits answered by the UWB backend, which knows only UWB_RANGING, ignores the rest and reports success -- so the transport grants were never requested and the first advertise failed for a permission the user never saw. Splitting the request in two instead needs the two answers joined into the one result the caller waits on, which the SPI's single reply id cannot express. So the coordinator now owns the whole permission flow: these are platform permission strings and checkForPermission is in the always-compiled half of the port, so one list, one pass, one answer. **UWB_RANGING was inspected, never requested.** Same fix, same place: it is in the coordinator's list and goes through the real runtime request. The two backends' permission methods are now unreachable stubs that say so. **The iOS authentication token was fabricated from public metadata.** It hashed the two display names and the service type -- all of which a relay observes and can reproduce on both of its sessions, so it would have shown matching digits at both ends while relaying. The public API documents that comparison as the defence against exactly that, which makes a guessable token worse than none. MultipeerConnectivity exposes nothing to bind one to, so iOS now reports empty, and the API doc, the guide and the capability matrix all say so. **A rejected iOS invitation reported a disconnection.** MCSessionStateNotConnected covers both, and an app that was inviting waited forever for the connected or failed answer that never came. Peers that actually reached Connected are tracked, so only they can disconnect; everything else is a connection failure. **The iOS service type was cached forever.** Stopping discovery for one service and starting it for another carried on browsing the first. It is reassigned on every call now. Two on the daemon side. The companion foreground-service permission moves to API 31 (verified in the SDK's api-versions.xml, which also caught GLASSES and COMPUTER being swapped in my own profile gates -- 34 and 33, not the order the enum declares them). And legacy Play Services mode is refused for the nearby transport rather than compiling Nearby Connections against a 6.5.87 monolith that predates the API. The remaining comment asked for com.apple.developer.nearby-interaction on every ranging build. I disagree and the reasoning is in the code at the gate: the entitlement arrived in iOS 16 while foreground ranging shipped in 14, NIError declares no missing-entitlement code, and Apple documents the capability as permitting Nearby Interaction in the background. Injecting it unconditionally would fail codesigning for every app whose App ID lacks the capability, which is a far worse failure than one build hint. Local: 5267 core tests, 908 plugin tests, 503 daemon tests, PMD 0, SpotBugs 0 across core/android/ios/plugin, both daemon guards, and every prose gate. The iOS native still compiles clean in all four configurations plus tvOS. Co-Authored-By: Claude Opus 5 (1M context) --- .../nearby/transport/IncomingConnection.java | 10 +- .../android/nearby/AndroidNearbyBackend.java | 97 ++++++++++++++++--- .../nearby/AndroidNearbyTransport.java | 39 ++------ .../android/nearby/AndroidUwbRanging.java | 16 +-- Ports/iOSPort/nativeSources/CN1Nearby.m | 64 ++++++++---- docs/developer-guide/Nearby-Devices.asciidoc | 13 ++- .../builders/AndroidGradleBuilder.java | 19 ++++ .../com/codename1/builders/IPhoneBuilder.java | 44 +++++++-- 8 files changed, 219 insertions(+), 83 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java index 47429482a95..aa5ffbd28c7 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java +++ b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java @@ -70,9 +70,13 @@ public Endpoint getEndpoint() { return endpoint; } - /// The short string both devices compute from this connection. Identical - /// on both sides when nothing is in the middle. Never null; empty on a - /// platform that does not produce one. + /// The short string both devices derive from this connection's key + /// exchange. Identical on both sides when nothing is in the middle. + /// + /// Never null, and **empty on iOS**: MultipeerConnectivity offers no + /// material to bind a token to, and inventing one from the service name + /// and display names would produce matching digits at both ends of a + /// relay. Treat empty as "this platform cannot answer the question". public String getAuthenticationToken() { return authenticationToken; } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index e24579483cd..8405c5e7ae3 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -33,18 +33,21 @@ import android.content.Context; import android.content.Intent; import android.content.IntentSender; +import android.content.pm.PackageManager; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.net.MacAddress; import android.os.ParcelUuid; +import com.codename1.impl.android.AndroidImplementation; import com.codename1.impl.android.CodenameOneActivity; import com.codename1.impl.android.IntentResultListener; import com.codename1.nearby.NearbyAvailability; import com.codename1.nearby.NearbyError; import com.codename1.nearby.companion.CompanionDevices; import com.codename1.nearby.spi.NearbyBridge; +import com.codename1.ui.Display; import java.util.ArrayList; import java.util.List; @@ -131,21 +134,91 @@ public int getCompanionAvailability() { } public void requestPermissions(int requestId, int permissionBits) { - // Delegated to whichever half is present: the permissions differ, and - // the ranging half is the one that knows about UWB_RANGING. - if (ranging != null) { - ranging.requestPermissions(requestId, permissionBits); - return; + // Owned here rather than delegated to the two optional backends, for + // two reasons that between them killed the previous arrangement. + // + // Delegating by "whichever half is loaded" handed an app that uses + // both its discovery, advertise and connect bits to the UWB backend, + // which knows only UWB_RANGING, ignores the rest and reports success + // -- so the transport grants were never requested and the first + // advertise failed for a permission the user never saw. Splitting the + // request in two instead needs the two answers joined into the single + // result the caller is waiting on, and neither backend can be asked + // for a partial answer through an SPI whose only reply path is a + // request id the caller owns. + // + // None of this needs an optional dependency: these are platform + // permission strings and AndroidImplementation.checkForPermission is + // in the always-compiled half of the port. So one list, one pass, one + // answer. + final ArrayList perms = new ArrayList(); + if ((permissionBits & NearbyBridge.PERMISSION_RANGING) != 0 + && Build.VERSION.SDK_INT >= 31) { + add(perms, "android.permission.UWB_RANGING"); + } + boolean transportBits = (permissionBits + & (NearbyBridge.PERMISSION_DISCOVERY + | NearbyBridge.PERMISSION_ADVERTISE + | NearbyBridge.PERMISSION_CONNECT)) != 0; + if (transportBits) { + if (Build.VERSION.SDK_INT >= 31) { + if ((permissionBits & NearbyBridge.PERMISSION_DISCOVERY) != 0) { + add(perms, "android.permission.BLUETOOTH_SCAN"); + } + if ((permissionBits & NearbyBridge.PERMISSION_ADVERTISE) != 0) { + add(perms, "android.permission.BLUETOOTH_ADVERTISE"); + } + if ((permissionBits & NearbyBridge.PERMISSION_CONNECT) != 0) { + add(perms, "android.permission.BLUETOOTH_CONNECT"); + } + } + if (Build.VERSION.SDK_INT >= 33) { + add(perms, "android.permission.NEARBY_WIFI_DEVICES"); + } else { + // Below 33 Nearby Connections genuinely refuses to start + // without a location grant; it is not a scan-results + // technicality there. + add(perms, "android.permission.ACCESS_FINE_LOCATION"); + } } - if (transport != null) { - transport.requestPermissions(requestId, permissionBits); + if (perms.isEmpty()) { + // Nothing left to ask for -- everything is already granted, or the + // request was for association, which needs no runtime permission + // on any Android version because the chooser IS the consent. + com.codename1.nearby.ranging.Ranging + .deliverPermissionResult(requestId, true); return; } - // Association needs no runtime permission on any Android version -- - // consent is the chooser itself -- so an app that only associates is - // told yes rather than left waiting. - com.codename1.nearby.ranging.Ranging.deliverPermissionResult(requestId, - true); + // checkForPermission blocks through invokeAndBlock and must run on the + // EDT. + Display.getInstance().callSerially( + permissionRunnable(requestId, perms)); + } + + /// Adds a permission the app has not already been granted. + private void add(ArrayList perms, String permission) { + if (activity.checkSelfPermission(permission) + != PackageManager.PERMISSION_GRANTED) { + perms.add(permission); + } + } + + /// Static so the Runnable carries no synthetic outer reference, which + /// SpotBugs reports as SIC_INNER_SHOULD_BE_STATIC_ANON. + private static Runnable permissionRunnable(final int requestId, + final ArrayList perms) { + return new Runnable() { + @Override + public void run() { + boolean all = true; + for (String permission : perms) { + all = AndroidImplementation.checkForPermission(permission, + "This is required to find nearby devices") && all; + } + com.codename1.nearby.ranging.Ranging.deliverPermissionResult( + requestId, all); + } + }; } // ------------------------------------------------------------------ diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index eb15b98615d..87715318d26 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -121,36 +121,15 @@ public int getRangingCapabilities() { return 0; } - public void requestPermissions(final int requestId, int permissionBits) { - // Actually ask. Nearby Connections drives Bluetooth, BLE and Wi-Fi and - // refuses to start without the runtime grants, and nothing on the - // startAdvertising/startDiscovery path checks them -- so answering a - // blanket true here made requestPermissions resolve while the first - // real operation failed for a permission the user was never asked - // about. AndroidBluetooth.requestPermissions is the shape this - // follows, down to running the blocking check on the EDT. - final ArrayList perms = new ArrayList(); - if (Build.VERSION.SDK_INT >= 31) { - add(perms, "android.permission.BLUETOOTH_SCAN"); - add(perms, "android.permission.BLUETOOTH_ADVERTISE"); - add(perms, "android.permission.BLUETOOTH_CONNECT"); - } - if (Build.VERSION.SDK_INT >= 33) { - add(perms, "android.permission.NEARBY_WIFI_DEVICES"); - } else { - // Below 33 Nearby Connections genuinely needs a location grant -- - // it is not a scan-results technicality there, the API refuses to - // start without one. - add(perms, "android.permission.ACCESS_FINE_LOCATION"); - } - if (perms.isEmpty()) { - com.codename1.nearby.ranging.Ranging.deliverPermissionResult( - requestId, true); - return; - } - // checkForPermission blocks through invokeAndBlock and must run on the - // EDT. - Display.getInstance().callSerially(requestRunnable(requestId, perms)); + public void requestPermissions(int requestId, int permissionBits) { + // AndroidNearbyBackend owns the permission flow for both halves: the + // strings are platform permissions, needing no optional dependency, + // and an app using ranging AND transport needs ONE answer covering + // both -- which no single backend can give. Reached only through that + // coordinator, so this is unreachable; it answers rather than hanging + // in case a future caller finds another way in. + com.codename1.nearby.ranging.Ranging.deliverPermissionResult(requestId, + true); } /// Adds a permission the app has not already been granted. diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index 73adcda9ba2..056c787fe29 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -167,14 +167,14 @@ public int getTransportAvailability() { } public void requestPermissions(int requestId, int permissionBits) { - boolean granted = true; - if ((permissionBits & NearbyBridge.PERMISSION_RANGING) != 0 - && Build.VERSION.SDK_INT >= 31) { - granted = context.checkSelfPermission( - "android.permission.UWB_RANGING") - == PackageManager.PERMISSION_GRANTED; - } - Ranging.deliverPermissionResult(requestId, granted); + // AndroidNearbyBackend owns the permission flow for both halves: the + // strings are platform permissions, needing no optional dependency, + // and an app using ranging AND transport needs ONE answer covering + // both -- which no single backend can give. Reached only through that + // coordinator, so this is unreachable; it answers rather than hanging + // in case a future caller finds another way in. + com.codename1.nearby.ranging.Ranging.deliverPermissionResult(requestId, + true); } // ------------------------------------------------------------------ diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index a72acf4ea30..549b022be4f 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -324,6 +324,7 @@ @interface CN1NearbyTransport : NSObject Date: Sun, 23 Aug 2026 16:17:49 +0300 Subject: [PATCH 12/94] Nearby devices: third review round Two real bugs and one push-back. **An Android file payload was handed to the app before it arrived.** Nearby Connections calls onPayloadReceived when a FILE transfer is ANNOUNCED, not when it completes -- the file on disk is partial at that point, and the app was also told about transfers that later failed or were cancelled. Incoming files are now held until the terminal update names them SUCCESS, which is what payloadReceived's complete-payload contract requires. **iOS advertising and browsing failures were dropped.** MultipeerConnectivity rejects both asynchronously, after startAdvertising has already resolved true, and the delegate discarded the error -- so an app believed it was advertising when it was not and no second signal was ever coming. Both request ids are held so the late failure can settle them, and cleared on stop so an unrelated error cannot fail a request that already settled. The third comment said Jetpack UWB reports azimuth and elevation in radians and asked for Math.toDegrees. It does not, and the conversion would turn a 90-degree bearing into 1.57. androidx.core.uwb's own KDoc on RangingPosition reads "The azimuth angle in degrees of the ranging device", and disassembling UwbClientSessionScopeAospImpl shows the backend float copied straight into the androidx measurement with no conversion anywhere in the library. The reasoning is in the code beside the delivery. Chasing that did turn up something real in the same lines, from the sentence right after the one quoted: Android's azimuth range is [-90, 90], not the [-180, 180] my public documentation promised. Apple's direction vector folds to the full circle and so distinguishes a peer in front from one behind; Android's azimuth cannot. RangingUpdate now says that instead of implying a range neither platform delivers. PMD 0, SpotBugs 0 across core, android and ios, 5267 core tests green, and the native still compiles clean in every configuration plus tvOS. Co-Authored-By: Claude Opus 5 (1M context) --- .../nearby/ranging/RangingUpdate.java | 20 +++--- .../nearby/AndroidNearbyTransport.java | 62 +++++++++++++------ .../android/nearby/AndroidUwbRanging.java | 15 +++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 34 +++++++++- 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java index 8a346246974..4e8bee22073 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java @@ -111,16 +111,22 @@ public boolean hasDirection() { return hasDirection; } - /// The horizontal angle to the peer in degrees, in the range -180 to - /// 180. Zero is straight ahead -- out of the top of a phone held - /// upright -- and positive is to the right. + /// The horizontal angle to the peer in degrees. Zero is straight ahead -- + /// out of the top of a phone held upright -- and positive is to the + /// right. /// /// Undefined when [#hasDirection()] is `false`. /// - /// Android reports this angle directly. On iOS the platform reports a - /// unit direction vector instead and the port converts it with - /// `atan2(x, -z)`, which is the same convention; [#getDirectionVector()] - /// still hands back the untouched vector for code that wants it. + /// **The range is platform-dependent, and the difference is meaningful.** + /// Apple reports a unit direction vector, which the port folds with + /// `atan2(x, -z)` into -180 to 180 -- so it distinguishes a peer in front + /// from one directly behind. Jetpack UWB reports the angle itself, in + /// degrees, but only over -90 to 90, which does not. Code that needs to + /// know which side of the device a peer is on cannot get that from + /// azimuth alone on Android. + /// + /// [#getDirectionVector()] still hands back Apple's untouched vector for + /// code that wants it. public double getAzimuth() { return azimuth; } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 87715318d26..a7382582405 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -73,6 +73,10 @@ public class AndroidNearbyTransport implements NearbyBridge { Collections.synchronizedMap(new HashMap()); private final Map payloadIds = Collections.synchronizedMap(new HashMap()); + /// Incoming FILE payloads between their announcement and the terminal + /// update that says the bytes actually arrived. + private final Map incomingFiles = + Collections.synchronizedMap(new HashMap()); private String serviceId = ""; private String localName = ""; @@ -320,6 +324,7 @@ public void stopAllTransport() { client().stopAllEndpoints(); endpointNames.clear(); payloadIds.clear(); + incomingFiles.clear(); } // ------------------------------------------------------------------ @@ -381,34 +386,31 @@ private PayloadCallback payloadCallback() { @Override public void onPayloadReceived(String endpointId, Payload payload) { if (payload.getType() == Payload.Type.BYTES) { + // A BYTES payload arrives complete -- Nearby delivers the + // whole array in this callback. NearbyTransport.deliverPayloadReceived( encode(endpointId, nameOf(endpointId)), (int) payload.getId(), NearbyBridge.PAYLOAD_BYTES, payload.asBytes(), null); return; } - if (payload.getType() == Payload.Type.FILE - && payload.asFile() != null) { - java.io.File f = null; - try { - f = payload.asFile().asJavaFile(); - } catch (Throwable t) { - // Older Play services return the file only through a - // ParcelFileDescriptor; nothing to hand the app then. - } - NearbyTransport.deliverPayloadReceived( - encode(endpointId, nameOf(endpointId)), - (int) payload.getId(), NearbyBridge.PAYLOAD_FILE, - null, f == null ? null - : "file://" + f.getAbsolutePath()); + if (payload.getType() == Payload.Type.FILE) { + // A FILE payload is ANNOUNCED here, not delivered: the + // transfer has only started and the file on disk is + // partial. Handing it to the app now breaks the + // complete-payload contract of payloadReceived -- a + // listener would read a half-written file, and would be + // told about one that later failed or was cancelled. + // Held until the terminal SUCCESS update names this id. + incomingFiles.put(Long.valueOf(payload.getId()), payload); } } @Override public void onPayloadTransferUpdate(String endpointId, PayloadTransferUpdate update) { - Integer mapped = payloadIds.get( - Long.valueOf(update.getPayloadId())); + Long key = Long.valueOf(update.getPayloadId()); + Integer mapped = payloadIds.get(key); int id = mapped == null ? (int) update.getPayloadId() : mapped.intValue(); NearbyTransport.deliverPayloadProgress( @@ -416,9 +418,33 @@ public void onPayloadTransferUpdate(String endpointId, update.getBytesTransferred(), update.getTotalBytes(), statusFor(update.getStatus()).ordinal()); if (update.getStatus() - != PayloadTransferUpdate.Status.IN_PROGRESS) { - payloadIds.remove(Long.valueOf(update.getPayloadId())); + == PayloadTransferUpdate.Status.IN_PROGRESS) { + return; + } + payloadIds.remove(key); + // The terminal update is where an incoming file becomes real. + // Anything other than SUCCESS means the app never hears about + // it, which is the point: a failed or cancelled transfer is + // not a payload. + Payload file = incomingFiles.remove(key); + if (file == null + || update.getStatus() + != PayloadTransferUpdate.Status.SUCCESS) { + return; + } + java.io.File f = null; + try { + if (file.asFile() != null) { + f = file.asFile().asJavaFile(); + } + } catch (Throwable t) { + // Older Play services expose the file only through a + // ParcelFileDescriptor; nothing to hand the app then. } + NearbyTransport.deliverPayloadReceived( + encode(endpointId, nameOf(endpointId)), + (int) file.getId(), NearbyBridge.PAYLOAD_FILE, null, + f == null ? null : "file://" + f.getAbsolutePath()); } }; } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index 056c787fe29..a0e66c11dcc 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -295,6 +295,21 @@ private static void deliver(int handle, RangingResult result) { } RangingPosition position = ((RangingResult.RangingResultPosition) result).getPosition(); + // Degrees already -- NOT radians, and NOT converted here. + // + // It was suggested these arrive in radians and need Math.toDegrees. + // androidx.core.uwb's own KDoc on RangingPosition says otherwise: + // "The azimuth angle in degrees of the ranging device", and the same + // for elevation. The library does no unit conversion of its own + // either -- disassembling UwbClientSessionScopeAospImpl shows the + // backend's float copied straight into androidx RangingMeasurement -- + // so whatever it is called, it is what the library documents. + // Converting would turn a 90-degree bearing into 1.57. + // + // The RANGES do differ from iOS and the portable documentation says + // so: Android reports azimuth in [-90, 90], which cannot tell a peer + // in front from one behind, while Apple's direction vector yields + // [-180, 180]. RangingMeasurement distance = position.getDistance(); RangingMeasurement azimuth = position.getAzimuth(); RangingMeasurement elevation = position.getElevation(); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 549b022be4f..7224048c72a 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -325,6 +325,8 @@ @interface CN1NearbyTransport : NSObject Date: Sun, 23 Aug 2026 16:27:21 +0300 Subject: [PATCH 13/94] Nearby devices: fourth review round Five findings, all real. **A short Android token payload read past the end of the array.** RangingToken.fromByteArray validates only the outer frame, so a peer could hand over a well-formed envelope with a two-byte payload and the decoder's unguarded readInt walked off it. An ArrayIndexOutOfBoundsException is not an IllegalArgumentException, so it escaped startRanging's handler into application code and left the start resource pending forever. Every read is bounds-checked now, and the message names the field that ran out. **Presence was silently lost on Android 12 and 12L.** The AssociationInfo callbacks are API 33; those releases deliver through onDeviceAppeared(String) and onDeviceDisappeared(String). startObservingPresence accepts API 31, so it reported the watch as accepted and then nothing ever arrived. Both String overloads are implemented -- the address IS the association id below 33, which is what the backend encodes there, so the two match without a lookup. **Every iOS byte payload arrived as id 0.** MultipeerConnectivity carries raw bytes and nothing else, so the sender's id was never transmitted and no app could tell two payloads apart or match one to its progress events, which Payload.getId() promises it can. The id is framed into four leading bytes and stripped on receipt; both ends of an MPC session are Codename One, so the framing is symmetric by construction. **requestConnection ignored the local name it was given on iOS.** The MCPeerID had been built at discovery time from the device name, and the invite path never looked at the argument -- so the ordinary discover-then-invite flow showed the peer the wrong name. MCPeerID is immutable and the session, advertiser and browser are all bound to it, so applying a new name is a rebuild of the lot; done only when nothing is connected, because renaming under a live session would drop it. **Stopping a session mid-accessory-handshake hung the caller.** startAccessory is answered from didGenerateShareableConfigurationData, and clearing the delegate silenced both that and didInvalidateWithError, so the AsyncResource waited forever. A pending start is failed before the delegate goes. PMD 0, SpotBugs 0 across core, android and ios, 5267 core tests green, and the native compiles clean in all four configurations plus tvOS. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/nearby/AndroidUwbRanging.java | 34 +++-- .../nearby/CN1CompanionDeviceService.java | 35 +++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 141 ++++++++++++++++-- 3 files changed, 187 insertions(+), 23 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index a0e66c11dcc..f3261c56bef 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -470,29 +470,36 @@ private static Peer decode(byte[] framed) { throw new IllegalArgumentException( "this token was minted by another platform"); } - int length = readInt(framed, 6); + int length = readInt(framed, 6, "payload length"); if (length < 0 || 10 + length > framed.length) { throw new IllegalArgumentException("truncated ranging token"); } + // Every read is bounds-checked, including the four-byte ints. + // RangingToken.fromByteArray only validates the OUTER frame, so a + // peer can hand over a well-formed envelope whose payload is two + // bytes long -- and an ArrayIndexOutOfBoundsException from in here + // is not an IllegalArgumentException, so it would escape + // startRanging's handler into application code and leave the start + // resource pending forever. Peer peer = new Peer(); int p = 10; - int addressLength = readInt(framed, p); + int addressLength = readInt(framed, p, "address length"); p += 4; - if (addressLength < 0 || p + addressLength > framed.length) { + if (addressLength < 0 || addressLength > framed.length - p) { throw new IllegalArgumentException("truncated ranging token"); } peer.address = new byte[addressLength]; System.arraycopy(framed, p, peer.address, 0, addressLength); p += addressLength; - peer.channel = readInt(framed, p); + peer.channel = readInt(framed, p, "channel"); p += 4; - peer.preamble = readInt(framed, p); + peer.preamble = readInt(framed, p, "preamble index"); p += 4; - peer.sessionId = readInt(framed, p); + peer.sessionId = readInt(framed, p, "session id"); p += 4; - int keyLength = readInt(framed, p); + int keyLength = readInt(framed, p, "session key length"); p += 4; - if (keyLength < 0 || p + keyLength > framed.length) { + if (keyLength < 0 || keyLength > framed.length - p) { throw new IllegalArgumentException("truncated ranging token"); } peer.sessionKey = new byte[keyLength]; @@ -509,7 +516,16 @@ private static int writeInt(byte[] b, int p, int v) { return p + 4; } - private static int readInt(byte[] b, int p) { + /// Reads four bytes, refusing rather than running off the end. + /// + /// The `what` is in the message because a truncated token is something a + /// developer debugging an out-of-band exchange has to locate, and "field + /// X starts past the end" says where to look. + private static int readInt(byte[] b, int p, String what) { + if (p < 0 || p > b.length - 4) { + throw new IllegalArgumentException( + "truncated ranging token: " + what + " starts past the end"); + } return ((b[p] & 0xff) << 24) | ((b[p + 1] & 0xff) << 16) | ((b[p + 2] & 0xff) << 8) | (b[p + 3] & 0xff); } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index 82a3689d206..046a7fbbb07 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -84,6 +84,41 @@ public void onDeviceDisappeared(AssociationInfo associationInfo) { deliver(associationInfo, false); } + /// The API 31 and 32 form of the same event. + /// + /// The AssociationInfo overloads above arrived in API 33, and + /// startObservingPresence accepts 31 and later -- so on Android 12 and 12L + /// the platform called these and the two above were never invoked, losing + /// every appearance and disappearance while still reporting the watch as + /// accepted. Deprecated upstream, and overridden anyway, because those two + /// releases have no other delivery path. + @Override + public void onDeviceAppeared(String address) { + deliverByAddress(address, true); + } + + @Override + public void onDeviceDisappeared(String address) { + deliverByAddress(address, false); + } + + /// Delivers an event that names only a MAC address. + /// + /// The address IS the association id below API 33 -- that is what + /// AndroidNearbyBackend encodes there, having no AssociationInfo to take + /// an id from -- so no lookup is needed to match the two up. + private void deliverByAddress(String address, boolean present) { + if (address == null) { + return; + } + if (!OBSERVED.isEmpty() && !OBSERVED.contains(address)) { + return; + } + String encoded = sanitize(address) + '\t' + sanitize(address) + '\t' + + sanitize(address) + "\t0\t" + (present ? '1' : '0'); + CompanionDevices.deliverPresenceChanged(encoded, present); + } + private void deliver(AssociationInfo info, boolean present) { if (info == null || Build.VERSION.SDK_INT < 31) { return; diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 7224048c72a..b4e115700bf 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -492,9 +492,23 @@ - (void)session:(MCSession *)session didReceiveData:(NSData *)data fromPeer:(MCPeerID *)peerID { @autoreleasepool { NSString *encoded = [self encodePeer:peerID]; + // MultipeerConnectivity carries raw bytes and nothing else, so the + // sender's payload id is framed into the first four bytes and stripped + // here. Without it every received payload arrived as id 0 and no app + // could tell two of them apart, or match one to its progress events -- + // which Payload.getId() promises it can. Both ends of an MPC session + // are Codename One, so the framing is symmetric by construction. + JAVA_INT payloadId = 0; + NSData *body = data; + if ([data length] >= 4) { + const unsigned char *b = (const unsigned char *)[data bytes]; + payloadId = (JAVA_INT)((b[0] << 24) | (b[1] << 16) | (b[2] << 8) + | b[3]); + body = [data subdataWithRange:NSMakeRange(4, [data length] - 4)]; + } com_codename1_impl_ios_IOSNearbyCallbacks_payloadReceived___java_lang_String_int_int_byte_1ARRAY_java_lang_String( - getThreadLocalData(), cn1nbJString(encoded), 0, - CN1_NEARBY_PAYLOAD_BYTES, cn1nbJBytes(data), JAVA_NULL); + getThreadLocalData(), cn1nbJString(encoded), payloadId, + CN1_NEARBY_PAYLOAD_BYTES, cn1nbJBytes(body), JAVA_NULL); } } @@ -641,6 +655,77 @@ static BOOL cn1nbServiceTypeIsDeclared(NSString *serviceId) { serviceId == nil ? @"" : serviceId]; } +/// Gives the local peer the name the caller asked for. +/// +/// MCPeerID is immutable and the session, advertiser and browser are all bound +/// to it, so a rename is a rebuild of the lot. Only done when nothing is +/// connected: renaming under a live session would drop it, and an app that +/// passes a different name to a later call did not ask for that. +/// +/// This exists because an app that discovers first and names itself only in +/// requestConnection -- the ordinary initiator flow -- showed the peer its +/// device name instead, the identity having been built at discovery time. +static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { + NSString *wanted = localName == nil || [localName length] == 0 + ? nil : localName; + // MCPeerID rejects a display name longer than 63 UTF-8 bytes. + if (wanted != nil + && [wanted lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) { + wanted = [wanted substringToIndex:20]; + } + if (t.localPeer != nil && wanted != nil + && ![t.localPeer.displayName isEqualToString:wanted] + && [t.session.connectedPeers count] == 0) { + BOOL wasAdvertising = t.advertiser != nil; + BOOL wasBrowsing = t.browser != nil; + if (wasAdvertising) { + [t.advertiser stopAdvertisingPeer]; + t.advertiser.delegate = nil; + t.advertiser = nil; + } + if (wasBrowsing) { + [t.browser stopBrowsingForPeers]; + t.browser.delegate = nil; + t.browser = nil; + } + t.session.delegate = nil; + [t.session disconnect]; + t.session = nil; + t.localPeer = [[[MCPeerID alloc] initWithDisplayName:wanted] + autorelease]; + t.session = [[[MCSession alloc] initWithPeer:t.localPeer + securityIdentity:nil + encryptionPreference:MCEncryptionRequired] + autorelease]; + t.session.delegate = t; + if (wasAdvertising) { + t.advertiser = [[[MCNearbyServiceAdvertiser alloc] + initWithPeer:t.localPeer + discoveryInfo:nil + serviceType:t.serviceType] autorelease]; + t.advertiser.delegate = t; + [t.advertiser startAdvertisingPeer]; + } + if (wasBrowsing) { + t.browser = [[[MCNearbyServiceBrowser alloc] + initWithPeer:t.localPeer + serviceType:t.serviceType] autorelease]; + t.browser.delegate = t; + [t.browser startBrowsingForPeers]; + } + return; + } + if (t.localPeer == nil) { + NSString *name = wanted != nil ? wanted + : [[UIDevice currentDevice] name]; + if ([name lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) { + name = [name substringToIndex:20]; + } + t.localPeer = [[[MCPeerID alloc] initWithDisplayName:name] + autorelease]; + } +} + static CN1NearbyTransport *cn1nbTransportInit(NSString *serviceId, NSString *localName) { if (cn1nbTransport == nil) { @@ -659,16 +744,7 @@ static BOOL cn1nbServiceTypeIsDeclared(NSString *serviceId) { // caller passed is the one that takes effect. cn1nbTransport.serviceType = cn1nbServiceType(serviceId); } - if (cn1nbTransport.localPeer == nil) { - NSString *name = localName == nil || [localName length] == 0 - ? [[UIDevice currentDevice] name] : localName; - // MCPeerID rejects a display name longer than 63 UTF-8 bytes. - if ([name lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) { - name = [name substringToIndex:20]; - } - cn1nbTransport.localPeer = - [[[MCPeerID alloc] initWithDisplayName:name] autorelease]; - } + cn1nbApplyLocalName(cn1nbTransport, localName); if (cn1nbTransport.session == nil) { cn1nbTransport.session = [[[MCSession alloc] initWithPeer:cn1nbTransport.localPeer @@ -1056,6 +1132,21 @@ void com_codename1_impl_ios_IOSNative_nearbyStopSession___int( @autoreleasepool { CN1NearbyRangingSession *entry = cn1nbSessionFor(sessionHandle); if (entry != nil) { + // A start still waiting for its answer has to be failed FIRST. + // startAccessory is answered from + // didGenerateShareableConfigurationData, and clearing the + // delegate below silences both that and + // didInvalidateWithError -- so stopping mid-handshake left the + // caller's AsyncResource pending with nothing left alive to + // settle it. + int pending = entry.pendingStartRequest; + entry.pendingStartRequest = 0; + if (pending != 0) { + cn1nbFailRanging(pending, + CN1_NEARBY_ERR_SESSION_INVALIDATED, + @"the session was stopped before the accessory" + @" handshake completed"); + } // Cleared before invalidate so the delegate callback that // invalidation triggers finds nothing left to report -- the // app asked for this and does not need to be told. @@ -1354,6 +1445,16 @@ void com_codename1_impl_ios_IOSNative_nearbyRequestConnection___int_java_lang_St @"no such endpoint"); return; } + // The name the caller wants the invited peer to see. Applied before + // the invitation goes out, or it would carry the previous identity. + cn1nbApplyLocalName(cn1nbTransport, + toNSString(CN1_THREAD_STATE_PASS_ARG localName)); + peer = [cn1nbTransport peerForId:pid]; + if (peer == nil || cn1nbTransport.browser == nil) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"the endpoint was lost while renaming this device"); + return; + } [cn1nbTransport.browser invitePeer:peer toSession:cn1nbTransport.session withContext:nil @@ -1458,9 +1559,21 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i return; } NSData *data = cn1nbDataFromJavaArray(bytes); + // Framed with the payload id -- see didReceiveData for why. + NSMutableData *framed = [NSMutableData dataWithCapacity: + (data == nil ? 0 : [data length]) + 4]; + unsigned char header[4] = { + (unsigned char)((payloadId >> 24) & 0xff), + (unsigned char)((payloadId >> 16) & 0xff), + (unsigned char)((payloadId >> 8) & 0xff), + (unsigned char)(payloadId & 0xff) + }; + [framed appendBytes:header length:4]; + if (data != nil) { + [framed appendData:data]; + } NSError *err = nil; - BOOL sent = [cn1nbTransport.session sendData:data == nil - ? [NSData data] : data + BOOL sent = [cn1nbTransport.session sendData:framed toPeers:peers withMode:MCSessionSendDataReliable error:&err]; From 9c394baa9e56730d0d520546cc6c614298a3fc96 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:39:02 +0300 Subject: [PATCH 14/94] Nearby devices: fifth review round Six findings, all real. **One MCSession per peer on iOS, so disconnect can actually disconnect.** MCSession has no per-peer disconnect -- `disconnect` tears the whole thing down -- so a single shared session made NearbyTransport.disconnect(endpoint) impossible to honour once two peers were connected: it dropped nobody and reported nothing, quietly breaking a method the API documents as dropping one endpoint. Sessions are keyed by endpoint id now, which is the arrangement MultipeerConnectivity actually supports for this, and costs a dictionary. **Android received payloads carried Google's id, not the sender's.** Payload.getId() documents the sender's id, and Nearby Connections mints its own on each side -- so ids could collide and no receiver could match a payload to its progress events. The sender's id is framed into four leading bytes exactly as the iOS transport now does, and getMaxPayloadSize() reports the limit less that header so an app that respects it is never rejected for a header it never knew about. **A multi-recipient send lost its id mapping after the first recipient.** Nearby reports a terminal update per endpoint under one payload id; dropping the mapping on the first meant later recipients' progress came back under Google's id and cancel() could no longer reach transfers still running. Recipients are counted down instead. **A cancelled prepareSession leaked a radio session.** PendingMap.take hands back a cancelled resource just the same, and completing one is a no-op -- so the session was built, registered in two places, and handed to nobody, leaving something alive that no caller could stop. **UWB_RANGING is declared whatever the target SDK is.** targetSdkVersion picks compatibility behaviours, not the device: an app targeting 30 still runs on an Android 12 phone with a UWB radio, where the runtime request fails unless the manifest declares it. Older devices ignore permissions they do not know. **The Bonjour fold uses Locale.ROOT.** In a Turkish locale toLowerCase() maps ASCII 'I' to dotless 'i', which the ASCII filter then drops -- folding "PING" to "p-ng". The device folds with its own locale, so a build server in tr_TR would declare a service type no device registers. PMD 0 and SpotBugs 0 across core, android, ios and the plugin; 5267 core tests and 503 daemon tests green; every native configuration plus tvOS compiles clean and all 29 natives still resolve. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/nearby/ranging/Ranging.java | 8 +- .../nearby/AndroidNearbyTransport.java | 62 +++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 134 +++++++++++++----- .../com/codename1/builders/IPhoneBuilder.java | 8 +- .../builders/NearbyManifestFragments.java | 15 +- .../builders/NearbyManifestFragmentsTest.java | 19 ++- 6 files changed, 186 insertions(+), 60 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java index 0ce98406fa8..db847412274 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -252,9 +252,11 @@ public static void deliverSessionPrepared(int requestId, int sessionHandle, boolean role, int tokenPlatform, byte[] tokenPayload) { EdtResult r = PENDING_SESSIONS.take(requestId); - if (r == null) { - // Nobody is waiting: the caller cancelled, or a port answered - // twice. Release the radio rather than leaking the session. + // Cancelled counts as nobody waiting. take() hands back a cancelled + // resource just the same, and completing one is a no-op -- so building + // the session here registered it in two places and handed the caller + // nothing, leaving a radio session alive that no one could stop. + if (r == null || r.isCancelled()) { NearbyBridge b = NearbyRequests.bridge(); if (b != null) { b.stopRangingSession(sessionHandle); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index a7382582405..ecb0ce26dbd 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -66,7 +66,13 @@ public class AndroidNearbyTransport implements NearbyBridge { /// The Nearby Connections limit for a BYTES payload. - private static final int MAX_BYTES_PAYLOAD = 32 * 1024; + private static final int NEARBY_BYTES_LIMIT = 32 * 1024; + + /// What an app may actually send: the limit less the four-byte payload-id + /// header this transport frames in. Reported rather than the raw limit, + /// because an app that respects getMaxPayloadSize() must not then be + /// rejected by Nearby for the header it never knew about. + private static final int MAX_BYTES_PAYLOAD = NEARBY_BYTES_LIMIT - 4; private final Context context; private final Map endpointNames = @@ -77,6 +83,10 @@ public class AndroidNearbyTransport implements NearbyBridge { /// update that says the bytes actually arrived. private final Map incomingFiles = Collections.synchronizedMap(new HashMap()); + /// How many recipients of an outgoing payload have yet to reach a + /// terminal transfer state. + private final Map payloadRecipients = + Collections.synchronizedMap(new HashMap()); private String serviceId = ""; private String localName = ""; @@ -276,8 +286,21 @@ public void sendPayload(final int requestId, String[] endpointIds, } payload = Payload.fromFile(new File(p)); } else { - payload = Payload.fromBytes(bytes == null ? new byte[0] - : bytes); + // Framed with the sender's payload id. Nearby Connections + // mints its own id on each side, so without this the + // receiver saw Google's local id -- which is not the one the + // sender was handed, may collide, and cannot be matched to + // the sender's progress events. Payload.getId() documents + // the sender's id, so it has to travel with the bytes. Both + // ends are Codename One, so the framing is symmetric. + byte[] body = bytes == null ? new byte[0] : bytes; + byte[] framed = new byte[body.length + 4]; + framed[0] = (byte) ((payloadId >> 24) & 0xff); + framed[1] = (byte) ((payloadId >> 16) & 0xff); + framed[2] = (byte) ((payloadId >> 8) & 0xff); + framed[3] = (byte) (payloadId & 0xff); + System.arraycopy(body, 0, framed, 4, body.length); + payload = Payload.fromBytes(framed); } } catch (Exception e) { NearbyTransport.deliverRequestFailed(requestId, @@ -289,6 +312,8 @@ public void sendPayload(final int requestId, String[] endpointIds, // report progress against the id the app was handed. payloadIds.put(Long.valueOf(payload.getId()), Integer.valueOf(payloadId)); + payloadRecipients.put(Long.valueOf(payload.getId()), + Integer.valueOf(endpointIds.length)); java.util.List targets = java.util.Arrays.asList(endpointIds); client().sendPayload(targets, payload) .addOnSuccessListener(new OnSuccessListener() { @@ -324,6 +349,7 @@ public void stopAllTransport() { client().stopAllEndpoints(); endpointNames.clear(); payloadIds.clear(); + payloadRecipients.clear(); incomingFiles.clear(); } @@ -387,11 +413,22 @@ private PayloadCallback payloadCallback() { public void onPayloadReceived(String endpointId, Payload payload) { if (payload.getType() == Payload.Type.BYTES) { // A BYTES payload arrives complete -- Nearby delivers the - // whole array in this callback. + // whole array in this callback. The first four bytes are + // the sender's payload id; see sendPayload. + byte[] raw = payload.asBytes(); + int senderId = 0; + byte[] body = raw == null ? new byte[0] : raw; + if (body.length >= 4) { + senderId = ((body[0] & 0xff) << 24) + | ((body[1] & 0xff) << 16) + | ((body[2] & 0xff) << 8) | (body[3] & 0xff); + byte[] trimmed = new byte[body.length - 4]; + System.arraycopy(body, 4, trimmed, 0, trimmed.length); + body = trimmed; + } NearbyTransport.deliverPayloadReceived( encode(endpointId, nameOf(endpointId)), - (int) payload.getId(), NearbyBridge.PAYLOAD_BYTES, - payload.asBytes(), null); + senderId, NearbyBridge.PAYLOAD_BYTES, body, null); return; } if (payload.getType() == Payload.Type.FILE) { @@ -421,6 +458,19 @@ public void onPayloadTransferUpdate(String endpointId, == PayloadTransferUpdate.Status.IN_PROGRESS) { return; } + // Kept until EVERY recipient is done. One payload sent to + // several endpoints produces a terminal update per endpoint + // under the same Nearby id, so dropping the mapping on the + // first meant later recipients' progress was reported under + // Google's local id, and cancel() could no longer reach the + // transfers still running. + Integer left = payloadRecipients.get(key); + int remaining = left == null ? 0 : left.intValue() - 1; + if (remaining > 0) { + payloadRecipients.put(key, Integer.valueOf(remaining)); + return; + } + payloadRecipients.remove(key); payloadIds.remove(key); // The terminal update is where an incoming file becomes real. // Anything other than SUCCESS means the app never hears about diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index b4e115700bf..670fc790b74 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -318,7 +318,15 @@ - (void)session:(NISession *)session @interface CN1NearbyTransport : NSObject @property (nonatomic, retain) MCPeerID *localPeer; -@property (nonatomic, retain) MCSession *session; +/// One MCSession PER PEER, keyed by endpoint id. +/// +/// MCSession has no per-peer disconnect -- `disconnect` tears the whole thing +/// down -- so a single shared session made NearbyTransport.disconnect(endpoint) +/// impossible to honour once two peers were connected: it either dropped +/// everyone or, as it did, silently did nothing. A session per peer is the +/// arrangement MultipeerConnectivity actually supports for that, and it costs +/// only the dictionary: MCSession is cheap and the delegate is shared. +@property (nonatomic, retain) NSMutableDictionary *sessionsById; @property (nonatomic, retain) MCNearbyServiceAdvertiser *advertiser; @property (nonatomic, retain) MCNearbyServiceBrowser *browser; @property (nonatomic, retain) NSMutableDictionary *peersById; @@ -408,7 +416,7 @@ @implementation CN1NearbyTransport - (void)dealloc { [_localPeer release]; - [_session release]; + [_sessionsById release]; [_advertiser release]; [_browser release]; [_peersById release]; @@ -419,6 +427,55 @@ - (void)dealloc { [super dealloc]; } +/// The session for one endpoint, created on first use. +/// +/// The delegate is shared: MCSessionDelegate hands the session back on every +/// callback, and nothing here needs to know which one it was. +- (MCSession *)sessionFor:(NSString *)endpointId { + if (endpointId == nil) { + return nil; + } + MCSession *existing = [self.sessionsById objectForKey:endpointId]; + if (existing != nil) { + return existing; + } + MCSession *created = [[[MCSession alloc] initWithPeer:self.localPeer + securityIdentity:nil + encryptionPreference:MCEncryptionRequired] + autorelease]; + created.delegate = self; + [self.sessionsById setObject:created forKey:endpointId]; + return created; +} + +/// Drops one endpoint's session and forgets it. +- (void)closeSessionFor:(NSString *)endpointId { + MCSession *session = [self.sessionsById objectForKey:endpointId]; + if (session == nil) { + return; + } + [self.sessionsById removeObjectForKey:endpointId]; + session.delegate = nil; + [session disconnect]; +} + +/// How many peers are connected across every session. +- (NSUInteger)connectedPeerCount { + NSUInteger n = 0; + for (MCSession *session in [self.sessionsById allValues]) { + n += [session.connectedPeers count]; + } + return n; +} + +/// Drops every session. +- (void)closeAllSessions { + NSArray *keys = [self.sessionsById allKeys]; + for (NSString *key in keys) { + [self closeSessionFor:key]; + } +} + - (NSString *)encodePeer:(MCPeerID *)peer { NSString *pid = cn1nbIdForPeer(peer); [self.peersById setObject:peer forKey:pid]; @@ -675,7 +732,7 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { } if (t.localPeer != nil && wanted != nil && ![t.localPeer.displayName isEqualToString:wanted] - && [t.session.connectedPeers count] == 0) { + && [t connectedPeerCount] == 0) { BOOL wasAdvertising = t.advertiser != nil; BOOL wasBrowsing = t.browser != nil; if (wasAdvertising) { @@ -688,16 +745,9 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { t.browser.delegate = nil; t.browser = nil; } - t.session.delegate = nil; - [t.session disconnect]; - t.session = nil; + [t closeAllSessions]; t.localPeer = [[[MCPeerID alloc] initWithDisplayName:wanted] autorelease]; - t.session = [[[MCSession alloc] initWithPeer:t.localPeer - securityIdentity:nil - encryptionPreference:MCEncryptionRequired] - autorelease]; - t.session.delegate = t; if (wasAdvertising) { t.advertiser = [[[MCNearbyServiceAdvertiser alloc] initWithPeer:t.localPeer @@ -734,6 +784,7 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { cn1nbTransport.invitations = [NSMutableDictionary dictionary]; cn1nbTransport.progressByPayload = [NSMutableDictionary dictionary]; cn1nbTransport.everConnected = [NSMutableSet set]; + cn1nbTransport.sessionsById = [NSMutableDictionary dictionary]; } if (serviceId != nil) { // Reassigned on EVERY call, not just the first. Caching it meant @@ -745,13 +796,6 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { cn1nbTransport.serviceType = cn1nbServiceType(serviceId); } cn1nbApplyLocalName(cn1nbTransport, localName); - if (cn1nbTransport.session == nil) { - cn1nbTransport.session = [[[MCSession alloc] - initWithPeer:cn1nbTransport.localPeer - securityIdentity:nil - encryptionPreference:MCEncryptionRequired] autorelease]; - cn1nbTransport.session.delegate = cn1nbTransport; - } return cn1nbTransport; } @@ -1456,7 +1500,7 @@ void com_codename1_impl_ios_IOSNative_nearbyRequestConnection___int_java_lang_St return; } [cn1nbTransport.browser invitePeer:peer - toSession:cn1nbTransport.session + toSession:[cn1nbTransport sessionFor:pid] withContext:nil timeout:30]; cn1nbTransportOk(requestId); @@ -1482,7 +1526,7 @@ void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_Str return; } [cn1nbTransport.invitations removeObjectForKey:pid]; - handler(YES, cn1nbTransport.session); + handler(YES, [cn1nbTransport sessionFor:pid]); cn1nbTransportOk(requestId); return; } @@ -1515,7 +1559,7 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i JAVA_INT payloadType, JAVA_OBJECT bytes, JAVA_OBJECT path) { #ifdef CN1_NEARBY_HAS_MPC @autoreleasepool { - if (cn1nbTransport == nil || cn1nbTransport.session == nil) { + if (cn1nbTransport == nil) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, @"the transport is not running"); return; @@ -1523,10 +1567,12 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i NSString *joined = toNSString(CN1_THREAD_STATE_PASS_ARG joinedEndpointIds); NSMutableArray *peers = [NSMutableArray array]; + NSMutableArray *peerIds = [NSMutableArray array]; for (NSString *pid in cn1nbSplitLines(joined)) { MCPeerID *peer = [cn1nbTransport peerForId:pid]; if (peer != nil) { [peers addObject:peer]; + [peerIds addObject:pid]; } } if ([peers count] == 0) { @@ -1540,8 +1586,11 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i p = [p substringFromIndex:7]; } NSURL *url = [NSURL fileURLWithPath:p]; - for (MCPeerID *peer in peers) { - [cn1nbTransport.session sendResourceAtURL:url + for (NSUInteger i = 0; i < [peers count]; i++) { + MCPeerID *peer = [peers objectAtIndex:i]; + MCSession *session = [cn1nbTransport + sessionFor:[peerIds objectAtIndex:i]]; + [session sendResourceAtURL:url withName:[p lastPathComponent] toPeer:peer withCompletionHandler:^(NSError *error) { @@ -1572,11 +1621,24 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i if (data != nil) { [framed appendData:data]; } + // One send per peer, because each has its own session now. NSError *err = nil; - BOOL sent = [cn1nbTransport.session sendData:framed - toPeers:peers - withMode:MCSessionSendDataReliable - error:&err]; + BOOL sent = [peers count] > 0; + for (NSUInteger i = 0; i < [peers count]; i++) { + MCSession *session = [cn1nbTransport + sessionFor:[peerIds objectAtIndex:i]]; + NSError *one = nil; + if (![session sendData:framed + toPeers:[NSArray arrayWithObject: + [peers objectAtIndex:i]] + withMode:MCSessionSendDataReliable + error:&one]) { + sent = NO; + if (err == nil) { + err = one; + } + } + } if (!sent) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, [err localizedDescription]); @@ -1609,18 +1671,16 @@ void com_codename1_impl_ios_IOSNative_nearbyDisconnect___java_lang_String( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT endpointId) { #ifdef CN1_NEARBY_HAS_MPC @autoreleasepool { - if (cn1nbTransport == nil || cn1nbTransport.session == nil) { + if (cn1nbTransport == nil) { return; } - // MCSession disconnects as a whole rather than per peer, so a - // one-peer session is the only case this can honour precisely. The - // delegate reports the drop either way, so the app is told the truth. + // Drops exactly the endpoint asked for. With one shared MCSession this + // was impossible -- disconnect tears the whole thing down -- so it + // used to do nothing at all once a second peer connected, quietly + // breaking a method the public API documents as dropping one endpoint. + // Each peer has its own session now, so closing one closes one. NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); - MCPeerID *peer = [cn1nbTransport peerForId:pid]; - if (peer != nil - && [cn1nbTransport.session.connectedPeers count] <= 1) { - [cn1nbTransport.session disconnect]; - } + [cn1nbTransport closeSessionFor:pid]; } #endif } @@ -1642,7 +1702,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStopAllTransport__( cn1nbTransport.browser.delegate = nil; cn1nbTransport.browser = nil; } - [cn1nbTransport.session disconnect]; + [cn1nbTransport closeAllSessions]; [cn1nbTransport.invitations removeAllObjects]; } #endif 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 08b6eeca8cc..9dec9fc9f6c 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 @@ -301,7 +301,13 @@ static String foldBonjourServiceType(String serviceId) { return ""; } StringBuilder out = new StringBuilder(); - String lower = serviceId.toLowerCase(); + // Locale.ROOT, not the default locale. This is a protocol identifier, + // and in a Turkish locale toLowerCase() maps ASCII 'I' to dotless + // 'i' -- which the ASCII filter below then drops, folding "PING" to + // "p-ng". The device doing the runtime fold has its own locale, so a + // build server in tr_TR would declare a service type no device ever + // registers and the transport would find nobody. + String lower = serviceId.toLowerCase(java.util.Locale.ROOT); for (int i = 0; i < lower.length() && out.length() < 15; i++) { char c = lower.charAt(i); if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 98a48fed0b3..39437984ac9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -86,12 +86,15 @@ static String inject(String xPermissions, boolean ranging, boolean tiramisu = targetSdkVersion >= 33; if (ranging) { - // API 31 and later only. Declaring it below that is harmless but - // noisy, and an unknown permission in a manifest is the kind of - // thing a store review flags and a developer then has to explain. - if (modern) { - out = addPermission(out, "android.permission.UWB_RANGING", ""); - } + // Declared whatever the target SDK is. targetSdkVersion says + // which compatibility behaviours the app opts into, NOT which + // device it runs on -- and an app targeting 30 still runs on an + // Android 12 phone with a UWB radio, where the runtime request + // fails outright unless the manifest declares the permission. + // Older devices ignore a permission they have never heard of, so + // declaring it always costs nothing and gating it cost the + // feature on every build that had not yet raised its target. + out = addPermission(out, "android.permission.UWB_RANGING", ""); out = addFeature(out, "android.hardware.uwb", false); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index dcd430bd4da..a69a751269b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -67,15 +67,20 @@ void rangingPaysForRangingOnly() { } @Test - void uwbRangingIsNotDeclaredBelowTheApiThatHasIt() { - // The permission arrives in API 31. Declaring it on an older target - // is harmless and noisy, and a store review asks about it. - String out = NearbyManifestFragments.inject("", true, false, false, + void uwbRangingIsDeclaredWhateverTheTargetSdk() { + // targetSdkVersion picks compatibility behaviours, not the device. An + // app targeting 30 still runs on an Android 12 phone with a UWB radio, + // and there the runtime request fails unless the manifest declares + // this. Older devices ignore a permission they do not know. + String legacy = NearbyManifestFragments.inject("", true, false, false, false, false, 30); - assertFalse(out.contains("android.permission.UWB_RANGING")); - // The feature is still declared, because that is what keeps the app + assertTrue(legacy.contains("android.permission.UWB_RANGING")); + String modern = NearbyManifestFragments.inject("", true, false, false, + false, false, 34); + assertTrue(modern.contains("android.permission.UWB_RANGING")); + // The feature stays optional, because that is what keeps the app // installable on a device without the radio. - assertTrue(out.contains("android.hardware.uwb")); + assertTrue(legacy.contains("android.hardware.uwb")); } @Test From 0c83d165e2897d96970eb70f3726b0d53e7297b0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:48:05 +0300 Subject: [PATCH 15/94] Nearby devices: sixth review round **A remote peer could write outside the app container on iOS.** The resource name in an incoming MultipeerConnectivity file transfer is chosen by the sender, and it was appended straight to the Documents path -- so a name like "../../Library/Preferences/x" walked out of the directory, and the removeItemAtPath and move that follow would then delete and overwrite files elsewhere. Reduced to its last path component, rejected outright if anything separator-shaped survives, and the assembled path is checked to still sit under the directory it was built from. **Advertising and discovery shared one service id on Android.** An app advertising service B while discovering service A had the later call overwrite the field, so endpoints found under A were reported as B -- which Endpoint.getServiceId() documents as the service they were found under. The service is recorded per endpoint when it first appears now, from whichever side saw it. **Ranging reported AVAILABLE with the permission denied.** getAvailability exists precisely to separate "this device cannot" from "this device could if you asked", and UNAUTHORIZED is the answer that tells an app to request rather than hide the feature. It checked only the OS version and the hardware feature, so an app showed ranging as ready until session preparation failed. **REQUEST_COMPANION_PROFILE_WATCH is declared whatever the target SDK is** -- the same correction UWB_RANGING got last round, for the same reason: selecting DEVICE_PROFILE_WATCH needs the permission on an Android 12 device no matter what the app targets. The remaining comment restated the UWB_RANGING target gate, which the previous push had already removed. PMD 0 and SpotBugs 0 across core, android, ios and the plugin; 5267 core and 503 daemon tests green; every native configuration plus tvOS compiles clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../nearby/AndroidNearbyTransport.java | 31 +++++++++++++++---- .../android/nearby/AndroidUwbRanging.java | 17 ++++++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 24 +++++++++++++- .../builders/NearbyManifestFragments.java | 7 ++++- .../builders/NearbyManifestFragmentsTest.java | 9 +++--- 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index ecb0ce26dbd..6f7c176e073 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -88,7 +88,16 @@ public class AndroidNearbyTransport implements NearbyBridge { private final Map payloadRecipients = Collections.synchronizedMap(new HashMap()); - private String serviceId = ""; + /// The service each endpoint was found under. + /// + /// One shared field was wrong: an app advertising service B while + /// discovering service A had the later call overwrite it, and endpoints + /// found under A were then encoded as B -- which Endpoint.getServiceId() + /// documents as the service they were found under. + private final Map endpointServices = + Collections.synchronizedMap(new HashMap()); + private String advertisingServiceId = ""; + private String discoveryServiceId = ""; private String localName = ""; public AndroidNearbyTransport(Context context) { @@ -180,12 +189,12 @@ public void run() { public void startAdvertising(final int requestId, String serviceId, String localName, int strategy) { - this.serviceId = serviceId == null ? "" : serviceId; + this.advertisingServiceId = serviceId == null ? "" : serviceId; this.localName = localName == null ? "" : localName; AdvertisingOptions options = new AdvertisingOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); - client().startAdvertising(this.localName, this.serviceId, + client().startAdvertising(this.localName, this.advertisingServiceId, connectionCallback(), options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { @@ -207,11 +216,12 @@ public void stopAdvertising() { public void startDiscovery(final int requestId, String serviceId, int strategy) { - this.serviceId = serviceId == null ? "" : serviceId; + this.discoveryServiceId = serviceId == null ? "" : serviceId; DiscoveryOptions options = new DiscoveryOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); - client().startDiscovery(this.serviceId, discoveryCallback(), options) + client().startDiscovery(this.discoveryServiceId, discoveryCallback(), + options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { NearbyTransport.deliverRequestOk(requestId); @@ -348,6 +358,7 @@ public void disconnect(String endpointId) { public void stopAllTransport() { client().stopAllEndpoints(); endpointNames.clear(); + endpointServices.clear(); payloadIds.clear(); payloadRecipients.clear(); incomingFiles.clear(); @@ -363,6 +374,7 @@ private EndpointDiscoveryCallback discoveryCallback() { public void onEndpointFound(String endpointId, DiscoveredEndpointInfo info) { endpointNames.put(endpointId, info.getEndpointName()); + endpointServices.put(endpointId, discoveryServiceId); NearbyTransport.deliverEndpointFound( encode(endpointId, info.getEndpointName()), true); } @@ -382,6 +394,12 @@ private ConnectionLifecycleCallback connectionCallback() { public void onConnectionInitiated(String endpointId, ConnectionInfo info) { endpointNames.put(endpointId, info.getEndpointName()); + // An endpoint that arrives here without having been + // discovered came in through advertising, so that is the + // service it belongs to. + if (!endpointServices.containsKey(endpointId)) { + endpointServices.put(endpointId, advertisingServiceId); + } NearbyTransport.deliverConnectionRequested( encode(endpointId, info.getEndpointName()), info.getAuthenticationDigits()); @@ -546,8 +564,9 @@ private String nameOf(String endpointId) { } private String encode(String endpointId, String name) { + String service = endpointServices.get(endpointId); return sanitize(endpointId) + '\t' + sanitize(name) + '\t' - + sanitize(serviceId); + + sanitize(service == null ? "" : service); } private static String sanitize(String s) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index f3261c56bef..bbd2e9a5d7b 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -113,8 +113,21 @@ public boolean isRangingSupported() { } public int getRangingAvailability() { - return isRangingSupported() ? NearbyAvailability.AVAILABLE.ordinal() - : NearbyAvailability.NOT_SUPPORTED.ordinal(); + if (!isRangingSupported()) { + return NearbyAvailability.NOT_SUPPORTED.ordinal(); + } + // UNAUTHORIZED is the whole reason getAvailability() exists beside + // isSupported(): a phone with a UWB radio whose owner has not granted + // (or has revoked) UWB_RANGING is supported and unusable, and the + // documented answer tells the app to ask rather than to hide the + // feature. Reporting AVAILABLE here let an app show ranging as ready + // until session preparation failed. + if (Build.VERSION.SDK_INT >= 31 + && context.checkSelfPermission("android.permission.UWB_RANGING") + != PackageManager.PERMISSION_GRANTED) { + return NearbyAvailability.UNAUTHORIZED.ordinal(); + } + return NearbyAvailability.AVAILABLE.ordinal(); } public int getRangingCapabilities() { diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 670fc790b74..12ab8a2b6d5 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -591,10 +591,32 @@ - (void)session:(MCSession *)session // The URL the framework hands over is in a temporary location it will // delete, so the file is moved somewhere the app can still read when // the callback returns. + // resourceName is chosen by the REMOTE peer, so it is untrusted + // input. Appended raw, a name like "../../Library/Preferences/x" + // walked out of the app's Documents directory and the removeItem and + // move below would then delete and overwrite files elsewhere in the + // container. Reduced to its last path component, and anything that + // still looks like traversal or a separator is replaced outright. + NSString *safe = [resourceName lastPathComponent]; + if (safe == nil || [safe length] == 0 + || [safe isEqualToString:@"."] + || [safe isEqualToString:@".."] + || [safe rangeOfString:@"/"].location != NSNotFound) { + safe = @"payload"; + } NSString *docs = [NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]; NSString *target = [docs stringByAppendingPathComponent: - [NSString stringWithFormat:@"cn1nearby-%@", resourceName]]; + [NSString stringWithFormat:@"cn1nearby-%@", safe]]; + // Belt and braces: whatever the name folded to, the result has to + // stay inside the directory it was built from. + if (![[target stringByStandardizingPath] + hasPrefix:[docs stringByStandardizingPath]]) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), 0, 0, -1, + CN1_NEARBY_PAYLOAD_FAILURE); + return; + } [[NSFileManager defaultManager] removeItemAtPath:target error:nil]; NSError *moveError = nil; [[NSFileManager defaultManager] moveItemAtPath:[localURL path] diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 39437984ac9..5d114fc43ec 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -160,7 +160,12 @@ static String inject(String xPermissions, boolean ranging, + "_FROM_BACKGROUND", ""); } } - if (modern && watchProfile) { + if (watchProfile) { + // Declared whatever the target SDK is, for the reason + // UWB_RANGING above is: selecting DEVICE_PROFILE_WATCH needs + // this permission on an Android 12 device no matter what the + // app targets, and an app targeting 30 had the association + // rejected there. Older devices ignore it. out = addPermission(out, "android.permission.REQUEST_COMPANION_PROFILE_WATCH", ""); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index a69a751269b..b1227c1d89b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -175,17 +175,18 @@ void theForegroundServiceExemptionArrivesWithApi31NotApi33() { } @Test - void theWatchProfilePermissionIsOptInAndModernOnly() { + void theWatchProfilePermissionIsOptInButNotTargetGated() { assertFalse(NearbyManifestFragments.inject("", false, false, true, false, false, 34) .contains("REQUEST_COMPANION_PROFILE_WATCH")); assertTrue(NearbyManifestFragments.inject("", false, false, true, false, true, 34) .contains("android.permission.REQUEST_COMPANION_PROFILE_WATCH")); - // The permission arrives with the profiles, in API 31. - assertFalse(NearbyManifestFragments.inject("", false, false, true, + // Selecting DEVICE_PROFILE_WATCH needs this on an Android 12 device + // whatever the app targets, so a legacy target must still declare it. + assertTrue(NearbyManifestFragments.inject("", false, false, true, false, true, 30) - .contains("REQUEST_COMPANION_PROFILE_WATCH")); + .contains("android.permission.REQUEST_COMPANION_PROFILE_WATCH")); } @Test From c63a8cac26f9593eb6a40fe14e6663350144bac5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:02:06 +0300 Subject: [PATCH 16/94] Address the seventh nearby review round - iOS: retain the NSProgress returned by sendResourceAtURL in progressByPayload and cancel it from nearbyCancelPayload, so a cancelled file payload actually stops transferring instead of the cancel being a no-op on the Java side only. - Android: call releaseResultListener() from the association onFailure path and from the SendIntentException catch. Previously only the success path released it, so a failed association leaked the listener and the next association delivered its result to a stale one. - iOS: an association request with no filters now falls back to the NSAccessorySetupBluetoothServices array declared in the bundle rather than picking a session with an empty descriptor, which AccessorySetupKit rejects. - iOS + Android: frame file payloads as cn1id-- so the receiver can recover the sender's payload id. Android additionally sets payload.setFileName(...) and reads it back through senderIdOf(Payload). - iOS: Endpoint.serviceId now carries the caller's unfolded service id rather than the folded Bonjour service type, so an app comparing it against the id it passed to startAdvertising matches. - Builders: raise compileSdk to at least 36 when the ranging package is used. androidx.core.uwb declares minCompileSdk=36 in its AAR metadata, so a lower compileSdk fails the build with an AGP metadata error rather than anything that names UWB. --- .../android/nearby/AndroidNearbyBackend.java | 19 +++ .../nearby/AndroidNearbyTransport.java | 45 ++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 113 ++++++++++++++++-- .../builders/AndroidGradleBuilder.java | 12 ++ 4 files changed, 176 insertions(+), 13 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 8405c5e7ae3..adadfcde428 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -326,6 +326,14 @@ public void onDeviceFound(IntentSender chooserLauncher) { @Override public void onFailure(CharSequence error) { pendingAssociateRequest = 0; + // The listener was installed before associate() was called, + // and installing one marks CodenameOneActivity as waiting for + // a result. Leaving it there when no chooser is ever launched + // wedges the whole activity-result channel: the camera, the + // scanner and every other startActivityForResult caller then + // cannot install their own listener and their results arrive + // here instead. + releaseResultListener(); CompanionDevices.deliverRequestFailed(requestId, NearbyError.PEER_UNAVAILABLE.ordinal(), error == null ? null : error.toString()); @@ -339,11 +347,22 @@ private void launch(IntentSender chooserLauncher, int requestId) { ASSOCIATE_REQUEST, null, 0, 0, 0); } catch (IntentSender.SendIntentException e) { pendingAssociateRequest = 0; + // Same as the onFailure path: nothing will come back through the + // listener, so it must not stay installed. + releaseResultListener(); CompanionDevices.deliverRequestFailed(requestId, NearbyError.UNKNOWN.ordinal(), e.getMessage()); } } + /// Hands the activity-result channel back, so the next + /// startActivityForResult caller can install its own listener. + private void releaseResultListener() { + if (activity instanceof CodenameOneActivity) { + ((CodenameOneActivity) activity).restoreIntentResultListener(); + } + } + private void listenForResult(final int requestId, final CompanionDeviceManager cdm) { if (!(activity instanceof CodenameOneActivity)) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 6f7c176e073..a8c6f6d4e2b 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -294,7 +294,15 @@ public void sendPayload(final int requestId, String[] endpointIds, if (p != null && p.startsWith("file://")) { p = p.substring(7); } - payload = Payload.fromFile(new File(p)); + // The sender's payload id rides in the file NAME, because a + // FILE payload has nowhere else to put it and getId() on the + // receiving side is otherwise Google's own local id -- a + // different number from the one the sender was handed, and a + // long truncated into an int besides. + File source = new File(p); + payload = Payload.fromFile(source); + payload.setFileName(ID_PREFIX + payloadId + "-" + + source.getName()); } else { // Framed with the sender's payload id. Nearby Connections // mints its own id on each side, so without this the @@ -511,7 +519,7 @@ public void onPayloadTransferUpdate(String endpointId, } NearbyTransport.deliverPayloadReceived( encode(endpointId, nameOf(endpointId)), - (int) file.getId(), NearbyBridge.PAYLOAD_FILE, null, + senderIdOf(file), NearbyBridge.PAYLOAD_FILE, null, f == null ? null : "file://" + f.getAbsolutePath()); } }; @@ -569,6 +577,39 @@ private String encode(String endpointId, String name) { + sanitize(service == null ? "" : service); } + /// The marker that carries a sender's payload id in a file name. + private static final String ID_PREFIX = "cn1id-"; + + /// The sender's payload id for an incoming file, recovered from the name + /// it was sent under. + /// + /// Falls back to Google's local id when the name carries no marker, which + /// is what a file from an older build would look like -- wrong, but no + /// worse than it was before, and better than zero. + private static int senderIdOf(Payload file) { + String name = null; + try { + if (file.asFile() != null) { + name = file.asFile().asJavaFile() == null ? null + : file.asFile().asJavaFile().getName(); + } + } catch (Throwable t) { + name = null; + } + if (name != null && name.startsWith(ID_PREFIX)) { + int dash = name.indexOf('-', ID_PREFIX.length()); + if (dash > ID_PREFIX.length()) { + try { + return Integer.parseInt( + name.substring(ID_PREFIX.length(), dash)); + } catch (NumberFormatException notAnId) { + // Fall through to the local id. + } + } + } + return (int) file.getId(); + } + private static String sanitize(String s) { if (s == null) { return ""; diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 12ab8a2b6d5..97962828fce 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -336,6 +336,13 @@ @interface CN1NearbyTransport : NSObject Date: Sun, 23 Aug 2026 17:10:34 +0300 Subject: [PATCH 17/94] Address the eighth nearby review round - RangingToken.fromByteArray validated its declared payload length with "10 + len > data.length", which overflows to a negative number for a length near Integer.MAX_VALUE and waves a ten-byte input through, so the allocation below asked for two gigabytes. Compare against data.length - 10 instead, which cannot underflow because the length is already known to be at least 10, and require an exact match so trailing bytes are rejected rather than silently ignored. Tokens travel between devices out of band, so the input is genuinely untrusted. - Companion presence events arriving before any listener existed were dispatched into an empty listener list and lost. That is the cold start the API exists for: Android may start the process for CN1CompanionDeviceService alone, with no activity, so the app's init() has not registered anything yet. CompanionDevices now parks up to the 64 most recent events and replays them to the first listener that registers, taking the backlog under the listener monitor so a live event cannot overtake the replay on the EDT. - iOS tracked one serviceType/serviceId pair for both halves of the transport. An app advertising "files" while browsing "chat" therefore relabelled the browser's sightings with whichever call ran last. The advertising and discovery services are now separate fields, and each peer is encoded with the service it was actually seen on, recorded in serviceIdByPeer when the browser finds it or the advertiser is invited by it. --- .../nearby/companion/CompanionDevices.java | 60 +++++++++++- .../nearby/ranging/RangingToken.java | 9 +- .../nearby/CN1CompanionDeviceService.java | 6 ++ Ports/iOSPort/nativeSources/CN1Nearby.m | 98 +++++++++++++------ .../com/codename1/nearby/LocalNearbyTest.java | 45 +++++++++ .../codename1/nearby/RangingTokenTest.java | 24 +++++ 6 files changed, 212 insertions(+), 30 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index 9f82c1ef4be..f55df5e801e 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -85,6 +85,30 @@ public final class CompanionDevices { new PendingMap(); private static final List LISTENERS = new ArrayList(); + /// Presence events that arrived before any listener existed. The platform + /// may start the process purely to deliver one -- that is the whole point + /// of companion association -- and in that process the app's `init()` has + /// not run yet, so a straight dispatch reaches an empty listener list and + /// the wake-up is lost for good. Parked here instead, and replayed by + /// [#addPresenceListener]. + private static final List PENDING_PRESENCE = + new ArrayList(); + /// Bounds the parked backlog. An app that never registers a listener must + /// not accumulate events forever; the oldest is dropped first, because the + /// most recent sighting is the one worth reporting. + private static final int MAX_PENDING_PRESENCE = 64; + + /// One parked presence event. Static so it holds no implicit reference to + /// anything but the device it carries. + private static final class PendingPresence { + final CompanionDevice device; + final boolean present; + + PendingPresence(CompanionDevice device, boolean present) { + this.device = device; + this.present = present; + } + } private CompanionDevices() { } @@ -237,7 +261,10 @@ public static void stopObservingPresence(String associationId) { /// /// Register from the app's `init()`: presence is exactly the event that /// can arrive during a cold start, because the platform launched the app - /// to deliver it. + /// to deliver it. An event that arrived before any listener existed is + /// replayed to the listeners as soon as the first one registers, so a + /// wake-up delivered into a process whose `init()` had not run yet is not + /// lost. At most the 64 most recent are kept. /// /// #### Parameters /// @@ -246,8 +273,23 @@ public static void addPresenceListener(PresenceListener l) { if (l == null) { return; } + List replay = null; synchronized (LISTENERS) { LISTENERS.add(l); + if (!PENDING_PRESENCE.isEmpty()) { + // Taking the backlog under the same monitor that + // deliverPresenceChanged parks into is what keeps the order + // right: an event arriving between the registration and the + // replay finds the queue still non-empty and parks behind the + // backlog rather than overtaking it on the EDT. + replay = new ArrayList(PENDING_PRESENCE); + PENDING_PRESENCE.clear(); + } + } + if (replay != null) { + for (PendingPresence parked : replay) { + dispatchPresence(parked.device, parked.present); + } } } @@ -279,6 +321,7 @@ public static void resetForTest() { PENDING_DISASSOCIATE.failAll(reset); synchronized (LISTENERS) { LISTENERS.clear(); + PENDING_PRESENCE.clear(); } } @@ -363,6 +406,21 @@ public static void deliverPresenceChanged(String encodedDevice, if (d == null) { return; } + synchronized (LISTENERS) { + if (LISTENERS.isEmpty() || !PENDING_PRESENCE.isEmpty()) { + while (PENDING_PRESENCE.size() >= MAX_PENDING_PRESENCE) { + PENDING_PRESENCE.remove(0); + } + PENDING_PRESENCE.add(new PendingPresence(d, present)); + return; + } + } + dispatchPresence(d, present); + } + + /// Hands one presence event to the listeners on the EDT. + private static void dispatchPresence(final CompanionDevice d, + final boolean present) { NearbyRequests.onEdt(new Runnable() { @Override public void run() { diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java index f0c56fe8aa2..ff7fd4e29a9 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java @@ -140,7 +140,14 @@ public static RangingToken fromByteArray(byte[] data) { } int plat = data[5] & 0xff; int len = readInt(data, 6); - if (len < 0 || 10 + len > data.length) { + // Subtract rather than add: a hostile or corrupt peer can declare a + // length near Integer.MAX_VALUE, and "10 + len > data.length" would + // overflow to a negative number and wave it through, leaving the + // allocation below to ask for gigabytes. data.length is already known + // to be at least 10, so the subtraction cannot underflow. The length + // must match exactly -- toByteArray always emits 10 + len bytes, so + // trailing bytes mean this is not our encoding. + if (len < 0 || len != data.length - 10) { throw new IllegalArgumentException("truncated ranging token"); } byte[] payload = new byte[len]; diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index 046a7fbbb07..81f8a5a726d 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -41,6 +41,12 @@ /// that registered a `PresenceListener` in `init()` hears about a device that /// appeared while the app was not running. /// +/// The platform may start the process for THIS service alone, with no activity +/// and therefore no initialized Codename One and no registered listener yet. +/// The event is not dispatched and dropped in that case: `CompanionDevices` +/// parks it and replays it to the first listener that registers, which in a +/// cold start is the one the app adds from `init()`. +/// /// The builder writes the `` element that binds this, guarded by /// `android.permission.BIND_COMPANION_DEVICE_SERVICE` and the /// `CompanionDeviceService` intent filter, only for an app that observes diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 97962828fce..73c50114a04 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -335,14 +335,25 @@ @interface CN1NearbyTransport : NSObject *)info { @autoreleasepool { - NSString *encoded = [self encodePeer:peerID]; + NSString *encoded = [self encodePeer:peerID + service:self.discoverServiceId]; com_codename1_impl_ios_IOSNearbyCallbacks_endpointFound___java_lang_String_boolean( getThreadLocalData(), cn1nbJString(encoded), JAVA_TRUE); } @@ -715,7 +750,8 @@ - (void)browser:(MCNearbyServiceBrowser *)browser - (void)browser:(MCNearbyServiceBrowser *)browser lostPeer:(MCPeerID *)peerID { @autoreleasepool { - NSString *encoded = [self encodePeer:peerID]; + NSString *encoded = [self encodePeer:peerID + service:self.discoverServiceId]; com_codename1_impl_ios_IOSNearbyCallbacks_endpointFound___java_lang_String_boolean( getThreadLocalData(), cn1nbJString(encoded), JAVA_FALSE); } @@ -796,14 +832,14 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { t.advertiser = [[[MCNearbyServiceAdvertiser alloc] initWithPeer:t.localPeer discoveryInfo:nil - serviceType:t.serviceType] autorelease]; + serviceType:t.advertiseServiceType] autorelease]; t.advertiser.delegate = t; [t.advertiser startAdvertisingPeer]; } if (wasBrowsing) { t.browser = [[[MCNearbyServiceBrowser alloc] initWithPeer:t.localPeer - serviceType:t.serviceType] autorelease]; + serviceType:t.discoverServiceType] autorelease]; t.browser.delegate = t; [t.browser startBrowsingForPeers]; } @@ -821,7 +857,7 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { } static CN1NearbyTransport *cn1nbTransportInit(NSString *serviceId, - NSString *localName) { + NSString *localName, BOOL advertising) { if (cn1nbTransport == nil) { cn1nbTransport = [[CN1NearbyTransport alloc] init]; cn1nbTransport.peersById = [NSMutableDictionary dictionary]; @@ -829,16 +865,22 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { cn1nbTransport.progressByPayload = [NSMutableDictionary dictionary]; cn1nbTransport.everConnected = [NSMutableSet set]; cn1nbTransport.sessionsById = [NSMutableDictionary dictionary]; + cn1nbTransport.serviceIdByPeer = [NSMutableDictionary dictionary]; } if (serviceId != nil) { - // Reassigned on EVERY call, not just the first. Caching it meant - // stopping discovery for "chat" and starting it for "files" carried on - // browsing chat, and an app advertising one service while browsing - // another silently used whichever call came first. The advertiser and - // browser below are rebuilt per call and read this, so the id the - // caller passed is the one that takes effect. - cn1nbTransport.serviceType = cn1nbServiceType(serviceId); - cn1nbTransport.serviceId = serviceId; + // Assigned on EVERY call, and only to the half this call is for. + // Caching it meant stopping discovery for "chat" and starting it for + // "files" carried on browsing chat; writing one shared field instead + // meant an app advertising "files" while browsing "chat" relabelled + // the browser's sightings as "files". The advertiser and browser are + // rebuilt per call and read their own field. + if (advertising) { + cn1nbTransport.advertiseServiceType = cn1nbServiceType(serviceId); + cn1nbTransport.advertiseServiceId = serviceId; + } else { + cn1nbTransport.discoverServiceType = cn1nbServiceType(serviceId); + cn1nbTransport.discoverServiceId = serviceId; + } } cn1nbApplyLocalName(cn1nbTransport, localName); return cn1nbTransport; @@ -1475,7 +1517,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str return; } NSString *name = toNSString(CN1_THREAD_STATE_PASS_ARG localName); - CN1NearbyTransport *t = cn1nbTransportInit(sid, name); + CN1NearbyTransport *t = cn1nbTransportInit(sid, name, YES); if (t.advertiser != nil) { [t.advertiser stopAdvertisingPeer]; t.advertiser = nil; @@ -1483,7 +1525,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str t.advertiser = [[[MCNearbyServiceAdvertiser alloc] initWithPeer:t.localPeer discoveryInfo:nil - serviceType:t.serviceType] autorelease]; + serviceType:t.advertiseServiceType] autorelease]; t.advertiser.delegate = t; // Recorded BEFORE the answer: didNotStartAdvertisingPeer can fire // after this returns, and it needs the id to fail. @@ -1522,14 +1564,14 @@ void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_Strin cn1nbUndeclaredServiceMessage(sid)); return; } - CN1NearbyTransport *t = cn1nbTransportInit(sid, nil); + CN1NearbyTransport *t = cn1nbTransportInit(sid, nil, NO); if (t.browser != nil) { [t.browser stopBrowsingForPeers]; t.browser = nil; } t.browser = [[[MCNearbyServiceBrowser alloc] initWithPeer:t.localPeer - serviceType:t.serviceType] autorelease]; + serviceType:t.discoverServiceType] autorelease]; t.browser.delegate = t; t.pendingDiscoverRequest = requestId; [t.browser startBrowsingForPeers]; diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index bc8f3369f1d..c8ab2e3d4fd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -470,6 +470,51 @@ public void deviceDisappeared(CompanionDevice device) { assertEquals(2, events.size()); } + @Test + void aPresenceEventThatBeatsTheListenerIsReplayedRatherThanLost() { + // The whole point of companion association is that the platform can + // start the process purely to deliver this, which on Android happens + // in a process where the app's init() has not run and no listener + // exists yet. Dispatched straight through, the wake-up would be lost. + CompanionDevices.deliverPresenceChanged( + "cold\tCold Watch\t\t0\t1", true); + CompanionDevices.deliverPresenceChanged( + "cold\tCold Watch\t\t0\t0", false); + + final List events = new ArrayList(); + CompanionDevices.addPresenceListener(new PresenceListener() { + @Override + public void deviceAppeared(CompanionDevice device) { + events.add("appeared:" + device.getId()); + } + + @Override + public void deviceDisappeared(CompanionDevice device) { + events.add("disappeared:" + device.getId()); + } + }); + + assertEquals(2, events.size()); + assertEquals("appeared:cold", events.get(0)); + assertEquals("disappeared:cold", events.get(1)); + + // Drained, not merely copied -- a second listener does not see the + // backlog a third time. + final List later = new ArrayList(); + CompanionDevices.addPresenceListener(new PresenceListener() { + @Override + public void deviceAppeared(CompanionDevice device) { + later.add("appeared:" + device.getId()); + } + + @Override + public void deviceDisappeared(CompanionDevice device) { + later.add("disappeared:" + device.getId()); + } + }); + assertTrue(later.isEmpty()); + } + @Test void observingSomethingThatIsNotAssociatedIsRefused() { assertFalse(CompanionDevices.startObservingPresence("nope")); diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java index 4900f35a29d..049aa4a35e3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java @@ -110,6 +110,30 @@ void aTruncatedTokenIsRejectedRatherThanReadPastItsEnd() { () -> RangingToken.fromByteArray(cut)); } + @Test + void aHugeDeclaredLengthIsRejectedRatherThanOverflowingIntoAnAllocation() { + // 10 + Integer.MAX_VALUE wraps negative, so an additive bounds check + // would accept this ten-byte input and then try to allocate 2GB. + byte[] t = RangingToken.forPayload(RangingToken.PLATFORM_APPLE_NI, + new byte[0]).toByteArray(); + t[6] = (byte) 0x7f; + t[7] = (byte) 0xff; + t[8] = (byte) 0xff; + t[9] = (byte) 0xff; + assertThrows(IllegalArgumentException.class, + () -> RangingToken.fromByteArray(t)); + } + + @Test + void trailingBytesAreRejectedBecauseTheEncodingHasNoRoomForThem() { + byte[] full = RangingToken.forPayload(RangingToken.PLATFORM_APPLE_NI, + new byte[] {1, 2, 3}).toByteArray(); + byte[] padded = new byte[full.length + 4]; + System.arraycopy(full, 0, padded, 0, full.length); + assertThrows(IllegalArgumentException.class, + () -> RangingToken.fromByteArray(padded)); + } + @Test void anUnknownVersionIsRejectedRatherThanGuessedAt() { byte[] t = RangingToken.forPayload(RangingToken.PLATFORM_APPLE_NI, From 1885968b90a8eed6142a0b953674d0faf75b3ac8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:13:54 +0300 Subject: [PATCH 18/94] Say where to register the presence listener Android may start the process for the companion service alone, so a listener registered by a form does not exist when the sighting arrives. Names init() as the place, and records that an event arriving before any listener is replayed rather than lost. --- docs/developer-guide/Nearby-Devices.asciidoc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index 6117346f07d..6ea1e3f5925 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -166,6 +166,13 @@ only ever discovers Bluetooth services an app declared up front, so set your accessories advertise -- without it the picker finds nothing on iOS, and the build log says so. +Register the presence listener from your app's `init()` rather than from a form. +Android may start the process to deliver a sighting and nothing else, with no +form on screen, and a listener that a form registers doesn't exist yet at that +point. An event that arrives before any listener is registered is held and +replayed to the first one that registers, so a wake-up isn't lost, but only the +64 most recent are kept. + === Transport: Sending Something [source,java] From 12d2ba839808410ab1586902e91023fecb9f2748 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:56:34 +0300 Subject: [PATCH 19/94] Align the @Override annotations with the methods they annotate Checkstyle's IndentationCheck failed build-test (8) on 26 annotations left at column 4 when they were added in bulk. Cosmetic, but the gate is zero-tolerance. --- .../impl/nearby/LocalNearbyBridge.java | 28 +++++++++---------- .../nearby/companion/CompanionDevices.java | 2 +- .../nearby/ranging/RangingSession.java | 10 +++---- .../nearby/transport/NearbyTransport.java | 12 ++++---- quality-report.md | 28 +++++++++++++++++++ 5 files changed, 54 insertions(+), 26 deletions(-) create mode 100644 quality-report.md diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 4ec72205f7b..7a76bdba02f 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -320,7 +320,7 @@ public void startRanging(final int requestId, final int sessionHandle, return; } answer(new Runnable() { - @Override + @Override public void run() { s.running = true; Ranging.deliverSessionStarted(requestId, sessionHandle); @@ -339,7 +339,7 @@ public void startAccessoryRanging(final int requestId, return; } answer(new Runnable() { - @Override + @Override public void run() { s.running = true; // A real accessory handshake sends configuration back; the @@ -369,7 +369,7 @@ public void stopRangingSession(int sessionHandle) { public void associate(final int requestId, final int profile, boolean singleDevice, final String[] filters) { answer(new Runnable() { - @Override + @Override public void run() { Candidate c = firstMatch(filters); if (c == null) { @@ -403,7 +403,7 @@ public String[] getAssociations() { @Override public void disassociate(final int requestId, final String associationId) { answer(new Runnable() { - @Override + @Override public void run() { observed.remove(associationId); if (associations.remove(associationId) == null) { @@ -479,7 +479,7 @@ public void startDiscovery(final int requestId, final String serviceId, int strategy) { discovering = true; answer(new Runnable() { - @Override + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); for (SimEndpoint e : endpoints) { @@ -505,13 +505,13 @@ public void requestConnection(final int requestId, final String endpointId, return; } answer(new Runnable() { - @Override + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); // The simulated peer always accepts, one hop later, so the // app sees the two-step shape the real platforms have. answer(new Runnable() { - @Override + @Override public void run() { connected.add(endpointId); NearbyTransport.deliverConnectionResult(e.encode(), @@ -540,7 +540,7 @@ public void sendPayload(final int requestId, final String[] endpointIds, final int payloadId, final int payloadType, final byte[] bytes, final String path) { answer(new Runnable() { - @Override + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); for (String endpointId : endpointIds) { @@ -622,7 +622,7 @@ private void tick(final SimSession s) { return; } later(TICK_MILLIS, new Runnable() { - @Override + @Override public void run() { tick(s); } @@ -703,7 +703,7 @@ private PermissionAnswer(int requestId) { this.requestId = requestId; } - @Override + @Override public void run() { Ranging.deliverPermissionResult(requestId, true); } @@ -723,7 +723,7 @@ private SessionPrepared(int requestId, int sessionHandle, this.session = session; } - @Override + @Override public void run() { Ranging.deliverSessionPrepared(requestId, sessionHandle, controller, RangingToken.PLATFORM_SIMULATED, @@ -743,7 +743,7 @@ private RangingFailure(int requestId, NearbyError error, this.message = message; } - @Override + @Override public void run() { Ranging.deliverRequestFailed(requestId, error.ordinal(), message); } @@ -756,7 +756,7 @@ private TransportOk(int requestId) { this.requestId = requestId; } - @Override + @Override public void run() { NearbyTransport.deliverRequestOk(requestId); } @@ -774,7 +774,7 @@ private TransportFailure(int requestId, NearbyError error, this.message = message; } - @Override + @Override public void run() { NearbyTransport.deliverRequestFailed(requestId, error.ordinal(), message); diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index f55df5e801e..f227098d14d 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -422,7 +422,7 @@ public static void deliverPresenceChanged(String encodedDevice, private static void dispatchPresence(final CompanionDevice d, final boolean present) { NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { PresenceListener[] ls; synchronized (LISTENERS) { diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java index 4ee0d3c21b9..3c166166cd6 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java @@ -243,7 +243,7 @@ public static void deliverUpdate(int sessionHandle, boolean hasDistance, hasDirection, azimuth, hasElevation, elevation, vector, System.currentTimeMillis()); NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { s.running = true; RangingListener[] ls = s.snapshot(); @@ -273,7 +273,7 @@ public static void deliverPeerRemoved(int sessionHandle, reasonOrdinal >= 0 && reasonOrdinal < all.length ? all[reasonOrdinal] : RangingRemovalReason.UNKNOWN; NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { RangingListener[] ls = s.snapshot(); for (RangingListener l : ls) { @@ -296,7 +296,7 @@ public static void deliverSuspended(int sessionHandle) { return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { s.running = false; RangingListener[] ls = s.snapshot(); @@ -320,7 +320,7 @@ public static void deliverResumed(int sessionHandle) { return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { s.running = true; RangingListener[] ls = s.snapshot(); @@ -353,7 +353,7 @@ public static void deliverInvalidated(int sessionHandle, int errorOrdinal, } final NearbyException ex = Ranging.toException(errorOrdinal, message); NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { s.running = false; s.closed = true; diff --git a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java index 3385fe0f489..f260d1fb412 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -469,7 +469,7 @@ public static void deliverEndpointFound(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { TransportListener[] ls = snapshot(); for (TransportListener l : ls) { @@ -499,7 +499,7 @@ public static void deliverConnectionRequested(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { IncomingConnection r = new IncomingConnection(e, authenticationToken); @@ -537,7 +537,7 @@ public static void deliverConnectionResult(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { TransportListener[] ls = snapshot(); for (TransportListener l : ls) { @@ -566,7 +566,7 @@ public static void deliverDisconnected(String encodedEndpoint) { return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { TransportListener[] ls = snapshot(); for (TransportListener l : ls) { @@ -597,7 +597,7 @@ public static void deliverPayloadReceived(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { Payload p = Payload.received(payloadId, payloadType == NearbyBridge.PAYLOAD_FILE @@ -631,7 +631,7 @@ public static void deliverPayloadProgress(String encodedEndpoint, return; } NearbyRequests.onEdt(new Runnable() { - @Override + @Override public void run() { PayloadStatus[] all = PayloadStatus.values(); PayloadStatus s = statusOrdinal >= 0 diff --git a/quality-report.md b/quality-report.md new file mode 100644 index 00000000000..76233a09819 --- /dev/null +++ b/quality-report.md @@ -0,0 +1,28 @@ +## ✅ Continuous Quality Report + +### Test & Coverage +- ✅ **Tests:** 6194 total, 0 failed, 1 skipped +- 📊 **Line coverage:** 59.64% + - **Lowest covered classes** + - `com.codename1.gaming.level.GameSceneView` – 0.00% + - `com.codename1.crash.CrashProtection` – 0.00% + - `com.codename1.payment.CommerceManager` – 0.00% + - `com.codename1.nearby.companion.CompanionDevices` – 0.00% + - `com.codename1.wearable.WearableMessage` – 0.00% + - `com.codename1.vr.VRView` – 0.00% + - `com.codename1.appreview.RatingDialog` – 0.00% + - `com.codename1.db.ManagedKeys` – 0.00% + - `com.codename1.calendar.DefaultCalendarHttpTransport` – 0.00% + - `com.codename1.security.Secrets` – 0.00% + +### Static Analysis +- **SpotBugs** + - ✅ **ByteCodeTranslator:** 0 findings (no issues) + - ✅ **android:** 0 findings (no issues) + - ✅ **codenameone-maven-plugin:** 0 findings (no issues) + - ✅ **core-unittests:** 0 findings (no issues) + - ✅ **ios:** 0 findings (no issues) +- ✅ **PMD:** 0 findings (no issues) +- ✅ **Checkstyle:** 0 findings (no issues) + +_Generated automatically by the PR CI workflow._ From ad5771c59bb387109d36d25f891afb56a57b8336 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:08:20 +0300 Subject: [PATCH 20/94] Address the ninth nearby review round - RangingSession: every queued delivery now rechecks that the session is still open. An update queued from a background thread could reach the EDT after stop() ran there, setting running back to true on a session isRunning() had already promised was finished and notifying a listener registered after the stop. The flags move under the SESSIONS monitor that stop() already used, so the read and the write agree. - Android association no longer guesses which association the chooser created. getMyAssociations() documents no order, so "the last one" could hand an app that already held associations one the user did not pick. API 33's EXTRA_ASSOCIATION names it directly; failing that, it is the one missing from a snapshot taken before the chooser opened; failing that, the intent's device extra, which is all API 26 through 32 offer. - An unfiltered association request stays unfiltered. Adding an empty BluetoothLeDeviceFilter looked neutral and was not: it restricts the chooser to a BLE scan, so Wi-Fi and classic Bluetooth companions vanished from the very case that asked to see everything. - UWB session preparation no longer blocks the caller. Opening a session scope binds to the UWB system service, and prepareRangingSession is called from the EDT, so blockingGet() froze the UI for as long as the service took to come up. The scope is subscribed on an io thread and the result delivered from its callbacks. getRangingCapabilities had the same block: it is now answered from a probe started in the background as soon as the platform reports ranging usable, with a bounded wait only for a call that beats it, and a failed probe is not cached so a later call after the permission is granted still learns the truth. - The nearby Bonjour service types merge into the ios.NSBonjourServices hint instead of being injected as an NSBonjourServices key. The plist renderer emits the generated array only when the injected fragment has no key of its own, so the injection silently dropped every service already declared -- including the _matter._tcp. and _matterc._udp. entries Matter commissioning needs, without which iOS stops delivering their mDNS traffic. A project that owns the key through ios.plistInject is refused with the entries to add, as the Matter path already does. - CN1Nearby.m strips a trailing dot from a declared Bonjour service before cutting off its transport suffix. Taken in the other order, the trailing dot the plist renderer appends to every entry was the dot the suffix was cut at, leaving "chat._tcp" where "chat" was meant -- so every declared service looked undeclared and advertising was refused. - NearbyManifestFragments widens an existing ACCESS_FINE_LOCATION declaration rather than adding a second one. Bluetooth runs first and caps it at 30 for a scanning app; Nearby Connections needs the grant through 32 and had none at all on Android 12 and 12L. --- .../nearby/ranging/RangingSession.java | 62 +++++- .../android/nearby/AndroidNearbyBackend.java | 108 ++++++++-- .../android/nearby/AndroidUwbRanging.java | 188 +++++++++++++++--- Ports/iOSPort/nativeSources/CN1Nearby.m | 11 +- .../com/codename1/builders/IPhoneBuilder.java | 92 ++++++++- .../builders/NearbyManifestFragments.java | 72 ++++++- .../builders/NearbyBonjourMergeTest.java | 156 +++++++++++++++ .../builders/NearbyManifestFragmentsTest.java | 52 +++++ .../com/codename1/nearby/LocalNearbyTest.java | 29 +++ 9 files changed, 702 insertions(+), 68 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java index 3c166166cd6..29e8e9b2b46 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java @@ -88,7 +88,9 @@ public RangingRole getRole() { /// `true` while the radio is measuring. False before [#start] and after /// [#stop()], and false while the session is suspended. public boolean isRunning() { - return running; + synchronized (SESSIONS) { + return running; + } } /// Starts ranging against a peer whose token arrived out of band. @@ -245,7 +247,17 @@ public static void deliverUpdate(int sessionHandle, boolean hasDistance, NearbyRequests.onEdt(new Runnable() { @Override public void run() { - s.running = true; + // A native update queued from a background thread can reach + // the EDT after stop() ran there. Without this it set running + // back to true on a session isRunning() has already promised + // is finished, and delivered to a listener registered after + // the stop. + if (s.isClosed()) { + return; + } + synchronized (SESSIONS) { + s.running = true; + } RangingListener[] ls = s.snapshot(); for (RangingListener l : ls) { l.updated(u); @@ -275,6 +287,9 @@ public static void deliverPeerRemoved(int sessionHandle, NearbyRequests.onEdt(new Runnable() { @Override public void run() { + if (s.isClosed()) { + return; + } RangingListener[] ls = s.snapshot(); for (RangingListener l : ls) { l.peerRemoved(reason); @@ -298,7 +313,12 @@ public static void deliverSuspended(int sessionHandle) { NearbyRequests.onEdt(new Runnable() { @Override public void run() { - s.running = false; + if (s.isClosed()) { + return; + } + synchronized (SESSIONS) { + s.running = false; + } RangingListener[] ls = s.snapshot(); for (RangingListener l : ls) { l.suspended(); @@ -322,7 +342,12 @@ public static void deliverResumed(int sessionHandle) { NearbyRequests.onEdt(new Runnable() { @Override public void run() { - s.running = true; + if (s.isClosed()) { + return; + } + synchronized (SESSIONS) { + s.running = true; + } RangingListener[] ls = s.snapshot(); for (RangingListener l : ls) { l.resumed(); @@ -355,8 +380,15 @@ public static void deliverInvalidated(int sessionHandle, int errorOrdinal, NearbyRequests.onEdt(new Runnable() { @Override public void run() { - s.running = false; - s.closed = true; + // stop() promises no callback follows it, so an invalidation + // that was already queued when it ran stays unreported. + if (s.isClosed()) { + return; + } + synchronized (SESSIONS) { + s.running = false; + s.closed = true; + } RangingListener[] ls = s.snapshot(); synchronized (s.listeners) { s.listeners.clear(); @@ -404,10 +436,24 @@ static RangingSession lookup(int handle) { } void markRunning() { - running = true; + synchronized (SESSIONS) { + running = true; + } starting = false; } + /// True once [#stop] or an invalidation has finished this session. + /// + /// Read under the SESSIONS monitor because that is where the flag is + /// written, and every queued delivery consults it before touching the + /// session: a callback that was already on its way when the app stopped + /// the session must not arrive. + boolean isClosed() { + synchronized (SESSIONS) { + return closed; + } + } + /// Clears the in-progress flag after a start that failed. /// /// Without this a session whose [#start] was rejected -- a corrupt token, @@ -426,7 +472,7 @@ private RangingListener[] snapshot() { } private NearbyException checkStartable() { - if (closed) { + if (isClosed()) { return new NearbyException(NearbyError.SESSION_INVALIDATED, "this session has been stopped; prepare another"); } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index adadfcde428..4199cfe8cb2 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -50,7 +50,9 @@ import com.codename1.ui.Display; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.regex.Pattern; /// The Android nearby implementation, compiled inside the generated app @@ -302,19 +304,16 @@ public void associate(final int requestId, int profile, request.setDeviceProfile(deviceProfile); } } - boolean anyFilter = false; for (int i = 0; filters != null && i < filters.length; i++) { - if (addFilter(request, filters[i])) { - anyFilter = true; - } - } - if (!anyFilter) { - // An unfiltered request is legal and shows everything the radios - // can see. Left as is rather than refused: that is the same thing - // an empty filter list means in the portable API. - request.addDeviceFilter(new BluetoothLeDeviceFilter.Builder() - .build()); - } + addFilter(request, filters[i]); + } + // No filter is added when the caller gave none. An empty + // BluetoothLeDeviceFilter is NOT the neutral choice it looks like: a + // request carrying one restricts the chooser to a BLE scan, so classic + // Bluetooth and Wi-Fi companions vanish from the very case that asked + // to see everything. A request with no filters at all is what makes + // the platform scan all three transports, which is what the portable + // API promises for an empty filter list. pendingAssociateRequest = requestId; listenForResult(requestId, cdm); cdm.associate(request.build(), new CompanionDeviceManager.Callback() { @@ -368,6 +367,9 @@ private void listenForResult(final int requestId, if (!(activity instanceof CodenameOneActivity)) { return; } + // Taken BEFORE the chooser opens, so the association it creates can be + // told apart from the ones this app already had. + final Set before = associationKeys(cdm); final CodenameOneActivity host = (CodenameOneActivity) activity; host.setIntentResultListener(new IntentResultListener() { public void onActivityResult(int requestCode, int resultCode, @@ -383,7 +385,7 @@ public void onActivityResult(int requestCode, int resultCode, "the user dismissed the chooser"); return; } - String encoded = newestAssociation(cdm, data); + String encoded = newestAssociation(cdm, data, before); if (encoded == null) { CompanionDevices.deliverRequestFailed(requestId, NearbyError.UNKNOWN.ordinal(), @@ -401,12 +403,50 @@ public void onActivityResult(int requestCode, int resultCode, /// wherever possible: on API 33 and later the association carries an id /// and a display name the intent extra does not, and that id is what /// `disassociate` and presence observation take. + /// + /// Identified three ways, in descending order of certainty, because + /// "the last one in the list" is not one of them -- `getMyAssociations` + /// documents no order, so an app that already held associations could be + /// handed one the user did not pick: + /// + /// 1. `EXTRA_ASSOCIATION`, which API 33 puts in the result intent and + /// which names the association directly; + /// 2. the one association missing from the snapshot taken before the + /// chooser opened; + /// 3. the intent's device extra, which is all API 26 through 32 offer. + /// + /// #### Parameters + /// + /// - `cdm`: the platform manager + /// - `data`: the chooser's result intent + /// - `before`: the association keys this app held before the chooser ran @SuppressLint("MissingPermission") - private String newestAssociation(CompanionDeviceManager cdm, Intent data) { + private String newestAssociation(CompanionDeviceManager cdm, Intent data, + Set before) { if (Build.VERSION.SDK_INT >= 33) { + if (data != null) { + Object association = data.getParcelableExtra( + CompanionDeviceManager.EXTRA_ASSOCIATION); + if (association instanceof AssociationInfo) { + return encode((AssociationInfo) association, true); + } + } List all = cdm.getMyAssociations(); - if (all != null && !all.isEmpty()) { - return encode(all.get(all.size() - 1), true); + AssociationInfo fresh = null; + for (int i = 0; all != null && i < all.size(); i++) { + if (!before.contains(idOf(all.get(i)))) { + if (fresh != null) { + // Two new ones means something else associated while + // the chooser was open; neither can be claimed as the + // user's pick, so fall through to the intent extra. + fresh = null; + break; + } + fresh = all.get(i); + } + } + if (fresh != null) { + return encode(fresh, true); } } if (data != null) { @@ -418,13 +458,43 @@ private String newestAssociation(CompanionDeviceManager cdm, Intent data) { } } List legacy = cdm.getAssociations(); - if (legacy != null && !legacy.isEmpty()) { - String mac = legacy.get(legacy.size() - 1); - return encodeLegacy(mac, mac, true); + for (int i = 0; legacy != null && i < legacy.size(); i++) { + if (!before.contains(legacy.get(i))) { + String mac = legacy.get(i); + return encodeLegacy(mac, mac, true); + } } return null; } + /// The keys of every association this app currently holds: the API 33 id + /// where there is one, the MAC address below that. + @SuppressLint("MissingPermission") + private Set associationKeys(CompanionDeviceManager cdm) { + Set out = new HashSet(); + if (cdm == null) { + return out; + } + try { + if (Build.VERSION.SDK_INT >= 33) { + List all = cdm.getMyAssociations(); + for (int i = 0; all != null && i < all.size(); i++) { + out.add(idOf(all.get(i))); + } + return out; + } + List legacy = cdm.getAssociations(); + for (int i = 0; legacy != null && i < legacy.size(); i++) { + out.add(legacy.get(i)); + } + } catch (Throwable notPermitted) { + // Reading associations needs no permission, but a manufacturer + // build that throws here must not take the association with it: + // an empty snapshot only costs the fallback path. + } + return out; + } + @SuppressLint("MissingPermission") public String[] getAssociations() { CompanionDeviceManager cdm = manager(); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index bbd2e9a5d7b..a17598d96f5 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -93,10 +93,83 @@ public class AndroidUwbRanging implements NearbyBridge { private UwbManager manager; + private final Object capabilityLock = new Object(); + /// The capability bits the platform reported, or -1 while unknown. A + /// probe that FAILS leaves this at -1 rather than caching zero: the usual + /// reason it fails is that UWB_RANGING has not been granted yet, and + /// remembering "no direction" from before the user said yes would make + /// the answer wrong for the rest of the process. + private int probedCapabilities = -1; + private boolean probeInFlight; + public AndroidUwbRanging(Context context) { this.context = context; } + /// Asks the platform for its ranging capabilities, off the calling thread. + /// + /// Android reports them only through a session scope, and opening one + /// binds to the UWB system service -- seconds, on a cold radio. Started + /// once, as early as anything touches this bridge, so that by the time an + /// app asks the answer is usually already here. + private void startCapabilityProbe() { + synchronized (capabilityLock) { + if (probedCapabilities >= 0 || probeInFlight) { + return; + } + probeInFlight = true; + } + try { + UwbManagerRx.clientSessionScopeSingle(managerOrThrow()) + .subscribeOn(Schedulers.io()) + .subscribe(new io.reactivex.rxjava3.functions.Consumer< + UwbClientSessionScope>() { + public void accept(UwbClientSessionScope scope) { + int bits = 0; + try { + RangingCapabilities caps = + scope.getRangingCapabilities(); + if (caps.isAzimuthalAngleSupported()) { + bits |= NearbyBridge.CAPABILITY_DIRECTION; + } + if (caps.isElevationAngleSupported()) { + bits |= NearbyBridge.CAPABILITY_ELEVATION; + } + if (caps.isBackgroundRangingSupported()) { + bits |= NearbyBridge.CAPABILITY_BACKGROUND; + } + } catch (Throwable unreadable) { + bits = 0; + } + settleProbe(bits); + } + }, new io.reactivex.rxjava3.functions.Consumer< + Throwable>() { + public void accept(Throwable error) { + settleProbe(-1); + } + }); + } catch (Throwable noRadio) { + settleProbe(-1); + } + } + + /// Records the probe's answer and wakes anything waiting for it. + /// + /// #### Parameters + /// + /// - `bits`: the capability bits, or -1 when the probe failed and the + /// next caller should try again + private void settleProbe(int bits) { + synchronized (capabilityLock) { + if (bits >= 0) { + probedCapabilities = bits; + } + probeInFlight = false; + capabilityLock.notifyAll(); + } + } + // ------------------------------------------------------------------ // Capability // ------------------------------------------------------------------ @@ -127,6 +200,9 @@ public int getRangingAvailability() { != PackageManager.PERMISSION_GRANTED) { return NearbyAvailability.UNAUTHORIZED.ordinal(); } + // Warmed here, where the permission is known to be granted, so the + // scope is usually open by the time an app asks what it can measure. + startCapabilityProbe(); return NearbyAvailability.AVAILABLE.ordinal(); } @@ -139,22 +215,32 @@ public int getRangingCapabilities() { // answer is distance alone: claiming direction a device cannot // produce would have an app draw an arrow that never moves. int bits = NearbyBridge.CAPABILITY_DISTANCE; - try { - UwbClientSessionScope scope = UwbManagerRx - .clientSessionScopeSingle(managerOrThrow()) - .blockingGet(); - RangingCapabilities caps = scope.getRangingCapabilities(); - if (caps.isAzimuthalAngleSupported()) { - bits |= NearbyBridge.CAPABILITY_DIRECTION; - } - if (caps.isElevationAngleSupported()) { - bits |= NearbyBridge.CAPABILITY_ELEVATION; - } - if (caps.isBackgroundRangingSupported()) { - bits |= NearbyBridge.CAPABILITY_BACKGROUND; + // The scope that carries the answer is opened by a background probe, + // not here: opening one on the calling thread blocks it on the UWB + // system service binding, and this is called from the EDT. The wait + // below is bounded and only ever happens on a call that beats the + // probe; once it lands the answer is cached and every later call is + // free. + startCapabilityProbe(); + int probed; + long deadline = System.currentTimeMillis() + 400; + synchronized (capabilityLock) { + while (probedCapabilities < 0 && probeInFlight) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + capabilityLock.wait(left); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } } - } catch (Throwable t) { - // Left at distance-only. + probed = probedCapabilities; + } + if (probed > 0) { + bits |= probed; } // An accessory is ranged here by joining the session it names, which // is the same code path as a peer -- so if ranging works at all, @@ -201,27 +287,71 @@ public void prepareRangingSession(final int requestId, "this device has no ultra-wideband radio"); return; } + // Subscribed, never blockingGet(). Opening a session scope binds to + // the UWB system service and negotiates a local address, and this is + // called from the EDT -- so blocking on it froze the UI for as long as + // the service took to come up, which on a cold radio is long enough to + // be an ANR rather than a stutter. The answer arrives on an io thread + // and Ranging hops it back to the EDT itself. try { - Session session = new Session(sessionHandle, controller); + final Session session = new Session(sessionHandle, controller); + UwbManager uwb = managerOrThrow(); if (controller) { - UwbControllerSessionScope scope = UwbManagerRx - .controllerSessionScopeSingle(managerOrThrow()) - .blockingGet(); - session.scope = scope; - session.channel = scope.getUwbComplexChannel(); - } else { - UwbControleeSessionScope scope = UwbManagerRx - .controleeSessionScopeSingle(managerOrThrow()) - .blockingGet(); - session.scope = scope; + UwbManagerRx.controllerSessionScopeSingle(uwb) + .subscribeOn(Schedulers.io()) + .subscribe(new io.reactivex.rxjava3.functions.Consumer< + UwbControllerSessionScope>() { + public void accept(UwbControllerSessionScope scope) { + session.scope = scope; + session.channel = scope.getUwbComplexChannel(); + finishPrepare(requestId, session); + } + }, new io.reactivex.rxjava3.functions.Consumer< + Throwable>() { + public void accept(Throwable error) { + fail(requestId, NearbyError.SESSION_FAILED, + message(error)); + } + }); + return; } + UwbManagerRx.controleeSessionScopeSingle(uwb) + .subscribeOn(Schedulers.io()) + .subscribe(new io.reactivex.rxjava3.functions.Consumer< + UwbControleeSessionScope>() { + public void accept(UwbControleeSessionScope scope) { + session.scope = scope; + finishPrepare(requestId, session); + } + }, new io.reactivex.rxjava3.functions.Consumer< + Throwable>() { + public void accept(Throwable error) { + fail(requestId, NearbyError.SESSION_FAILED, + message(error)); + } + }); + } catch (Throwable t) { + fail(requestId, NearbyError.SESSION_FAILED, message(t)); + } + } + + /// Finishes a prepare once the session scope has been opened. Runs on the + /// RxJava io thread the scope was delivered on, never on the EDT. + /// + /// #### Parameters + /// + /// - `requestId`: the request to answer + /// - `session`: the session whose scope is now set + private void finishPrepare(int requestId, Session session) { + try { session.localAddress = session.scope.getLocalAddress(); session.sessionId = random.nextInt(Integer.MAX_VALUE - 1) + 1; session.sessionKey = new byte[8]; random.nextBytes(session.sessionKey); - sessions.put(Integer.valueOf(sessionHandle), session); - Ranging.deliverSessionPrepared(requestId, sessionHandle, controller, - RangingToken.PLATFORM_ANDROID_UWB, session.token()); + sessions.put(Integer.valueOf(session.handle), session); + Ranging.deliverSessionPrepared(requestId, session.handle, + session.controller, RangingToken.PLATFORM_ANDROID_UWB, + session.token()); } catch (Throwable t) { fail(requestId, NearbyError.SESSION_FAILED, message(t)); } diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 73c50114a04..9530dfcee7e 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -385,8 +385,17 @@ @interface CN1NearbyTransport : NSObject "chat" + // "_chat._tcp" -> "chat", and "_chat._tcp." likewise. + // + // The trailing dot is not optional to handle: the builder's + // NSBonjourServices renderer appends one to every entry, so that is + // the spelling most plists actually carry. Stripped FIRST -- taken + // last, it is the dot the transport suffix is cut at, which left + // "chat._tcp" here and made every declared service look undeclared. NSString *name = (NSString *)entry; + while ([name hasSuffix:@"."]) { + name = [name substringToIndex:[name length] - 1]; + } if ([name hasPrefix:@"_"]) { name = [name substringFromIndex:1]; } 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 9dec9fc9f6c..d56d83e213e 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 @@ -336,6 +336,88 @@ static String foldBonjourServiceType(String serviceId) { /// #### Returns /// /// the escaped text + /// Adds the nearby service types to the app's Bonjour array, MERGING + /// with whatever is already declared rather than replacing it. + /// + /// Written through the `ios.NSBonjourServices` hint, the same route Matter + /// commissioning uses, and NOT through `ios.plistInject`. The plist + /// renderer emits the generated array only when the injected fragment has + /// no `NSBonjourServices` key of its own -- a plist carrying the key twice + /// keeps neither value reliably -- so injecting the key here silently + /// suppressed every service the app had already declared. An app that + /// commissions Matter accessories and also uses nearby transport lost + /// `_matter._tcp.` and `_matterc._udp.` that way, and iOS then drops the + /// mDNS traffic those need. + /// + /// A project that owns the key through `ios.plistInject` is refused + /// rather than rewritten, again as Matter does: the fragment is the + /// developer's own XML and reformatting it here would be guessing. + /// + /// @param request the build request + /// @param serviceTypes the folded service types this app advertises + private void mergeNearbyBonjourServices(BuildRequest request, + List serviceTypes) throws BuildException { + List needed = new ArrayList(); + for (int i = 0; i < serviceTypes.size(); i++) { + // MultipeerConnectivity uses both transports for one service. + needed.add("_" + serviceTypes.get(i) + "._tcp."); + needed.add("_" + serviceTypes.get(i) + "._udp."); + } + if (WatchNativeBuilder.injectedPlistKeys(request) + .contains("NSBonjourServices")) { + List declared = WatchNativeBuilder + .injectedPlistStringArray(request, "NSBonjourServices"); + List missing = new ArrayList(); + for (String service : needed) { + if (!bonjourListed(declared, service)) { + missing.add(service); + } + } + if (!missing.isEmpty()) { + throw new BuildException( + "This app uses com.codename1.nearby.transport and " + + "declares NSBonjourServices through ios.plistInject, " + + "but that array does not list " + missing + ". iOS " + + "drops mDNS traffic for a service type the plist " + + "does not name, so the app would find no peers. Add " + + "those entries to the array in ios.plistInject, or " + + "remove the key from it and let the build declare " + + "the array through ios.NSBonjourServices."); + } + return; + } + String bonjour = request.getArg("ios.NSBonjourServices", ""); + List existing = new ArrayList(); + for (String entry : bonjour.split("[,;]")) { + existing.add(entry.trim()); + } + for (String service : needed) { + if (bonjourListed(existing, service)) { + continue; + } + existing.add(service); + bonjour = bonjour.trim().length() == 0 ? service + : bonjour.trim() + "," + service; + } + request.putArgument("ios.NSBonjourServices", bonjour); + } + + /// True when a Bonjour service type is already in a list, with or without + /// its trailing dot -- both spellings appear in the wild and name the + /// same service. + private static boolean bonjourListed(List declared, + String service) { + String bare = service.endsWith(".") + ? service.substring(0, service.length() - 1) : service; + for (String entry : declared) { + String trimmed = entry == null ? "" : entry.trim(); + if (trimmed.equals(service) || trimmed.equals(bare)) { + return true; + } + } + return false; + } + private static String escapeNearbyPlistText(String value) { return value.replace("&", "&").replace("<", "<") .replace(">", ">"); @@ -4456,15 +4538,7 @@ public void usesClassMethod(String cls, String method) { + " comma-separated list of the service" + " ids this app passes to" + " startAdvertising)")); - String[] bonjour = new String[serviceTypes.size() * 2]; - for (int i = 0; i < serviceTypes.size(); i++) { - bonjour[i * 2] = "_" + serviceTypes.get(i) + "._tcp"; - bonjour[i * 2 + 1] = "_" + serviceTypes.get(i) - + "._udp"; - } - declareNearbyPlistArray(request, "NSBonjourServices", - bonjour, - "MultipeerConnectivity cannot browse without it"); + mergeNearbyBonjourServices(request, serviceTypes); } if (usesNearbyCompanion) { enableNearbyDefine(buildinRes, "CN1_NEARBY_COMPANION"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 5d114fc43ec..7a0419f3c0d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -130,8 +130,16 @@ static String inject(String xPermissions, boolean ranging, // refuses to start without it. Capped so 33 and later use // NEARBY_WIFI_DEVICES instead and the app stops asking for // location it does not use. - out = addPermission(out, "android.permission.ACCESS_FINE_LOCATION", - tiramisu ? " android:maxSdkVersion=\"32\"" : ""); + // + // Widened rather than added, because addPermission suppresses a + // duplicate by NAME alone. BluetoothManifestFragments runs first + // and, for a scanning app with the default neverForLocation, has + // already declared this permission with maxSdkVersion="30" -- so + // the plain add left that cap in place and transport had no + // location grant at all on Android 12 and 12L, where it cannot + // start without one. + out = widenPermission(out, "android.permission.ACCESS_FINE_LOCATION", + tiramisu ? 32 : 0); } if (companion) { @@ -212,6 +220,66 @@ private static String addPermission(String xPermissions, String name, + extraAttributes + " />\n" + xPermissions; } + /// Makes sure a permission is declared and that its `maxSdkVersion` cap, + /// if any, reaches at least as far as this feature needs. + /// + /// A permission another feature already declared is not re-added -- the + /// manifest would then carry it twice -- so the only way to widen its + /// reach is to edit the declaration that is there. An existing + /// declaration with no cap already covers every level and is left alone. + /// + /// @param xPermissions the manifest fragment so far + /// @param name the permission + /// @param requiredThrough the highest API level at which the permission + /// must still be granted, or 0 when it must not be capped at all + /// @return the fragment, with the declaration added or widened + static String widenPermission(String xPermissions, String name, + int requiredThrough) { + int at = xPermissions.indexOf("\"" + name + "\""); + if (at < 0) { + return addPermission(xPermissions, name, requiredThrough > 0 + ? " android:maxSdkVersion=\"" + requiredThrough + "\"" : ""); + } + int start = xPermissions.lastIndexOf('<', at); + int end = xPermissions.indexOf('>', at); + if (start < 0 || end < 0) { + return xPermissions; + } + String element = xPermissions.substring(start, end + 1); + String marker = "android:maxSdkVersion=\""; + int capAt = element.indexOf(marker); + if (capAt < 0) { + // Uncapped, so it already reaches further than anything asked for. + return xPermissions; + } + int capEnd = element.indexOf('"', capAt + marker.length()); + if (capEnd < 0) { + return xPermissions; + } + int cap; + try { + cap = Integer.parseInt(element.substring( + capAt + marker.length(), capEnd).trim()); + } catch (NumberFormatException notANumber) { + return xPermissions; + } + if (requiredThrough > 0 && cap >= requiredThrough) { + return xPermissions; + } + String widened; + if (requiredThrough > 0) { + widened = element.substring(0, capAt + marker.length()) + + requiredThrough + element.substring(capEnd); + } else { + widened = element.substring(0, capAt) + + element.substring(capEnd + 1); + // The attribute left a double space behind it. + widened = widened.replace(" ", " "); + } + return xPermissions.substring(0, start) + widened + + xPermissions.substring(end + 1); + } + private static String addFeature(String xPermissions, String name, boolean required) { if (xPermissions.contains("\"" + name + "\"")) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java new file mode 100644 index 00000000000..c2e14867046 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java @@ -0,0 +1,156 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The nearby transport's Bonjour service types have to JOIN the app's array, + * not replace it. + * + *

Writing an {@code NSBonjourServices} key into {@code ios.plistInject} + * looks equivalent and is not: the plist renderer emits the array built from + * the {@code ios.NSBonjourServices} hint only when the injected fragment has + * no key of its own, because a plist carrying the key twice keeps neither + * value reliably. So the injection silently suppressed every service the app + * had already declared -- most visibly the {@code _matter._tcp.} and + * {@code _matterc._udp.} entries Matter commissioning accumulates, without + * which iOS stops delivering the mDNS traffic commissioning depends on.

+ */ +class NearbyBonjourMergeTest { + + private static BuildRequest request(String... kv) { + BuildRequest r = new BuildRequest(); + r.setMainClass("MyApp"); + r.setPackageName("com.example"); + for (int i = 0; i < kv.length; i += 2) { + r.putArgument(kv[i], kv[i + 1]); + } + return r; + } + + /** Drives the private merge and hands back the resulting hint. */ + private static String merge(BuildRequest request, String... serviceTypes) + throws Exception { + IPhoneBuilder b = new IPhoneBuilder(); + Method m = IPhoneBuilder.class.getDeclaredMethod( + "mergeNearbyBonjourServices", BuildRequest.class, List.class); + m.setAccessible(true); + try { + m.invoke(b, request, new ArrayList( + Arrays.asList(serviceTypes))); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof Exception) { + throw (Exception) e.getCause(); + } + throw e; + } + return request.getArg("ios.NSBonjourServices", ""); + } + + @Test + void theTypesGoIntoTheHintNotIntoPlistInject() throws Exception { + BuildRequest r = request(); + String hint = merge(r, "chat"); + assertTrue(hint.contains("_chat._tcp."), hint); + assertTrue(hint.contains("_chat._udp."), hint); + assertEquals("", r.getArg("ios.plistInject", ""), + "the key must not be injected, or the generated array is" + + " suppressed wholesale"); + } + + @Test + void servicesTheAppAlreadyDeclaredSurvive() throws Exception { + // Exactly what Matter commissioning leaves behind. + BuildRequest r = request("ios.NSBonjourServices", + "_matter._tcp.,_matterc._udp."); + String hint = merge(r, "chat"); + assertTrue(hint.contains("_matter._tcp."), hint); + assertTrue(hint.contains("_matterc._udp."), hint); + assertTrue(hint.contains("_chat._tcp."), hint); + } + + @Test + void aTypeThatIsAlreadyThereIsNotAddedTwice() throws Exception { + BuildRequest r = request("ios.NSBonjourServices", + "_chat._tcp.,_chat._udp."); + String hint = merge(r, "chat"); + assertEquals(2, hint.split(",").length, hint); + } + + @Test + void theTrailingDotIsNotWhatDecidesAMatch() throws Exception { + // Both spellings appear in the wild and name the same service. + BuildRequest r = request("ios.NSBonjourServices", "_chat._tcp"); + String hint = merge(r, "chat"); + assertEquals(1, countOccurrences(hint, "_chat._tcp"), hint); + } + + @Test + void aProjectThatOwnsTheKeyIsToldWhatToAddRatherThanOverwritten() + throws Exception { + BuildRequest r = request("ios.plistInject", + "NSBonjourServices" + + "_matter._tcp."); + BuildException thrown = assertThrows(BuildException.class, + new org.junit.jupiter.api.function.Executable() { + @Override + public void execute() throws Throwable { + merge(r, "chat"); + } + }); + assertTrue(thrown.getMessage().contains("_chat._tcp."), + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("ios.plistInject"), + thrown.getMessage()); + } + + @Test + void aProjectThatOwnsTheKeyAndListedTheTypesIsLeftAlone() throws Exception { + BuildRequest r = request("ios.plistInject", + "NSBonjourServices" + + "_chat._tcp." + + "_chat._udp."); + merge(r, "chat"); + } + + private static int countOccurrences(String haystack, String needle) { + int n = 0; + int at = haystack.indexOf(needle); + while (at >= 0) { + n++; + at = haystack.indexOf(needle, at + needle.length()); + } + return n; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index b1227c1d89b..a7c0b063a80 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -256,6 +256,58 @@ void nullInputIsTreatedAsEmpty() { assertTrue(out.contains("android.permission.UWB_RANGING")); } + @Test + void transportWidensTheLocationCapBluetoothAlreadyDeclared() { + // BluetoothManifestFragments runs first and, for a scanning app with + // the default neverForLocation, caps this at 30. Nearby Connections + // needs it through 32, and a plain duplicate-suppressing add left the + // 30 in place -- so transport had no location grant at all on Android + // 12 and 12L, where the API refuses to start without one. + String bluetooth = BluetoothManifestFragments.inject("", true, false, + false, false, true, false, 34); + assertTrue(bluetooth.contains("ACCESS_FINE_LOCATION"), + "precondition: bluetooth declares the permission"); + assertTrue(bluetooth.contains("android:maxSdkVersion=\"30\""), + "precondition: bluetooth caps it at 30"); + + String out = NearbyManifestFragments.inject(bluetooth, false, true, + false, false, false, 34); + int at = out.indexOf("ACCESS_FINE_LOCATION"); + int elementEnd = out.indexOf('>', at); + String element = out.substring(out.lastIndexOf('<', at), elementEnd); + assertTrue(element.contains("android:maxSdkVersion=\"32\""), + "the cap should reach 32: " + element); + // Widened, never duplicated: two declarations of one permission is + // not a manifest Android accepts predictably. + assertEquals(out.indexOf("ACCESS_FINE_LOCATION"), + out.lastIndexOf("ACCESS_FINE_LOCATION")); + } + + @Test + void transportBelowTiramisuRemovesTheCapAltogether() { + // With no NEARBY_WIFI_DEVICES to fall back on, the location grant has + // to hold at every level the app runs at. + String bluetooth = BluetoothManifestFragments.inject("", true, false, + false, false, true, false, 32); + String out = NearbyManifestFragments.inject(bluetooth, false, true, + false, false, false, 32); + int at = out.indexOf("ACCESS_FINE_LOCATION"); + String element = out.substring(out.lastIndexOf('<', at), + out.indexOf('>', at)); + assertFalse(element.contains("maxSdkVersion"), + "the cap should be gone: " + element); + } + + @Test + void aCapThatAlreadyReachesFarEnoughIsLeftAlone() { + String seeded = " \n"; + String out = NearbyManifestFragments.inject(seeded, false, true, false, + false, false, 34); + assertTrue(out.contains("android:maxSdkVersion=\"33\""), + "a wider cap is not narrowed: " + out); + } + @Test void usingNoneOfItChangesNothing() { String seeded = " \n"; diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index c8ab2e3d4fd..d95367af266 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -280,6 +280,35 @@ public void updated(RangingUpdate u) { assertEquals(before, seen.get()); } + @Test + void anUpdateAlreadyOnItsWayCannotRestartAStoppedSession() { + // A native update queued from a background thread can reach the EDT + // after stop() ran there. Delivering it set running back to true on a + // session isRunning() had already promised was finished, and notified + // a listener registered after the stop. + RangingSession s = running(); + int handle = handleOf(); + s.stop(); + assertFalse(s.isRunning()); + + final AtomicInteger seen = new AtomicInteger(); + s.addRangingListener(new RangingAdapter() { + @Override + public void updated(RangingUpdate u) { + seen.incrementAndGet(); + } + }); + RangingSession.deliverUpdate(handle, true, 1.5, false, 0, false, 0, + null); + RangingSession.deliverSuspended(handle); + RangingSession.deliverResumed(handle); + RangingSession.deliverInvalidated(handle, + NearbyError.SESSION_FAILED.ordinal(), "too late"); + + assertEquals(0, seen.get()); + assertFalse(s.isRunning()); + } + @Test void aPeerCanWalkAwayWithoutKillingTheSession() { RangingSession s = running(); From 83edaffacabed66510fdb40b2ca228101cb24a4a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:22:23 +0300 Subject: [PATCH 21/94] Address the tenth nearby review round Two of these are use-after-free crashes, both from the same mistake: removing the dictionary entry that was the object's only owner, and then using the object. - The copied invitation block is retained across the removal in both the accept and the reject path. The dictionary owns it by the time the app answers -- the pool the delegate autoreleased it into drained long ago -- so calling it after removeObjectForKey called freed memory. - closeSessionFor retains the MCSession the same way before clearing its delegate and disconnecting it. - One NSProgress per RECIPIENT, not per payload. A file sent to three peers stored three progresses under one key and kept the last, so cancel() stopped one transfer and the other two ran to completion reporting success; the first completion then dropped the shared key and left the rest uncancellable. Cancel now cancels every recipient, and the entry survives until the last one finishes. - A received file gets a destination unique to the transfer. Built from the sender's basename alone, a second photo.jpg overwrote the first -- under a Payload whose immutable path the app had already been given. - Both post-parse file failures report the recovered sender id instead of 0, so a receiver can match the failure to the payload and to the progress it was watching. - Advertising and discovery defer their answer by half a second. MultipeerConnectivity accepts a start synchronously and refuses it later, and the AsyncResource had already resolved true by then, so the refusal had nowhere to go. Stopping before the grace period elapses answers the pending start rather than stranding it. - Android transport availability reports UNAUTHORIZED when its permissions are missing instead of a flat AVAILABLE, so an app can act on the state the public API documents rather than discovering it at the first failed advertise. - The permission list moves into NearbyPermissions, used by both the coordinator that asks and the transport that reports -- answering differently is worse than either answer. It keys off the lower of the device level and the app's target, because Android's Bluetooth permission model does: an app targeting 30 on Android 12 uses the legacy permissions and location, and asking it for BLUETOOTH_SCAN left the grant it actually needed unrequested. - The manifest declares the split Bluetooth permissions and NEARBY_WIFI_DEVICES whatever the app targets, as UWB_RANGING already is: requesting a permission the manifest does not declare is refused instantly with no prompt. The caps stay keyed to the target, which is what the platform honours. - Progress for an INCOMING payload is reported under the sender's id. payloadIds was written only by our own sendPayload, so a receiver saw PayloadTransferUpdate.getPayloadId() disagree with the Payload.getId() it had just been handed. Two tests asserted the manifest behaviour the target-SDK gate produced and now assert the corrected contract. --- .../android/nearby/AndroidNearbyBackend.java | 29 ++- .../nearby/AndroidNearbyTransport.java | 28 +++ .../android/nearby/NearbyPermissions.java | 133 ++++++++++++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 171 ++++++++++++++++-- .../builders/NearbyManifestFragments.java | 30 +-- .../builders/NearbyManifestFragmentsTest.java | 32 +++- 6 files changed, 367 insertions(+), 56 deletions(-) create mode 100644 Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 4199cfe8cb2..e862a7c2827 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -163,24 +163,17 @@ public void requestPermissions(int requestId, int permissionBits) { | NearbyBridge.PERMISSION_ADVERTISE | NearbyBridge.PERMISSION_CONNECT)) != 0; if (transportBits) { - if (Build.VERSION.SDK_INT >= 31) { - if ((permissionBits & NearbyBridge.PERMISSION_DISCOVERY) != 0) { - add(perms, "android.permission.BLUETOOTH_SCAN"); - } - if ((permissionBits & NearbyBridge.PERMISSION_ADVERTISE) != 0) { - add(perms, "android.permission.BLUETOOTH_ADVERTISE"); - } - if ((permissionBits & NearbyBridge.PERMISSION_CONNECT) != 0) { - add(perms, "android.permission.BLUETOOTH_CONNECT"); - } - } - if (Build.VERSION.SDK_INT >= 33) { - add(perms, "android.permission.NEARBY_WIFI_DEVICES"); - } else { - // Below 33 Nearby Connections genuinely refuses to start - // without a location grant; it is not a scan-results - // technicality there. - add(perms, "android.permission.ACCESS_FINE_LOCATION"); + // Worked out by NearbyPermissions, which AndroidNearbyTransport + // also uses to answer getTransportAvailability -- one list, so + // the two cannot disagree about what "ready" means. It keys off + // the app's TARGET as well as the device level, because Android's + // Bluetooth permission model does: an app targeting 30 on Android + // 12 uses the legacy permissions and location, and asking it for + // BLUETOOTH_SCAN left the grant it needed unrequested. + List transport = NearbyPermissions.transportPermissions( + activity, permissionBits); + for (int i = 0; i < transport.size(); i++) { + add(perms, transport.get(i)); } } if (perms.isEmpty()) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index a8c6f6d4e2b..afc55b719a4 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -117,6 +117,20 @@ public boolean isTransportSupported() { } public int getTransportAvailability() { + // Reported honestly rather than as a flat AVAILABLE. Nearby + // Connections needs Bluetooth and, depending on the level, nearby-WiFi + // or location; without them advertising and discovery fail on the + // first call. Saying AVAILABLE anyway made getAvailability() unable to + // return the UNAUTHORIZED the public API documents, so an app showed + // the feature as ready right up to the failure and had nothing to + // prompt from. + if (!NearbyPermissions.allGranted(context, + NearbyPermissions.transportPermissions(context, + NearbyBridge.PERMISSION_DISCOVERY + | NearbyBridge.PERMISSION_ADVERTISE + | NearbyBridge.PERMISSION_CONNECT))) { + return NearbyAvailability.UNAUTHORIZED.ordinal(); + } return NearbyAvailability.AVAILABLE.ordinal(); } @@ -452,6 +466,14 @@ public void onPayloadReceived(String endpointId, Payload payload) { System.arraycopy(body, 4, trimmed, 0, trimmed.length); body = trimmed; } + // Recorded so the terminal transfer update for this + // payload reports the SENDER's id too. payloadIds was + // written only by our own sendPayload, so an incoming + // transfer fell back to Google's receiver-local id and + // PayloadTransferUpdate.getPayloadId() disagreed with the + // Payload.getId() the app had just been handed. + payloadIds.put(Long.valueOf(payload.getId()), + Integer.valueOf(senderId)); NearbyTransport.deliverPayloadReceived( encode(endpointId, nameOf(endpointId)), senderId, NearbyBridge.PAYLOAD_BYTES, body, null); @@ -466,6 +488,12 @@ public void onPayloadReceived(String endpointId, Payload payload) { // told about one that later failed or was cancelled. // Held until the terminal SUCCESS update names this id. incomingFiles.put(Long.valueOf(payload.getId()), payload); + // Same reason as the BYTES branch: progress for an + // incoming file has to be reported under the id the + // sender framed into the name, which is the id the + // delivered Payload will carry. + payloadIds.put(Long.valueOf(payload.getId()), + Integer.valueOf(senderIdOf(payload))); } } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java new file mode 100644 index 00000000000..91536535cf7 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java @@ -0,0 +1,133 @@ +/* + * 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.impl.android.nearby; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import com.codename1.nearby.spi.NearbyBridge; + +import java.util.ArrayList; +import java.util.List; + +/// The permissions the nearby transport needs, in one place. +/// +/// Two callers need the same answer and used to work it out separately: +/// `AndroidNearbyBackend` when it asks the user, and `AndroidNearbyTransport` +/// when it reports availability. Answering differently is worse than either +/// answer -- an app is told the transport is ready and then refused. +final class NearbyPermissions { + + private NearbyPermissions() { + } + + /// The API level whose rules actually apply to this app. + /// + /// NOT `Build.VERSION.SDK_INT` on its own. Android's Bluetooth permission + /// model switches on the app's TARGET, not on the device: an app + /// targeting 30 running on Android 12 still uses `BLUETOOTH` and + /// `ACCESS_FINE_LOCATION`, and the split permissions do not apply to it. + /// Asking such an app for `BLUETOOTH_SCAN` requested something the + /// platform was never going to route to it, and asking it for the wrong + /// one meant the grant it did need was never requested at all. + /// + /// #### Parameters + /// + /// - `context`: any context + /// + /// #### Returns + /// + /// the lower of the device level and the app's target + static int effectiveSdk(Context context) { + int device = Build.VERSION.SDK_INT; + int target = device; + try { + target = context.getApplicationInfo().targetSdkVersion; + } catch (Throwable unreadable) { + // A context with no application info is not a case worth failing + // for; the device level alone is the pre-existing behaviour. + } + return target < device ? target : device; + } + + /// The runtime permissions the requested transport operations need. + /// + /// #### Parameters + /// + /// - `context`: any context + /// - `permissionBits`: the `NearbyBridge.PERMISSION_*` bits asked for + /// + /// #### Returns + /// + /// the permission strings, never null + static List transportPermissions(Context context, + int permissionBits) { + List out = new ArrayList(); + int sdk = effectiveSdk(context); + if (sdk >= 31) { + if ((permissionBits & NearbyBridge.PERMISSION_DISCOVERY) != 0) { + out.add("android.permission.BLUETOOTH_SCAN"); + } + if ((permissionBits & NearbyBridge.PERMISSION_ADVERTISE) != 0) { + out.add("android.permission.BLUETOOTH_ADVERTISE"); + } + if ((permissionBits & NearbyBridge.PERMISSION_CONNECT) != 0) { + out.add("android.permission.BLUETOOTH_CONNECT"); + } + } + if (sdk >= 33) { + out.add("android.permission.NEARBY_WIFI_DEVICES"); + } else { + // Below 33 Nearby Connections genuinely refuses to start without + // a location grant; it is not a scan-results technicality there. + out.add("android.permission.ACCESS_FINE_LOCATION"); + } + return out; + } + + /// True when every permission in the list is granted right now. Never + /// prompts: this is the question an availability query asks. + /// + /// #### Parameters + /// + /// - `context`: any context + /// - `permissions`: the permissions to test + /// + /// #### Returns + /// + /// true when all of them are granted + static boolean allGranted(Context context, List permissions) { + if (Build.VERSION.SDK_INT < 23) { + // Install-time grants; anything in the manifest is held. + return true; + } + for (int i = 0; i < permissions.size(); i++) { + if (context.checkSelfPermission(permissions.get(i)) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } +} diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 9530dfcee7e..bf691abbe22 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -149,6 +149,18 @@ static void cn1nbTransportOk(int requestId) { getThreadLocalData(), requestId); } +/// How long a start is given to fail before it is called a success. +/// +/// MultipeerConnectivity accepts startAdvertisingPeer and +/// startBrowsingForPeers synchronously and rejects them later, through +/// didNotStartAdvertisingPeer / didNotStartBrowsingForPeers -- an unavailable +/// radio, a service type it will not take. Answering the caller straight away +/// meant the AsyncResource had already resolved true by the time the refusal +/// arrived, so the refusal had nowhere to go and the app was left believing +/// it was advertising. The refusals that do come, come immediately; half a +/// second is long enough to catch them and short enough not to be felt. +#define CN1_NEARBY_START_GRACE_NS (500ull * NSEC_PER_MSEC) + // ===================================================================== // Ranging -- Nearby Interaction // ===================================================================== @@ -354,6 +366,10 @@ @interface CN1NearbyTransport : NSObject _receiveSequence; + } NSString *target = [docs stringByAppendingPathComponent: - [NSString stringWithFormat:@"cn1nearby-%@", safe]]; + [NSString stringWithFormat:@"cn1nearby-%d-%d-%@", + (int)filePayloadId, received, safe]]; // Belt and braces: whatever the name folded to, the result has to // stay inside the directory it was built from. if (![[target stringByStandardizingPath] hasPrefix:[docs stringByStandardizingPath]]) { com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( - getThreadLocalData(), cn1nbJString(encoded), 0, 0, -1, - CN1_NEARBY_PAYLOAD_FAILURE); + getThreadLocalData(), cn1nbJString(encoded), + filePayloadId, 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); return; } [[NSFileManager defaultManager] removeItemAtPath:target error:nil]; @@ -687,9 +748,13 @@ - (void)session:(MCSession *)session toPath:target error:&moveError]; if (moveError != nil) { + // The recovered id, not zero. The sender's id has already been + // parsed out of the resource name at this point, and reporting + // the failure under 0 left the receiver unable to match it to the + // payload or to the progress it had been watching. com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( - getThreadLocalData(), cn1nbJString(encoded), 0, 0, -1, - CN1_NEARBY_PAYLOAD_FAILURE); + getThreadLocalData(), cn1nbJString(encoded), + filePayloadId, 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); return; } com_codename1_impl_ios_IOSNearbyCallbacks_payloadReceived___java_lang_String_int_int_byte_1ARRAY_java_lang_String( @@ -895,6 +960,41 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { return cn1nbTransport; } +/// Answers a start request once the framework has had its chance to refuse it. +/// +/// A second answer is harmless -- the Java side takes a pending request out of +/// its map, so whichever of this and the delegate's failure arrives first +/// wins and the other is dropped -- which is what makes the race between them +/// safe rather than merely unlikely. +/// +/// #### Parameters +/// +/// - `t`: the transport +/// - `advertising`: YES for advertising, NO for discovery +/// - `requestId`: the request to answer +static void cn1nbSettleTransportStart(CN1NearbyTransport *t, BOOL advertising, + int requestId) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)CN1_NEARBY_START_GRACE_NS), + dispatch_get_main_queue(), ^{ + @autoreleasepool { + int pending = advertising ? t.pendingAdvertiseRequest + : t.pendingDiscoverRequest; + if (pending != requestId) { + // Already failed by the delegate, already answered by a stop, + // or superseded by a newer start. Not ours to answer. + return; + } + if (advertising) { + t.pendingAdvertiseRequest = 0; + } else { + t.pendingDiscoverRequest = 0; + } + cn1nbTransportOk(requestId); + } + }); +} + #endif // CN1_NEARBY_HAS_MPC // ===================================================================== @@ -1540,7 +1640,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str // after this returns, and it needs the id to fail. t.pendingAdvertiseRequest = requestId; [t.advertiser startAdvertisingPeer]; - cn1nbTransportOk(requestId); + cn1nbSettleTransportStart(t, YES, requestId); return; } #endif @@ -1556,7 +1656,16 @@ void com_codename1_impl_ios_IOSNative_nearbyStopAdvertising__( [cn1nbTransport.advertiser stopAdvertisingPeer]; cn1nbTransport.advertiser.delegate = nil; cn1nbTransport.advertiser = nil; + // Answered on the way out. Advertising DID start -- the framework + // took it -- and stopping before the grace period elapsed would + // otherwise leave the start's AsyncResource unresolved for good, + // because the deferred answer only fires for a request that is + // still pending. + int pending = cn1nbTransport.pendingAdvertiseRequest; cn1nbTransport.pendingAdvertiseRequest = 0; + if (pending != 0) { + cn1nbTransportOk(pending); + } } } #endif @@ -1584,7 +1693,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_Strin t.browser.delegate = t; t.pendingDiscoverRequest = requestId; [t.browser startBrowsingForPeers]; - cn1nbTransportOk(requestId); + cn1nbSettleTransportStart(t, NO, requestId); return; } #endif @@ -1600,7 +1709,12 @@ void com_codename1_impl_ios_IOSNative_nearbyStopDiscovery__( [cn1nbTransport.browser stopBrowsingForPeers]; cn1nbTransport.browser.delegate = nil; cn1nbTransport.browser = nil; + // Answered on the way out, for the reason stopAdvertising is. + int pending = cn1nbTransport.pendingDiscoverRequest; cn1nbTransport.pendingDiscoverRequest = 0; + if (pending != 0) { + cn1nbTransportOk(pending); + } } } #endif @@ -1651,9 +1765,14 @@ void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_Str #ifdef CN1_NEARBY_HAS_MPC @autoreleasepool { NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); + // Retained across the removal. The dictionary is the only owner of + // the copied block by the time the app answers -- the pool the + // delegate autoreleased it into drained long ago -- so removing the + // entry first freed the block and calling it crashed. void (^handler)(BOOL, MCSession *) = cn1nbTransport == nil ? nil - : [cn1nbTransport.invitations objectForKey:pid]; + : [[[cn1nbTransport.invitations objectForKey:pid] + retain] autorelease]; if (handler == nil) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, @"there is no invitation from that endpoint"); @@ -1678,7 +1797,8 @@ void com_codename1_impl_ios_IOSNative_nearbyRejectConnection___java_lang_String( } NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); void (^handler)(BOOL, MCSession *) = - [cn1nbTransport.invitations objectForKey:pid]; + [[[cn1nbTransport.invitations objectForKey:pid] retain] + autorelease]; if (handler != nil) { [cn1nbTransport.invitations removeObjectForKey:pid]; handler(NO, nil); @@ -1729,6 +1849,10 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i MCPeerID *peer = [peers objectAtIndex:i]; MCSession *session = [cn1nbTransport sessionFor:[peerIds objectAtIndex:i]]; + // Captured by the completion block so it can forget exactly + // its own progress. A block cannot capture the __block-free + // NSProgress before it exists, so the holder stands in. + NSMutableArray *progressHolder = [NSMutableArray array]; NSProgress *progress = [session sendResourceAtURL:url withName:sentName toPeer:peer @@ -1740,17 +1864,26 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i payloadId, 0, -1, error == nil ? CN1_NEARBY_PAYLOAD_SUCCESS : CN1_NEARBY_PAYLOAD_FAILURE); - [cn1nbTransport.progressByPayload removeObjectForKey: - [NSNumber numberWithInt:(int)payloadId]]; + // Only THIS recipient's transfer is finished. The + // others under the same payload id are still going, + // and dropping the whole entry here left them + // uncancellable. + [cn1nbTransport forgetProgress:progressHolder + forPayload:payloadId]; } }]; // Retained so cancel() can actually stop a large transfer. // Without it cancelling did nothing at all: the file kept // going, kept using the radio, and could still report success. + // + // One entry per RECIPIENT. Keyed by payload id alone, a send + // to three peers stored three progresses under one key and + // kept only the last, so cancel() stopped one transfer and + // the other two ran to completion reporting success. if (progress != nil) { - [cn1nbTransport.progressByPayload - setObject:progress - forKey:[NSNumber numberWithInt:(int)payloadId]]; + [progressHolder addObject:progress]; + [cn1nbTransport rememberProgress:progress + forPayload:payloadId]; } } cn1nbTransportOk(requestId); @@ -1821,10 +1954,10 @@ void com_codename1_impl_ios_IOSNative_nearbyCancelPayload___int( // time anything could ask, which is the same outcome an app gets from // cancelling one anywhere. NSNumber *key = [NSNumber numberWithInt:payloadId]; - NSProgress *progress = [cn1nbTransport.progressByPayload - objectForKey:key]; - if (progress != nil) { - [cn1nbTransport.progressByPayload removeObjectForKey:key]; + NSArray *all = [[[cn1nbTransport.progressByPayload objectForKey:key] + copy] autorelease]; + [cn1nbTransport.progressByPayload removeObjectForKey:key]; + for (NSProgress *progress in all) { [progress cancel]; } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 7a0419f3c0d..4ac367f9b63 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -108,23 +108,27 @@ static String inject(String xPermissions, boolean ranging, legacyCap); out = addPermission(out, "android.permission.BLUETOOTH_ADMIN", legacyCap); - if (modern) { - out = addPermission(out, "android.permission.BLUETOOTH_SCAN", - " android:usesPermissionFlags=\"neverForLocation\""); - out = addPermission(out, - "android.permission.BLUETOOTH_ADVERTISE", ""); - out = addPermission(out, "android.permission.BLUETOOTH_CONNECT", - ""); - } + // Declared whatever the app targets, for the same reason + // UWB_RANGING above is. A permission is asked for at RUNTIME + // according to the level the app is actually running under, and + // requesting one the manifest does not declare is refused + // instantly with no prompt -- so a target-30 app on Android 12 + // could not ask for BLUETOOTH_SCAN at all. A device below 31 + // ignores permissions it has never heard of, so declaring them + // costs an older device nothing. + out = addPermission(out, "android.permission.BLUETOOTH_SCAN", + " android:usesPermissionFlags=\"neverForLocation\""); + out = addPermission(out, + "android.permission.BLUETOOTH_ADVERTISE", ""); + out = addPermission(out, "android.permission.BLUETOOTH_CONNECT", + ""); out = addPermission(out, "android.permission.ACCESS_WIFI_STATE", ""); out = addPermission(out, "android.permission.CHANGE_WIFI_STATE", ""); - if (tiramisu) { - out = addPermission(out, - "android.permission.NEARBY_WIFI_DEVICES", - " android:usesPermissionFlags=\"neverForLocation\""); - } + out = addPermission(out, + "android.permission.NEARBY_WIFI_DEVICES", + " android:usesPermissionFlags=\"neverForLocation\""); // Nearby Connections genuinely needs a location grant up to API // 32 -- it is not a scan-results technicality there, the API // refuses to start without it. Capped so 33 and later use diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index a7c0b063a80..0762021d374 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -112,12 +112,15 @@ void transportStopsAskingForLocationOnceNearbyWifiExists() { "android:name=\"android.permission.ACCESS_FINE_LOCATION\"" + " android:maxSdkVersion=\"32\"")); - // Below 33 there is no NEARBY_WIFI_DEVICES, and Nearby Connections - // genuinely refuses to start without a location grant -- so it must - // NOT be capped there. + // Below a target of 33 the CAP is what changes, not the + // declaration. Nearby Connections refuses to start without a location + // grant there, and the app runs under its target's rules whatever + // device it is on -- so location must not be capped. The permission + // is still declared, because the app may run on a 13 device and the + // runtime asks for what THAT device requires. String older = NearbyManifestFragments.inject("", false, true, false, false, false, 31); - assertFalse(older.contains("NEARBY_WIFI_DEVICES")); + assertTrue(older.contains("NEARBY_WIFI_DEVICES")); assertTrue(older.contains( "android:name=\"android.permission.ACCESS_FINE_LOCATION\" />")); } @@ -128,8 +131,25 @@ void transportOnALegacyTargetKeepsTheLegacyPairUncapped() { false, false, 30); assertTrue(out.contains( "android:name=\"android.permission.BLUETOOTH\" />")); - assertFalse(out.contains("BLUETOOTH_SCAN")); - assertFalse(out.contains("BLUETOOTH_ADVERTISE")); + } + + @Test + void theSplitPermissionsAreDeclaredEvenForALegacyTarget() { + // A permission is requested at RUNTIME according to the level the + // app is actually running under, and requesting one the manifest + // does not declare is refused instantly with no prompt -- so a + // target-30 app on Android 12 could not ask for these at all. A + // device below 31 ignores permissions it has never heard of. + String out = NearbyManifestFragments.inject("", false, true, false, + false, false, 30); + assertTrue(out.contains("BLUETOOTH_SCAN"), out); + assertTrue(out.contains("BLUETOOTH_ADVERTISE"), out); + assertTrue(out.contains("BLUETOOTH_CONNECT"), out); + assertTrue(out.contains("NEARBY_WIFI_DEVICES"), out); + // The legacy pair stays uncapped for a legacy target: that is what + // Android 12 actually honours for such an app. + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH\" />"), out); } @Test From 67a21a00da23b2912e844eac4631d4b1994c23b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:48:50 +0300 Subject: [PATCH 22/94] Address the eleventh nearby review round - A listener may answer a connection request after the callback returns. The auto-reject fired whenever the listener had not answered YET, which is the documented flow -- show the authentication token, ask the user, then accept -- so the later accept() was a no-op and the one handshake worth trusting could never connect. It now rejects only when there was no listener at all, which is still the case that would otherwise leave the far side waiting for a timeout. - iOS honours TransportStrategy. Both start methods took the strategy and discarded it, so POINT_TO_POINT connected to as many peers as asked. The connecting side is now bounded to one peer under STAR and POINT_TO_POINT, and the advertising side under POINT_TO_POINT, each refused with BUSY. LocalNearbyBridge enforces the same limits: a simulator that let an app hold three connections under POINT_TO_POINT would teach it a topology no device will honour. - Android clears an endpoint's service mapping when it is lost and when it disconnects. Nearby reuses endpoint ids, so a peer found under the discovery service could come back by connecting to this device's advertisement for a different service and keep the old label. - Ranging.deliverAccessoryConfiguration fails with SESSION_INVALIDATED when the session is gone, mirroring deliverSessionStarted. It was completing successfully and handing the caller handshake bytes for a session stop() had already closed. - The simulator's delayed acceptance is generation-checked, so a stop() between the request and the acceptance can no longer reconnect a transport nobody restarted. Testing that needed a seam: without a Display the bridge runs deliveries inline, so the two-hop race is unreachable from a unit test. deferForTest parks them for the test to release, and both directions are covered -- a stop before the acceptance suppresses it, one that lands first still connects. - iOS accessory ids are stable across launches. An accessory with no bluetoothIdentifier -- the SSID-filter case -- was published under its object hash, which is a different number every launch for an id the API documents as persistable, and which accessoryForId could never match because it only compared Bluetooth UUIDs. One helper now produces the id and matches it, falling back to the SSID and then the display name. - iOS emits the terminal SUCCESS progress update for a received file. Only the failure paths did, so a receiver that dismisses its transfer UI on the documented terminal status waited forever on every file that actually arrived. - The Android received-file id is also read from asUri(). It was suggested this should call Payload.getFileName(); there is no such method -- play-services-nearby 19.3.0 has setFileName and no getter, and javap over the whole connection package finds no getFileName on Payload or Payload.File. The reason that matters is recorded in a comment beside the code. The real gap it points at is scoped storage, where asJavaFile() returns null and the Uri is the only route to the transmitted name. - Builders: refuse a ranging build on a toolchain older than AGP 8.9.1, which androidx.core.uwb's AAR requires alongside minCompileSdk 36; raise compileSdk to at least 31 for the transport, whose permissions carry the API 31 usesPermissionFlags attribute whatever the app targets; and keep the Bonjour API's _http._tcp. default, which the nearby merge took away from an app using both APIs by creating the hint the later block seeds only when it is unset. --- .../impl/nearby/LocalNearbyBridge.java | 98 ++++++++++++ .../com/codename1/nearby/ranging/Ranging.java | 12 +- .../nearby/transport/NearbyTransport.java | 11 +- .../nearby/AndroidNearbyTransport.java | 34 ++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 74 ++++++++- docs/developer-guide/Nearby-Devices.asciidoc | 14 +- .../builders/AndroidGradleBuilder.java | 35 +++++ .../com/codename1/builders/IPhoneBuilder.java | 18 ++- .../builders/NearbyBonjourMergeTest.java | 58 ++++++- .../com/codename1/nearby/LocalNearbyTest.java | 144 ++++++++++++++++++ 10 files changed, 480 insertions(+), 18 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 7a76bdba02f..eb7004b076d 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -34,6 +34,7 @@ import com.codename1.nearby.spi.NearbyBridge; import com.codename1.nearby.transport.NearbyTransport; import com.codename1.nearby.transport.PayloadStatus; +import com.codename1.nearby.transport.TransportStrategy; import com.codename1.ui.Display; import java.util.ArrayList; @@ -101,6 +102,25 @@ public class LocalNearbyBridge implements NearbyBridge { private final List candidates = new ArrayList(); private final List endpoints = new ArrayList(); private final List connected = new ArrayList(); + /// Endpoints whose connection requests were rejected. Recorded so a + /// test can tell an immediate refusal from silence. + private final List rejected = new ArrayList(); + /// The topology each half was started with, as a TransportStrategy + /// ordinal. CLUSTER is the default, which is also what a caller that + /// passed no strategy is given. + private int advertiseStrategy = TransportStrategy.CLUSTER.ordinal(); + private int discoverStrategy = TransportStrategy.CLUSTER.ordinal(); + /// Bumped by every stop, so work queued by an earlier run of the + /// transport can tell that it is answering for a transport that has + /// since been stopped. Nothing in the simulation completes inline, which + /// is the point -- and that means a delayed acceptance really can outlive + /// the stop() that was supposed to have ended it. + private int transportGeneration; + /// Where delayed deliveries go while a test drives the clock, or null in + /// normal operation. + /// + /// @hidden not part of the public API; test-only. + private List deferred; private int sessionSequence; private boolean advertising; @@ -466,6 +486,7 @@ public int getMaxPayloadSize() { public void startAdvertising(final int requestId, String serviceId, String localName, int strategy) { advertising = true; + advertiseStrategy = strategy; answerOk(requestId); } @@ -478,6 +499,7 @@ public void stopAdvertising() { public void startDiscovery(final int requestId, final String serviceId, int strategy) { discovering = true; + discoverStrategy = strategy; answer(new Runnable() { @Override public void run() { @@ -504,15 +526,40 @@ public void requestConnection(final int requestId, final String endpointId, NearbyError.PEER_UNAVAILABLE, "no such endpoint")); return; } + // The simulation refuses what the real platforms refuse. This device + // is the one CONNECTING, and both STAR and POINT_TO_POINT allow it + // exactly one peer -- under STAR it is one of the many, not the + // centre. A simulator that let an app hold three connections under + // POINT_TO_POINT would teach it a topology no device will honour. + if (discoverStrategy != TransportStrategy.CLUSTER.ordinal() + && !connected.isEmpty()) { + answer(new TransportFailure(requestId, NearbyError.BUSY, + "this strategy allows one connection at a time;" + + " disconnect the current peer first")); + return; + } + final int generation = transportGeneration; answer(new Runnable() { @Override public void run() { + if (generation != transportGeneration) { + return; + } NearbyTransport.deliverRequestOk(requestId); // The simulated peer always accepts, one hop later, so the // app sees the two-step shape the real platforms have. answer(new Runnable() { @Override public void run() { + // Checked again here, because THIS is the hop that + // outlives a stop(): the acceptance was already + // queued when the app stopped the transport, and + // adding the endpoint then reported a connection on + // a transport that had been stopped and never + // restarted. + if (generation != transportGeneration) { + return; + } connected.add(endpointId); NearbyTransport.deliverConnectionResult(e.encode(), true, 0, null); @@ -524,6 +571,18 @@ public void run() { @Override public void acceptConnection(final int requestId, String endpointId) { + // POINT_TO_POINT bounds the advertiser too -- one connection on each + // side. STAR does not: accepting many is what makes this device the + // centre of the star. + if (advertiseStrategy == TransportStrategy.POINT_TO_POINT.ordinal() + && !connected.isEmpty() + && !connected.contains(endpointId)) { + rejectConnection(endpointId); + answer(new TransportFailure(requestId, NearbyError.BUSY, + "POINT_TO_POINT allows one connection at a time;" + + " disconnect the current peer first")); + return; + } if (!connected.contains(endpointId)) { connected.add(endpointId); } @@ -533,6 +592,18 @@ public void acceptConnection(final int requestId, String endpointId) { @Override public void rejectConnection(String endpointId) { connected.remove(endpointId); + if (endpointId != null && !rejected.contains(endpointId)) { + rejected.add(endpointId); + } + } + + /// The endpoints whose connection requests were turned down, newest last. + /// + /// #### Returns + /// + /// the rejected endpoint ids, never null + public List getRejectedEndpoints() { + return new ArrayList(rejected); } @Override @@ -580,6 +651,7 @@ public void disconnect(String endpointId) { public void stopAllTransport() { advertising = false; discovering = false; + transportGeneration++; List doomed = new ArrayList(connected); connected.clear(); for (String id : doomed) { @@ -590,6 +662,23 @@ public void stopAllTransport() { } } + /// Parks every delayed delivery in `sink` instead of running it, so a + /// test can decide when each one lands. + /// + /// Without a Display there is no timer, so deliveries otherwise run + /// inline and no test can put anything BETWEEN the two hops of a + /// simulated connection -- which is exactly where the interesting races + /// are. + /// + /// @hidden not part of the public API; test-only. + /// + /// #### Parameters + /// + /// - `sink`: where to park deliveries, or null to run them as usual + public void deferForTest(List sink) { + deferred = sink; + } + /// Whether [#startAdvertising] is in effect, for the simulator panel. public boolean isAdvertising() { return advertising; @@ -672,6 +761,15 @@ private void answer(Runnable delivery) { } private void later(int millis, Runnable delivery) { + List sink = deferred; + if (sink != null) { + // A test is driving the clock. Held until it says otherwise, so + // the delayed ordering the simulation exists to reproduce can be + // reproduced in a unit test too -- without a Display there is no + // timer, and everything below runs inline. + sink.add(delivery); + return; + } if (Display.isInitialized()) { Display.getInstance().setTimeout(millis, delivery); return; diff --git a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java index db847412274..5f34970fb2a 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -309,9 +309,17 @@ public static void deliverAccessoryConfiguration(int requestId, EdtResult r = PENDING_ACCESSORY.take(requestId); if (r != null) { RangingSession s = RangingSession.lookup(sessionHandle); - if (s != null) { - s.markRunning(); + if (s == null) { + // Mirrors deliverSessionStarted. A stop() that lands while the + // start is in flight deregisters the session, and completing + // anyway handed the caller handshake bytes for a session that + // is not running -- bytes it would then send to an accessory + // that has nothing to talk to. + r.error(new NearbyException(NearbyError.SESSION_INVALIDATED, + "the session was closed before it started")); + return; } + s.markRunning(); r.complete(shareableConfiguration == null ? new byte[0] : shareableConfiguration); } diff --git a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java index f260d1fb412..551a69fd088 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -507,10 +507,19 @@ public void run() { for (TransportListener l : ls) { l.connectionRequested(r); } - if (!r.isAnswered()) { + if (ls.length == 0) { // Nobody was listening, so nobody will ever answer. The // far side would sit in its connecting state until it // timed out; reject instead so it learns immediately. + // + // Only when there were NO listeners. A listener that + // returns without answering is the documented flow, not a + // mistake: showing the authentication token and asking the + // user whether it matches cannot finish inside this + // callback. Rejecting on "not answered yet" made the + // later accept() a no-op and left the verified handshake + // -- the one thing that makes the pairing trustworthy -- + // unable to connect at all. r.reject(); } } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index afc55b719a4..6a27a276836 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -406,6 +406,12 @@ public void onEndpointLost(String endpointId) { NearbyTransport.deliverEndpointFound( encode(endpointId, nameOf(endpointId)), false); endpointNames.remove(endpointId); + // The service mapping goes with it. Nearby reuses endpoint + // ids, so a peer lost under the discovery service could come + // back by CONNECTING to this device's advertisement for a + // different service -- and onConnectionInitiated leaves an + // existing entry alone, so it kept reporting the old one. + endpointServices.remove(endpointId); } }; } @@ -443,6 +449,8 @@ public void onDisconnected(String endpointId) { NearbyTransport.deliverDisconnected( encode(endpointId, nameOf(endpointId))); endpointNames.remove(endpointId); + // Cleared with the name, for the reason onEndpointLost does. + endpointServices.remove(endpointId); } }; } @@ -615,11 +623,31 @@ private String encode(String endpointId, String name) { /// is what a file from an older build would look like -- wrong, but no /// worse than it was before, and better than zero. private static int senderIdOf(Payload file) { + // Read from the received file's NAME, both ways it can be reached. + // + // It was suggested this should call Payload.getFileName() instead. + // There is no such method: play-services-nearby 19.3.0 has + // Payload.setFileName(String) and no getter for it, and neither + // Payload nor Payload.File exposes the transmitted name under any + // other name -- javap over the whole + // com.google.android.gms.nearby.connection package finds no + // getFileName at all. setFileName is what makes the RECEIVED file + // carry the sender's name, so asJavaFile().getName() is where that + // name arrives. + // + // asUri() is the second route and not a redundant one: under scoped + // storage asJavaFile() returns null and the Uri is all there is. String name = null; try { - if (file.asFile() != null) { - name = file.asFile().asJavaFile() == null ? null - : file.asFile().asJavaFile().getName(); + Payload.File f = file.asFile(); + if (f != null) { + java.io.File local = f.asJavaFile(); + if (local != null) { + name = local.getName(); + } + if (name == null && f.asUri() != null) { + name = f.asUri().getLastPathSegment(); + } } } catch (Throwable t) { name = null; diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index bf691abbe22..e9fe2a14b5b 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -366,6 +366,14 @@ @interface CN1NearbyTransport : NSObject 0) { + return [@"ssid:" stringByAppendingString:accessory.SSID]; + } + return [@"name:" stringByAppendingString: + accessory.displayName == nil ? @"" : accessory.displayName]; +} + - (NSString *)encode:(ASAccessory *)accessory present:(BOOL)present { NSString *identifier = accessory.bluetoothIdentifier != nil ? [accessory.bluetoothIdentifier UUIDString] : @""; return cn1nbJoin([NSArray arrayWithObjects: - identifier.length > 0 ? identifier - : [NSString stringWithFormat:@"%lu", - (unsigned long)[accessory hash]], + cn1nbAccessoryId(accessory), accessory.displayName == nil ? @"" : accessory.displayName, identifier, @"0", @@ -1046,9 +1082,7 @@ - (ASAccessory *)accessoryForId:(NSString *)associationId { return nil; } for (ASAccessory *a in self.session.accessories) { - if (a.bluetoothIdentifier != nil - && [[a.bluetoothIdentifier UUIDString] - isEqualToString:associationId]) { + if ([cn1nbAccessoryId(a) isEqualToString:associationId]) { return a; } } @@ -1638,6 +1672,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str t.advertiser.delegate = t; // Recorded BEFORE the answer: didNotStartAdvertisingPeer can fire // after this returns, and it needs the id to fail. + t.advertiseStrategy = (int)strategy; t.pendingAdvertiseRequest = requestId; [t.advertiser startAdvertisingPeer]; cn1nbSettleTransportStart(t, YES, requestId); @@ -1691,6 +1726,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_Strin initWithPeer:t.localPeer serviceType:t.discoverServiceType] autorelease]; t.browser.delegate = t; + t.discoverStrategy = (int)strategy; t.pendingDiscoverRequest = requestId; [t.browser startBrowsingForPeers]; cn1nbSettleTransportStart(t, NO, requestId); @@ -1737,6 +1773,19 @@ void com_codename1_impl_ios_IOSNative_nearbyRequestConnection___int_java_lang_St @"no such endpoint"); return; } + // This device is the one CONNECTING, so both STAR and POINT_TO_POINT + // allow it exactly one peer: under STAR it is the many, not the one. + if (cn1nbTransport.discoverStrategy != CN1_NEARBY_STRATEGY_CLUSTER + && [cn1nbTransport connectedPeerCount] > 0) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_BUSY, + cn1nbTransport.discoverStrategy + == CN1_NEARBY_STRATEGY_POINT_TO_POINT + ? @"POINT_TO_POINT allows one connection at a time;" + @" disconnect the current peer first" + : @"a STAR discoverer holds one connection at a time;" + @" disconnect the current peer first"); + return; + } // The name the caller wants the invited peer to see. Applied before // the invitation goes out, or it would carry the previous identity. cn1nbApplyLocalName(cn1nbTransport, @@ -1778,6 +1827,19 @@ void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_Str @"there is no invitation from that endpoint"); return; } + // POINT_TO_POINT means one connection on EACH side, so the + // advertiser is bounded too. STAR is not: accepting many is what + // makes this device the star's centre. + if (cn1nbTransport.advertiseStrategy + == CN1_NEARBY_STRATEGY_POINT_TO_POINT + && [cn1nbTransport connectedPeerCount] > 0) { + [cn1nbTransport.invitations removeObjectForKey:pid]; + handler(NO, nil); + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_BUSY, + @"POINT_TO_POINT allows one connection at a time;" + @" disconnect the current peer first"); + return; + } [cn1nbTransport.invitations removeObjectForKey:pid]; handler(YES, [cn1nbTransport sessionFor:pid]); cn1nbTransportOk(requestId); diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index 6ea1e3f5925..08e0f9b8161 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -192,8 +192,18 @@ relay can reproduce at both ends -- a check that looks like a defense and isn't. An iOS app that needs to know who it's talking to has to establish that itself, over a channel the relay doesn't control. -Answer every `connectionRequested` without delay. A request that's never answered -holds radio resources open on both sides until the far end times out. +Answer every `connectionRequested`. You don't have to answer inside the callback +-- showing the token and waiting for the user is the whole point, and the +request stays live until you call `accept()` or `reject()` -- but a request +that's never answered at all holds radio resources open on both sides until the +far end times out. If nothing is listening when a request arrives, it's +rejected for you, so the far end learns immediately instead of waiting. + +The strategy you pass to `startAdvertising` and `startDiscovery` is a limit, not +a hint. Under `POINT_TO_POINT` a second connection is refused with +`NearbyError.BUSY` on either side, and under `STAR` the discovering side holds +one connection while the advertising side accepts many. `CLUSTER` is the only +one with no limit. Disconnect before connecting elsewhere. *On iOS, list your service ids at build time.* The service id becomes a Bonjour service type there, and iOS browses only the types an app declared in its 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 8b5b356a40b..26338eac8fb 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 @@ -2644,6 +2644,30 @@ public void usesClassMethod(String cls, String method) { // usesHealthStore, NOT usesHealth: com.codename1.health.sensors is // pure BLE and must not drag in Health Connect or a Google Play // health-permissions review. + if (usesNearbyRanging) { + // androidx.core.uwb's AAR declares minAgpVersion=8.9.1 as well as + // minCompileSdk=36, and Gradle's dependency check rejects the + // project rather than building it -- with a message about AAR + // metadata that names neither UWB nor this hint. Raising the + // compile SDK alone is not enough, so a build that has explicitly + // selected an older toolchain is refused here, where the reason + // can still be explained. + // + // The version that will actually run, not the flag that usually + // selects it: android.gradleVersion overrides the choice, which is + // the same trap the Health Connect gate below documents. + if (!useGradle8 || gradleVersionInt < 8) { + throw new BuildException( + "com.codename1.nearby.ranging needs androidx.core.uwb," + + " whose Android Gradle plugin floor is 8.9.1, but" + + " this build would use Gradle " + gradleVersion + + " (android.useGradle8=" + useGradle8 + ") and an" + + " older plugin with it. Set android.useGradle8=true" + + " and leave android.gradleVersion unset to build a" + + " ranging app."); + } + } + if (usesHealthStore) { String readHint = request.getArg("android.health.read", ""); String writeHint = request.getArg("android.health.write", ""); @@ -6570,6 +6594,17 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { compileSdkVersion = ensureCompileSdkAtLeastTarget( compileSdkVersion, "36"); } + if (usesNearbyTransport) { + // android:usesPermissionFlags is an API 31 manifest attribute, + // and the transport's permissions carry it whatever the app + // targets -- they have to, because a permission is requested + // according to the level the DEVICE runs. AAPT rejects an + // attribute the compile SDK has never heard of, so a legacy + // toolchain (build tools 30, android.useGradle8=false) failed on + // the manifest before it ever reached javac. + compileSdkVersion = ensureCompileSdkAtLeastTarget( + compileSdkVersion, "31"); + } jcenter = " google()\n" + " jcenter()\n" + 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 d56d83e213e..52f3ad0b417 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 @@ -355,9 +355,22 @@ static String foldBonjourServiceType(String serviceId) { /// /// @param request the build request /// @param serviceTypes the folded service types this app advertises + /// @param usesBonjour whether the app also uses com.codename1.io.bonjour private void mergeNearbyBonjourServices(BuildRequest request, - List serviceTypes) throws BuildException { + List serviceTypes, boolean usesBonjour) + throws BuildException { + // The com.codename1.io.bonjour block further down seeds _http._tcp. + // only when the hint is still unset, which is its way of leaving a + // project that named its own types alone. This merge runs FIRST and + // creates the hint, so it would have taken that default away from an + // app that uses both APIs and set nothing -- leaving the ordinary + // Bonjour API unable to discover anything on iOS 14 and later. + boolean seedHttp = usesBonjour + && request.getArg("ios.NSBonjourServices", null) == null; List needed = new ArrayList(); + if (seedHttp) { + needed.add("_http._tcp."); + } for (int i = 0; i < serviceTypes.size(); i++) { // MultipeerConnectivity uses both transports for one service. needed.add("_" + serviceTypes.get(i) + "._tcp."); @@ -4538,7 +4551,8 @@ public void usesClassMethod(String cls, String method) { + " comma-separated list of the service" + " ids this app passes to" + " startAdvertising)")); - mergeNearbyBonjourServices(request, serviceTypes); + mergeNearbyBonjourServices(request, serviceTypes, + usesBonjour); } if (usesNearbyCompanion) { enableNearbyDefine(buildinRes, "CN1_NEARBY_COMPANION"); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java index c2e14867046..a3c4b3fb62a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java @@ -30,7 +30,9 @@ import java.util.Arrays; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -62,13 +64,20 @@ private static BuildRequest request(String... kv) { /** Drives the private merge and hands back the resulting hint. */ private static String merge(BuildRequest request, String... serviceTypes) throws Exception { + return merge(request, false, serviceTypes); + } + + private static String merge(BuildRequest request, boolean usesBonjour, + String... serviceTypes) throws Exception { IPhoneBuilder b = new IPhoneBuilder(); Method m = IPhoneBuilder.class.getDeclaredMethod( - "mergeNearbyBonjourServices", BuildRequest.class, List.class); + "mergeNearbyBonjourServices", BuildRequest.class, List.class, + boolean.class); m.setAccessible(true); try { m.invoke(b, request, new ArrayList( - Arrays.asList(serviceTypes))); + Arrays.asList(serviceTypes)), + Boolean.valueOf(usesBonjour)); } catch (InvocationTargetException e) { if (e.getCause() instanceof Exception) { throw (Exception) e.getCause(); @@ -144,6 +153,51 @@ void aProjectThatOwnsTheKeyAndListedTheTypesIsLeftAlone() throws Exception { merge(r, "chat"); } + @Test + void anAppThatAlsoUsesBonjourKeepsItsHttpDefault() { + // The bonjour block seeds _http._tcp. only when the hint is unset, + // and this merge creates the hint first -- so without seeding it here + // an app using both APIs silently lost the default it would have had. + BuildRequest r = request(); + String hint = assertDoesNotThrow(new org.junit.jupiter.api.function + .ThrowingSupplier() { + @Override + public String get() throws Throwable { + return merge(r, true, "chat"); + } + }); + assertTrue(hint.contains("_http._tcp."), hint); + assertTrue(hint.contains("_chat._tcp."), hint); + } + + @Test + void anAppThatNamedItsOwnTypesIsNotGivenTheHttpDefault() { + // Same as today: a project that set the hint owns it. + BuildRequest r = request("ios.NSBonjourServices", "_myapp._tcp."); + String hint = assertDoesNotThrow(new org.junit.jupiter.api.function + .ThrowingSupplier() { + @Override + public String get() throws Throwable { + return merge(r, true, "chat"); + } + }); + assertFalse(hint.contains("_http._tcp."), hint); + assertTrue(hint.contains("_myapp._tcp."), hint); + } + + @Test + void anAppThatDoesNotUseBonjourGetsOnlyItsNearbyTypes() { + BuildRequest r = request(); + String hint = assertDoesNotThrow(new org.junit.jupiter.api.function + .ThrowingSupplier() { + @Override + public String get() throws Throwable { + return merge(r, false, "chat"); + } + }); + assertFalse(hint.contains("_http._tcp."), hint); + } + private static int countOccurrences(String haystack, String needle) { int n = 0; int at = haystack.indexOf(needle); diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index d95367af266..757865d5044 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -42,6 +42,7 @@ import com.codename1.nearby.ranging.RangingUnit; import com.codename1.nearby.ranging.RangingUpdate; import com.codename1.nearby.transport.Endpoint; +import com.codename1.nearby.transport.IncomingConnection; import com.codename1.nearby.transport.NearbyTransport; import com.codename1.nearby.transport.Payload; import com.codename1.nearby.transport.PayloadStatus; @@ -553,6 +554,149 @@ void observingSomethingThatIsNotAssociatedIsRefused() { // transport // ------------------------------------------------------------------ + @Test + void stoppingBeforeTheAcceptanceLandsLeavesTheTransportStopped() { + // Nothing in the simulation completes inline, which is the point -- + // and it means the delayed acceptance really can outlive the stop() + // that was supposed to have ended the transport. Adding the endpoint + // then reported a connection on a transport nobody had restarted. + final List connected = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void connected(Endpoint e) { + connected.add(e); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + assertNotNull(e); + + // From here the test drives the clock, so a stop() can land between + // the request and the acceptance the way it does on a real timer. + List queue = new ArrayList(); + bridge.deferForTest(queue); + NearbyTransport.requestConnection(e, "me"); + NearbyTransport.stop(); + drain(queue); + + assertTrue(connected.isEmpty(), + "a stopped transport must not connect: " + connected); + } + + @Test + void anAcceptanceThatBeatsTheStopStillConnects() { + // The other side of the same guard: a connection that completed + // before the stop is a real connection, not one to suppress. + final List connected = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void connected(Endpoint e) { + connected.add(e); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + + List queue = new ArrayList(); + bridge.deferForTest(queue); + NearbyTransport.requestConnection(e, "me"); + drain(queue); + assertEquals(1, connected.size()); + } + + /// Runs every parked delivery, including any the deliveries themselves + /// park, until nothing is left. + private static void drain(List queue) { + while (!queue.isEmpty()) { + Runnable next = queue.remove(0); + next.run(); + } + } + + @Test + void pointToPointRefusesASecondConnectionInsteadOfAllowingIt() { + // TransportStrategy documents "exactly one connection on each side", + // and a simulator that let an app hold three would teach it a + // topology no device will honour. + List found = discoverAll(TransportStrategy.POINT_TO_POINT); + assertTrue(found.size() >= 2, "need two synthetic peers to test this"); + assertTrue(value(NearbyTransport.requestConnection(found.get(0), "me")) + .booleanValue()); + assertFailedWith(NearbyError.BUSY, + NearbyTransport.requestConnection(found.get(1), "me")); + } + + @Test + void clusterAllowsTheSecondConnectionPointToPointRefuses() { + List found = discoverAll(TransportStrategy.CLUSTER); + assertTrue(value(NearbyTransport.requestConnection(found.get(0), "me")) + .booleanValue()); + assertTrue(value(NearbyTransport.requestConnection(found.get(1), "me")) + .booleanValue()); + } + + /// Starts discovery with a strategy and hands back every endpoint it saw. + private List discoverAll(TransportStrategy strategy) { + final List found = new ArrayList(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.add(e); + } + }); + value(NearbyTransport.startDiscovery("chat", strategy)); + return found; + } + + @Test + void aListenerMayAnswerAConnectionRequestAfterItReturns() { + // The documented flow: show getAuthenticationToken() on both screens, + // ask the user whether the two match, and accept when they say yes. + // That cannot finish inside the callback, and rejecting a request the + // listener had not answered YET made the later accept() a no-op -- + // so the one handshake worth trusting could never connect. + final AtomicReference held = + new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void connectionRequested(IncomingConnection request) { + held.set(request); + } + }); + NearbyTransport.deliverConnectionRequested( + "peer-1\tA Phone\tchat", "1234"); + + IncomingConnection r = held.get(); + assertNotNull(r); + assertFalse(r.isAnswered(), + "a listener that has not answered must not be answered for it"); + r.accept(); + assertTrue(r.isAnswered()); + } + + @Test + void aConnectionRequestNobodyHeardIsRejectedRatherThanLeftHanging() { + // With no listener at all nobody will ever answer, and the far side + // would sit in its connecting state until it timed out. + NearbyTransport.deliverConnectionRequested( + "peer-2\tAnother Phone\tchat", "5678"); + assertTrue(bridge.getRejectedEndpoints().contains("peer-2"), + "expected an immediate reject, got " + + bridge.getRejectedEndpoints()); + } + @Test void discoveryFindsTheSyntheticEndpoints() { final List found = new ArrayList(); From 9fdefd764da88c5faa81163d7c4e50d23846f73c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:50:51 +0300 Subject: [PATCH 23/94] Say why the ranging AGP gate differs from the daemon's The two builders select the Android Gradle plugin differently -- this one by Gradle major, the daemon by exact Gradle version -- so the same guard needs a different condition in each. Without this it reads as a mirroring mistake. --- .../java/com/codename1/builders/AndroidGradleBuilder.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 26338eac8fb..e1663da3c48 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 @@ -2656,6 +2656,14 @@ public void usesClassMethod(String cls, String method) { // The version that will actually run, not the flag that usually // selects it: android.gradleVersion overrides the choice, which is // the same trap the Health Connect gate below documents. + // + // The Gradle MAJOR is the right test here, and only here: the + // dependency branch in this builder gives every Gradle 8 build + // ANDROID_GRADLE_PLUGIN_8_VERSION, which is well past the floor, + // so the major really does decide the plugin. The BuildDaemon + // copy selects the plugin by exact Gradle version and has to + // test for the modern pairing instead; the conditions differ + // because the selections do. if (!useGradle8 || gradleVersionInt < 8) { throw new BuildException( "com.codename1.nearby.ranging needs androidx.core.uwb," From f75537924b5d3d20f506e76e71cb4a9d0a6f581c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:13:03 +0300 Subject: [PATCH 24/94] Address the twelfth nearby review round - Raise the Android compile SDK to at least 33 for ANY nearby cluster, replacing the transport-only floor of 31. The deletion pass removes only the two files that carry an optional gradle dependency, so AndroidNearbyBackend and CN1CompanionDeviceService survive every nearby build -- and both compile against android.companion.AssociationInfo, which is API 33. A transport-only or ranging-only app built against 32 failed javac on a class it never asked for. The new floor subsumes the old one, which existed for the API 31 usesPermissionFlags attribute. - An acceptance the platform refuses now reaches connectionFailed. accept() returns void and its outcome is documented to arrive as connected or connectionFailed, so there is no AsyncResource for a port to fail -- and the request id it reported had no pending entry, so Android's acceptConnection failure listener wrote into the void and the app waited forever. NearbyTransport tracks the id against its endpoint and reports the refusal to the listeners. The map is bounded at 64, oldest first: an acceptance old enough to be evicted has already lost its race with the platform's own timeout. - iOS emits the terminal SUCCESS progress update for a received byte payload, matching the file path and Android. Without it a receiver that releases per-payload state on the documented terminal status waited forever on the common case. Testing the acceptance failure needs the ordering a real port produces -- accept returns, the platform refuses later -- which the simulated bridge cannot show while it runs deliveries inline. It uses the deferForTest clock seam and a getLastAcceptRequestId accessor, both in the style of the resetForTest and getRejectedEndpoints already there. --- .../impl/nearby/LocalNearbyBridge.java | 17 +++++ .../nearby/transport/IncomingConnection.java | 6 +- .../nearby/transport/NearbyTransport.java | 68 ++++++++++++++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 9 +++ .../builders/AndroidGradleBuilder.java | 25 ++++--- .../com/codename1/nearby/LocalNearbyTest.java | 38 +++++++++++ 6 files changed, 152 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index eb7004b076d..ef8ad5be21f 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -116,6 +116,11 @@ public class LocalNearbyBridge implements NearbyBridge { /// is the point -- and that means a delayed acceptance really can outlive /// the stop() that was supposed to have ended it. private int transportGeneration; + /// The id of the most recent acceptConnection, so a test can answer it + /// the way a port would. + /// + /// @hidden not part of the public API; test-only. + private int lastAcceptRequestId; /// Where delayed deliveries go while a test drives the clock, or null in /// normal operation. /// @@ -571,6 +576,7 @@ public void run() { @Override public void acceptConnection(final int requestId, String endpointId) { + lastAcceptRequestId = requestId; // POINT_TO_POINT bounds the advertiser too -- one connection on each // side. STAR does not: accepting many is what makes this device the // centre of the star. @@ -679,6 +685,17 @@ public void deferForTest(List sink) { deferred = sink; } + /// The request id of the most recent [#acceptConnection]. + /// + /// @hidden not part of the public API; test-only. + /// + /// #### Returns + /// + /// the id, or 0 when nothing has been accepted + public int getLastAcceptRequestId() { + return lastAcceptRequestId; + } + /// Whether [#startAdvertising] is in effect, for the simulator panel. public boolean isAdvertising() { return advertising; diff --git a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java index aa5ffbd28c7..eebe43093b1 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java +++ b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java @@ -97,7 +97,11 @@ public void accept() { answered = true; NearbyBridge b = NearbyRequests.bridge(); if (b != null) { - b.acceptConnection(NearbyRequests.nextId(), endpoint.getId()); + // Recorded before the call, so a port that refuses the acceptance + // synchronously still finds the endpoint to report it against. + int requestId = NearbyRequests.nextId(); + NearbyTransport.trackAcceptance(requestId, endpoint); + b.acceptConnection(requestId, endpoint.getId()); } } diff --git a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java index 551a69fd088..e67f97cadaf 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -34,7 +34,9 @@ import com.codename1.util.AsyncResource; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /// Moving bytes and files to a device that is physically nearby, with no /// access point, no pairing and no internet. @@ -87,6 +89,25 @@ /// Every callback here is delivered on the EDT. public final class NearbyTransport { + /// Request id to the endpoint whose incoming connection is being + /// accepted. + /// + /// accept() answers the platform rather than the caller -- it returns + /// void, and the outcome is documented to arrive as connected or + /// connectionFailed -- so there is no AsyncResource for a port to fail. + /// Without this the port's failure was dropped on the floor: the id it + /// reported had no entry in PENDING, and an acceptance the platform + /// refused (the endpoint went away first, most often) produced no + /// callback of any kind and left the app waiting. + private static final Map ACCEPTING = + new LinkedHashMap(); + /// Bounds ACCEPTING. Every entry is normally removed by the port's + /// answer, but a port that answers neither way would otherwise leave one + /// behind per acceptance for the life of the process. The oldest goes + /// first: an acceptance old enough to be evicted has already lost its + /// race with the platform's own timeout. + private static final int MAX_ACCEPTING = 64; + private static final PendingMap PENDING = new PendingMap(); private static final List LISTENERS = @@ -400,6 +421,9 @@ public static void removeTransportListener(TransportListener l) { /// /// @hidden not part of the public API; test-only. public static void resetForTest() { + synchronized (ACCEPTING) { + ACCEPTING.clear(); + } NearbyException reset = new NearbyException(NearbyError.UNKNOWN, "the nearby framework was reset"); PENDING.failAll(reset); @@ -421,12 +445,38 @@ public static void resetForTest() { /// /// - `requestId`: the id the request was made with public static void deliverRequestOk(int requestId) { + // An accepted connection is not an outcome, only the platform taking + // the answer; the outcome still arrives through the lifecycle + // callback. Dropped here so the entry cannot outlive the request. + takeAcceptance(requestId); EdtResult r = PENDING.take(requestId); if (r != null) { r.complete(Boolean.TRUE); } } + /// Records that `requestId` belongs to an acceptance of `endpoint`. + /// + /// @hidden not part of the public API. + static void trackAcceptance(int requestId, Endpoint endpoint) { + if (endpoint == null) { + return; + } + synchronized (ACCEPTING) { + while (ACCEPTING.size() >= MAX_ACCEPTING) { + ACCEPTING.remove(ACCEPTING.keySet().iterator().next()); + } + ACCEPTING.put(Integer.valueOf(requestId), endpoint); + } + } + + /// The endpoint an acceptance request belongs to, removing it. + private static Endpoint takeAcceptance(int requestId) { + synchronized (ACCEPTING) { + return ACCEPTING.remove(Integer.valueOf(requestId)); + } + } + /// Fails whichever transport request carries this id. /// /// @hidden not part of the public API; called by ports. @@ -439,7 +489,23 @@ public static void deliverRequestOk(int requestId) { /// - `message`: a human-readable detail, may be null public static void deliverRequestFailed(int requestId, int errorOrdinal, String message) { - NearbyException ex = NearbyWire.decodeError(errorOrdinal, message); + final NearbyException ex = + NearbyWire.decodeError(errorOrdinal, message); + final Endpoint accepting = takeAcceptance(requestId); + if (accepting != null) { + // Reported the way the accept() javadoc promises the outcome + // arrives, because there is no resource to fail. + NearbyRequests.onEdt(new Runnable() { + @Override + public void run() { + TransportListener[] ls = snapshot(); + for (TransportListener l : ls) { + l.connectionFailed(accepting, ex); + } + } + }); + return; + } EdtResult r = PENDING.take(requestId); if (r != null) { r.error(ex); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index e9fe2a14b5b..751bd10c492 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -672,6 +672,15 @@ - (void)session:(MCSession *)session didReceiveData:(NSData *)data | b[3]); body = [data subdataWithRange:NSMakeRange(4, [data length] - 4)]; } + // The terminal SUCCESS update, for the reason the file path emits + // one: a receiver that releases per-payload state or dismisses its + // transfer UI on the documented terminal status waited forever on + // every byte payload, which is the common case and the one Android + // has always reported. + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), payloadId, + (JAVA_LONG)[body length], (JAVA_LONG)[body length], + CN1_NEARBY_PAYLOAD_SUCCESS); com_codename1_impl_ios_IOSNearbyCallbacks_payloadReceived___java_lang_String_int_int_byte_1ARRAY_java_lang_String( getThreadLocalData(), cn1nbJString(encoded), payloadId, CN1_NEARBY_PAYLOAD_BYTES, cn1nbJBytes(body), JAVA_NULL); 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 e1663da3c48..93d1989a75f 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 @@ -6602,16 +6602,23 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { compileSdkVersion = ensureCompileSdkAtLeastTarget( compileSdkVersion, "36"); } - if (usesNearbyTransport) { - // android:usesPermissionFlags is an API 31 manifest attribute, - // and the transport's permissions carry it whatever the app - // targets -- they have to, because a permission is requested - // according to the level the DEVICE runs. AAPT rejects an - // attribute the compile SDK has never heard of, so a legacy - // toolchain (build tools 30, android.useGradle8=false) failed on - // the manifest before it ever reached javac. + if (usesNearbyRanging || usesNearbyTransport || usesNearbyCompanion) { + // 33, and for ANY of the three clusters, not just the one whose + // own API level says 33. + // + // AndroidNearbyBackend and CN1CompanionDeviceService survive for + // every nearby build -- the deletion pass above removes only the + // two files that carry an optional gradle dependency -- and both + // compile against android.companion.AssociationInfo, which is API + // 33. So a transport-only or ranging-only app built against 32 + // failed javac on a class it never asked for. + // + // This also covers android:usesPermissionFlags, an API 31 + // manifest attribute the transport's permissions carry whatever + // the app targets; AAPT rejects an attribute the compile SDK has + // never heard of, which failed the build even earlier. compileSdkVersion = ensureCompileSdkAtLeastTarget( - compileSdkVersion, "31"); + compileSdkVersion, "33"); } jcenter = " google()\n" + diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 757865d5044..01573795710 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -686,6 +686,44 @@ public void connectionRequested(IncomingConnection request) { assertTrue(r.isAnswered()); } + @Test + void anAcceptanceThePlatformRefusesIsReportedAsAConnectionFailure() { + // accept() returns void and its outcome is documented to arrive as + // connected or connectionFailed, so there is no AsyncResource for a + // port to fail. The port's failure used to be dropped: the id it + // reported had no pending entry, so an acceptance the platform + // refused produced no callback at all and the app waited forever. + final AtomicReference held = + new AtomicReference(); + final List failures = new ArrayList(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void connectionRequested(IncomingConnection request) { + held.set(request); + } + + @Override + public void connectionFailed(Endpoint e, NearbyException error) { + failures.add(error); + } + }); + NearbyTransport.deliverConnectionRequested( + "peer-9\tA Phone\tchat", "4321"); + // The clock is held so the bridge's own success cannot settle the + // request before the refusal below, which is the ordering a real + // port produces: acceptConnection returns and fails later. + List queue = new ArrayList(); + bridge.deferForTest(queue); + held.get().accept(); + + // The port refuses it after the fact, naming the request id it was + // handed -- which is the id accept() recorded. + NearbyTransport.deliverRequestFailed(bridge.getLastAcceptRequestId(), + NearbyError.PEER_UNAVAILABLE.ordinal(), "it went away"); + assertEquals(1, failures.size()); + assertSame(NearbyError.PEER_UNAVAILABLE, failures.get(0).getError()); + } + @Test void aConnectionRequestNobodyHeardIsRejectedRatherThanLeftHanging() { // With no listener at all nobody will ever answer, and the far side From 00ef87bb3c45eaab633fcaf8cbde280c9017a60b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:25:28 +0300 Subject: [PATCH 25/94] Address the thirteenth nearby review round - A folded Bonjour service type always contains an ASCII letter. Apple requires one, and ios.nearby.serviceType=123 folded to "123", which reads as legal -- so MCNearbyServiceAdvertiser RAISES rather than failing, and the app crashed instead of failing to advertise. An all-digit result is prefixed to cn1-123, which keeps the id recognisable and stays inside the fifteen-character limit. Both folds changed together, because the whole point of them being identical is that the type an app registers is the one its Info.plist declares -- NearbyBonjourServiceTypeTest compares the two. - ACCESS_COARSE_LOCATION is declared alongside ACCESS_FINE_LOCATION with the same cap, and requested with it. From Android 12 the two are granted together -- one dialog offering precise or approximate -- and a request for fine alone is refused when coarse is not declared, so a transport app on 12 or 12L could never obtain the location grant Nearby Connections needs there and discovery never started. Changed in NearbyPermissions as well as the manifest: those two disagreeing about what the transport needs is exactly what that helper exists to prevent. --- .../android/nearby/NearbyPermissions.java | 6 +++ Ports/iOSPort/nativeSources/CN1Nearby.m | 23 +++++++++++ .../com/codename1/builders/IPhoneBuilder.java | 23 +++++++++++ .../builders/NearbyManifestFragments.java | 9 +++++ .../NearbyBonjourServiceTypeTest.java | 38 +++++++++++++++++++ .../builders/NearbyManifestFragmentsTest.java | 24 ++++++++++++ 6 files changed, 123 insertions(+) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java index 91536535cf7..151bc842d74 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java @@ -101,7 +101,13 @@ static List transportPermissions(Context context, } else { // Below 33 Nearby Connections genuinely refuses to start without // a location grant; it is not a scan-results technicality there. + // + // Both, and in this order. From Android 12 the two are granted + // together -- the system shows one dialog offering precise or + // approximate -- and asking for fine without coarse is refused + // outright, so the grant the transport needs never arrived. out.add("android.permission.ACCESS_FINE_LOCATION"); + out.add("android.permission.ACCESS_COARSE_LOCATION"); } return out; } diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 751bd10c492..aed81a0ab46 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -451,6 +451,29 @@ @interface CN1NearbyTransport : NSObject = 'a' && c <= 'z') { + hasLetter = YES; + break; + } + } + if ([out length] > 0 && !hasLetter) { + [out insertString:@"cn1-" atIndex:0]; + if ([out length] > 15) { + [out deleteCharactersInRange:NSMakeRange(15, [out length] - 15)]; + } + while ([out hasSuffix:@"-"]) { + [out deleteCharactersInRange:NSMakeRange([out length] - 1, 1)]; + } + } return [out length] == 0 ? @"cn1-nearby" : out; } 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 52f3ad0b417..df228aa2c0d 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 @@ -320,6 +320,29 @@ static String foldBonjourServiceType(String serviceId) { while (out.length() > 0 && out.charAt(out.length() - 1) == '-') { out.setLength(out.length() - 1); } + // At least one ASCII LETTER, not merely one legal character. Apple + // requires it, and an all-digit id like "123" folded to "123" -- which + // reads as legal and makes MCNearbyServiceAdvertiser RAISE rather than + // fail, so the app crashed instead of failing to advertise. Prefixed + // rather than rejected: the id is still recognisable, and the runtime + // fold applies the same rule so the two agree. + boolean hasLetter = false; + for (int i = 0; i < out.length(); i++) { + char c = out.charAt(i); + if (c >= 'a' && c <= 'z') { + hasLetter = true; + break; + } + } + if (out.length() > 0 && !hasLetter) { + out.insert(0, "cn1-"); + if (out.length() > 15) { + out.setLength(15); + } + while (out.length() > 0 && out.charAt(out.length() - 1) == '-') { + out.setLength(out.length() - 1); + } + } return out.toString(); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 4ac367f9b63..95d2f966320 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -144,6 +144,15 @@ static String inject(String xPermissions, boolean ranging, // start without one. out = widenPermission(out, "android.permission.ACCESS_FINE_LOCATION", tiramisu ? 32 : 0); + // COARSE alongside FINE, with the same reach. From Android 12 the + // two are requested TOGETHER -- the system shows one dialog with a + // precise/approximate choice and refuses a request for fine alone + // when coarse is not declared -- so a transport app on 12 or 12L + // could not obtain the location grant Nearby Connections needs + // there, and discovery never started. + out = widenPermission(out, + "android.permission.ACCESS_COARSE_LOCATION", + tiramisu ? 32 : 0); } if (companion) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java index 768dbbbba18..1e9b03f582d 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java @@ -27,6 +27,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -204,4 +205,41 @@ void everyDeclaredTypeIsLegalWhateverTheHintSays() { } } } + + @Test + void anAllDigitIdIsGivenALetterRatherThanLeftIllegal() { + // Apple requires at least one ASCII LETTER, not merely one legal + // character. "123" folded to "123", which reads as legal and makes + // MCNearbyServiceAdvertiser raise rather than fail -- so the app + // crashed instead of failing to advertise. + String folded = IPhoneBuilder.foldBonjourServiceType("123"); + assertTrue(hasLetter(folded), folded); + assertTrue(folded.contains("123"), folded); + assertTrue(folded.length() <= 15, folded); + } + + @Test + void aDigitsAndPunctuationIdAlsoGetsALetter() { + String folded = IPhoneBuilder.foldBonjourServiceType("12.34.56"); + assertTrue(hasLetter(folded), folded); + assertTrue(folded.length() <= 15, folded); + assertFalse(folded.startsWith("-"), folded); + assertFalse(folded.endsWith("-"), folded); + } + + @Test + void anIdThatAlreadyHasALetterIsNotPrefixed() { + assertEquals("chat", IPhoneBuilder.foldBonjourServiceType("chat")); + assertEquals("a1", IPhoneBuilder.foldBonjourServiceType("a1")); + } + + private static boolean hasLetter(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 'a' && c <= 'z') { + return true; + } + } + return false; + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index 0762021d374..a98442388c1 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -318,6 +318,30 @@ void transportBelowTiramisuRemovesTheCapAltogether() { "the cap should be gone: " + element); } + @Test + void coarseLocationIsDeclaredAlongsideFineWithTheSameReach() { + // From Android 12 the two are requested together -- one dialog with a + // precise/approximate choice -- and a request for fine alone is + // refused when coarse is not declared, so the grant Nearby + // Connections needs on 12 and 12L never arrived. + String out = NearbyManifestFragments.inject("", false, true, false, + false, false, 34); + assertTrue(out.contains("android:name=\"android.permission" + + ".ACCESS_COARSE_LOCATION\" android:maxSdkVersion=\"32\""), + out); + assertTrue(out.contains("android:name=\"android.permission" + + ".ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\""), + out); + } + + @Test + void coarseLocationIsUncappedBelowATiramisuTarget() { + String out = NearbyManifestFragments.inject("", false, true, false, + false, false, 31); + assertTrue(out.contains("android:name=\"android.permission" + + ".ACCESS_COARSE_LOCATION\" />"), out); + } + @Test void aCapThatAlreadyReachesFarEnoughIsLeftAlone() { String seeded = " Date: Sun, 23 Aug 2026 20:38:33 +0300 Subject: [PATCH 26/94] Address the fourteenth nearby review round - Serialize the iOS transport's shared collections. MultipeerConnectivity delivers on a queue per session and the transport deliberately keeps one session per peer, so peersById, serviceIdByPeer, sessionsById, invitations, progressByPayload and everConnected were being mutated from several queues at once plus the native calls -- and none of them is thread-safe. Every access now goes through an accessor holding the transport's monitor, and the monitor is never held across a call into Java or across disconnect, which runs delegate work and would deadlock against the queue trying to take the same monitor. - A start that supersedes another inside the grace period answers the one it replaced. The deferred settler this branch added answers only a request that is still pending, so overwriting the id left the first caller's AsyncResource pending for good. Both the advertising and the discovery path settle the superseded request. - The simulator fails a connection request cancelled by stop() instead of returning silently. The generation check added last round stopped the stale reconnection and then left the caller's resource hanging, which is the failure EdtResult exists to prevent. - A cancelled file transfer reports CANCELED. PayloadStatus.CANCELED exists so an app can tell "I stopped this" from "the link broke", and mapping every completion error to FAILURE hid the one the app caused itself. - A partial multi-recipient byte send reports per recipient. Sending to three peers where the third fails still delivers to the first two, and failing the aggregate request then skipping the progress loop meant neither the recipients that got it nor the one that did not produced any terminal update. - A URI-backed Android file is copied into app storage and delivered with a usable path. Under scoped storage asJavaFile() is null, and the portable file payload carries a path and nothing else -- so the app was told the transfer succeeded and handed a payload its only accessor could not read. When the content cannot be reached at all it is reported as a failure instead, which is a state the API already documents. --- .../impl/nearby/LocalNearbyBridge.java | 8 + .../nearby/AndroidNearbyTransport.java | 105 +++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 248 ++++++++++++++---- .../com/codename1/nearby/LocalNearbyTest.java | 7 +- 4 files changed, 301 insertions(+), 67 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index ef8ad5be21f..980d61498ca 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -548,6 +548,14 @@ public void requestConnection(final int requestId, final String endpointId, @Override public void run() { if (generation != transportGeneration) { + // Failed, not dropped. Returning silently left the + // caller's AsyncResource pending for good -- a resource + // that never settles is worse than one that fails, which + // is the whole reason EdtResult exists. + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "the transport was stopped before the connection" + + " was answered"); return; } NearbyTransport.deliverRequestOk(requestId); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 6a27a276836..f6f89b8f2da 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -544,19 +544,24 @@ public void onPayloadTransferUpdate(String endpointId, != PayloadTransferUpdate.Status.SUCCESS) { return; } - java.io.File f = null; - try { - if (file.asFile() != null) { - f = file.asFile().asJavaFile(); - } - } catch (Throwable t) { - // Older Play services expose the file only through a - // ParcelFileDescriptor; nothing to hand the app then. + String path = localPathFor(file); + if (path == null) { + // A payload whose only accessor cannot be used is worse + // than one that failed: getPath() is all a file Payload + // offers, so delivering it with a null path told the app + // the transfer succeeded and then gave it nothing to + // read. Reported as a failure instead, which is a state + // the API already documents. + NearbyTransport.deliverPayloadProgress( + encode(endpointId, nameOf(endpointId)), + senderIdOf(file), 0, update.getTotalBytes(), + PayloadStatus.FAILURE.ordinal()); + return; } NearbyTransport.deliverPayloadReceived( encode(endpointId, nameOf(endpointId)), senderIdOf(file), NearbyBridge.PAYLOAD_FILE, null, - f == null ? null : "file://" + f.getAbsolutePath()); + "file://" + path); } }; } @@ -613,6 +618,88 @@ private String encode(String endpointId, String name) { + sanitize(service == null ? "" : service); } + /// A readable local path for a received file. + /// + /// asJavaFile() is the easy case and increasingly not the one that + /// happens: under scoped storage Nearby hands the file over as a content + /// Uri or a descriptor, and the app has no way to open either through the + /// portable API, whose file payload carries a path and nothing else. So + /// the content is copied into the app's own files directory and that path + /// is returned. + /// + /// #### Parameters + /// + /// - `file`: the received payload + /// + /// #### Returns + /// + /// an absolute path the app can read, or null when the content could not + /// be reached at all + private String localPathFor(Payload file) { + try { + Payload.File f = file.asFile(); + if (f == null) { + return null; + } + java.io.File local = f.asJavaFile(); + if (local != null && local.exists()) { + return local.getAbsolutePath(); + } + android.net.Uri uri = f.asUri(); + if (uri == null) { + return null; + } + java.io.File out = new java.io.File(context.getFilesDir(), + "cn1nearby-" + file.getId() + "-" + + sanitizeFileName(uri.getLastPathSegment())); + java.io.InputStream in = + context.getContentResolver().openInputStream(uri); + if (in == null) { + return null; + } + try { + java.io.OutputStream os = new java.io.FileOutputStream(out); + try { + byte[] buffer = new byte[8192]; + int read = in.read(buffer); + while (read > 0) { + os.write(buffer, 0, read); + read = in.read(buffer); + } + } finally { + os.close(); + } + } finally { + in.close(); + } + return out.getAbsolutePath(); + } catch (Throwable unreadable) { + return null; + } + } + + /// Reduces a remote-chosen name to something safe to append to a + /// directory. The name crossed the wire, so it is untrusted. + private static String sanitizeFileName(String name) { + if (name == null || name.length() == 0) { + return "payload"; + } + StringBuilder out = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '.' || c == '-' + || c == '_') { + out.append(c); + } + } + String safe = out.toString(); + if (safe.length() == 0 || ".".equals(safe) || "..".equals(safe)) { + return "payload"; + } + return safe; + } + /// The marker that carries a sender's payload id in a file name. private static final String ID_PREFIX = "cn1id-"; diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index aed81a0ab46..0fea938e47b 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -144,6 +144,26 @@ static void cn1nbFailTransport(int requestId, int error, NSString *message) { getThreadLocalData(), requestId, error, cn1nbJString(message)); } +/// True when an error describes a transfer this app cancelled. +/// +/// MultipeerConnectivity reports a cancelled resource through the same +/// completion handler as a broken one, so the error is all there is to go on. +static BOOL cn1nbWasCancelled(NSError *error) { + if (error == nil) { + return NO; + } + if ([[error domain] isEqualToString:NSCocoaErrorDomain] + && [error code] == NSUserCancelledError) { + return YES; + } + // NSProgress cancellation surfaces as POSIX ECANCELED on some releases. + if ([[error domain] isEqualToString:NSPOSIXErrorDomain] + && [error code] == ECANCELED) { + return YES; + } + return NO; +} + static void cn1nbTransportOk(int requestId) { com_codename1_impl_ios_IOSNearbyCallbacks_transportOk___int( getThreadLocalData(), requestId); @@ -513,6 +533,7 @@ - (MCSession *)sessionFor:(NSString *)endpointId { if (endpointId == nil) { return nil; } + @synchronized (self) { MCSession *existing = [self.sessionsById objectForKey:endpointId]; if (existing != nil) { return existing; @@ -524,6 +545,7 @@ - (MCSession *)sessionFor:(NSString *)endpointId { created.delegate = self; [self.sessionsById setObject:created forKey:endpointId]; return created; + } } /// Drops one endpoint's session and forgets it. @@ -532,12 +554,18 @@ - (void)closeSessionFor:(NSString *)endpointId { // owner of this session -- it was created autoreleased and the pool it // was created in has long since drained -- so removing the entry first // released it, and the two messages below then went to freed memory. - MCSession *session = [[[self.sessionsById objectForKey:endpointId] retain] - autorelease]; - if (session == nil) { - return; + MCSession *session; + @synchronized (self) { + session = [[[self.sessionsById objectForKey:endpointId] retain] + autorelease]; + if (session == nil) { + return; + } + [self.sessionsById removeObjectForKey:endpointId]; } - [self.sessionsById removeObjectForKey:endpointId]; + // Outside the lock: disconnect can run delegate work, and holding the + // transport's monitor across it would invite a deadlock against the + // delegate queue that is trying to take the same monitor. session.delegate = nil; [session disconnect]; } @@ -550,32 +578,96 @@ - (void)closeSessionFor:(NSString *)endpointId { /// - `payloadId`: the payload every recipient of this send shares - (void)rememberProgress:(NSProgress *)progress forPayload:(JAVA_INT)payloadId { NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; - NSMutableArray *all = [self.progressByPayload objectForKey:key]; - if (all == nil) { - all = [NSMutableArray array]; - [self.progressByPayload setObject:all forKey:key]; + @synchronized (self) { + NSMutableArray *all = [self.progressByPayload objectForKey:key]; + if (all == nil) { + all = [NSMutableArray array]; + [self.progressByPayload setObject:all forKey:key]; + } + [all addObject:progress]; } - [all addObject:progress]; } /// Forgets the transfers in `holder`, and the payload entry once the last /// recipient is done with it. - (void)forgetProgress:(NSArray *)holder forPayload:(JAVA_INT)payloadId { NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; - NSMutableArray *all = [self.progressByPayload objectForKey:key]; - if (all == nil) { - return; + @synchronized (self) { + NSMutableArray *all = [self.progressByPayload objectForKey:key]; + if (all == nil) { + return; + } + [all removeObjectsInArray:holder]; + if ([all count] == 0) { + [self.progressByPayload removeObjectForKey:key]; + } } - [all removeObjectsInArray:holder]; - if ([all count] == 0) { +} + +/// Takes and forgets every transfer registered for a payload, for cancel. +- (NSArray *)takeProgressesForPayload:(JAVA_INT)payloadId { + NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; + @synchronized (self) { + NSArray *all = [[[self.progressByPayload objectForKey:key] copy] + autorelease]; [self.progressByPayload removeObjectForKey:key]; + return all; + } +} + +/// Drops every unanswered invitation. +- (void)forgetInvitations { + @synchronized (self) { + [self.invitations removeAllObjects]; + } +} + +/// Records an invitation handler against the peer that sent it. +- (void)rememberInvitation:(id)handler forPeer:(NSString *)pid { + @synchronized (self) { + [self.invitations setObject:handler forKey:pid]; + } +} + +/// Takes the invitation handler for a peer, retained past the removal so it +/// survives being the dictionary's only owner. +- (id)takeInvitationForPeer:(NSString *)pid { + if (pid == nil) { + return nil; + } + @synchronized (self) { + id handler = [[[self.invitations objectForKey:pid] retain] autorelease]; + [self.invitations removeObjectForKey:pid]; + return handler; + } +} + +/// Records that a peer reached Connected, answering whether it is new. +- (void)markEverConnected:(NSString *)pid { + @synchronized (self) { + [self.everConnected addObject:pid]; + } +} + +/// True when this peer had reached Connected, forgetting it either way. +- (BOOL)takeEverConnected:(NSString *)pid { + @synchronized (self) { + if (![self.everConnected containsObject:pid]) { + return NO; + } + [self.everConnected removeObject:pid]; + return YES; } } /// How many peers are connected across every session. - (NSUInteger)connectedPeerCount { NSUInteger n = 0; - for (MCSession *session in [self.sessionsById allValues]) { + NSArray *sessions; + @synchronized (self) { + sessions = [[[self.sessionsById allValues] copy] autorelease]; + } + for (MCSession *session in sessions) { n += [session.connectedPeers count]; } return n; @@ -583,7 +675,10 @@ - (NSUInteger)connectedPeerCount { /// Drops every session. - (void)closeAllSessions { - NSArray *keys = [self.sessionsById allKeys]; + NSArray *keys; + @synchronized (self) { + keys = [[[self.sessionsById allKeys] copy] autorelease]; + } for (NSString *key in keys) { [self closeSessionFor:key]; } @@ -593,23 +688,28 @@ - (void)closeAllSessions { - (NSString *)encodePeer:(MCPeerID *)peer service:(NSString *)serviceId { NSString *pid = cn1nbIdForPeer(peer); if (serviceId != nil) { - [self.serviceIdByPeer setObject:serviceId forKey:pid]; + @synchronized (self) { + [self.serviceIdByPeer setObject:serviceId forKey:pid]; + } } return [self encodePeer:peer]; } - (NSString *)encodePeer:(MCPeerID *)peer { NSString *pid = cn1nbIdForPeer(peer); - [self.peersById setObject:peer forKey:pid]; + NSString *service; + @synchronized (self) { + [self.peersById setObject:peer forKey:pid]; // The service this peer was actually seen on, not whichever of the two // was configured most recently. A peer reached through a session -- a // state change, an arriving payload -- was found by the browser or came // in through the advertiser earlier, and that is when the mapping was // recorded. - NSString *service = [self.serviceIdByPeer objectForKey:pid]; - if (service == nil) { - service = self.discoverServiceId != nil ? self.discoverServiceId - : self.advertiseServiceId; + service = [self.serviceIdByPeer objectForKey:pid]; + if (service == nil) { + service = self.discoverServiceId != nil ? self.discoverServiceId + : self.advertiseServiceId; + } } return cn1nbJoin([NSArray arrayWithObjects:pid, peer.displayName == nil ? @"" : peer.displayName, @@ -617,7 +717,12 @@ - (NSString *)encodePeer:(MCPeerID *)peer { } - (MCPeerID *)peerForId:(NSString *)pid { - return pid == nil ? nil : [self.peersById objectForKey:pid]; + if (pid == nil) { + return nil; + } + @synchronized (self) { + return [self.peersById objectForKey:pid]; + } } /// MultipeerConnectivity gives no comparison token, and this does not invent @@ -652,7 +757,7 @@ - (void)session:(MCSession *)session peer:(MCPeerID *)peerID NSString *pid = cn1nbIdForPeer(peerID); NSString *encoded = [self encodePeer:peerID]; if (state == MCSessionStateConnected) { - [self.everConnected addObject:pid]; + [self markEverConnected:pid]; com_codename1_impl_ios_IOSNearbyCallbacks_connectionResult___java_lang_String_boolean_int_java_lang_String( getThreadLocalData(), cn1nbJString(encoded), JAVA_TRUE, 0, JAVA_NULL); @@ -663,8 +768,7 @@ - (void)session:(MCSession *)session peer:(MCPeerID *)peerID // for a connected/failed answer that never came -- a rejected or // timed-out invitation lands here without ever having been connected. // Only a peer that actually reached Connected can disconnect. - if ([self.everConnected containsObject:pid]) { - [self.everConnected removeObject:pid]; + if ([self takeEverConnected:pid]) { com_codename1_impl_ios_IOSNearbyCallbacks_disconnected___java_lang_String( getThreadLocalData(), cn1nbJString(encoded)); return; @@ -830,8 +934,8 @@ - (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser service:self.advertiseServiceId]; // Copied because the block outlives this call: it is answered when // the app calls accept or reject, which is at least an EDT hop away. - [self.invitations setObject:[[invitationHandler copy] autorelease] - forKey:pid]; + [self rememberInvitation:[[invitationHandler copy] autorelease] + forPeer:pid]; com_codename1_impl_ios_IOSNearbyCallbacks_connectionRequested___java_lang_String_java_lang_String( getThreadLocalData(), cn1nbJString(encoded), cn1nbJString([self tokenForPeer:peerID])); @@ -1705,7 +1809,16 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str // Recorded BEFORE the answer: didNotStartAdvertisingPeer can fire // after this returns, and it needs the id to fail. t.advertiseStrategy = (int)strategy; + // A start within the grace period of an earlier one replaces its + // pending id, and the earlier settler would then see a mismatch and + // return -- leaving that caller's AsyncResource pending for good. + // Answered on the way out: advertising did start, and the newer call + // is what changed it. + int superseded = t.pendingAdvertiseRequest; t.pendingAdvertiseRequest = requestId; + if (superseded != 0 && superseded != requestId) { + cn1nbTransportOk(superseded); + } [t.advertiser startAdvertisingPeer]; cn1nbSettleTransportStart(t, YES, requestId); return; @@ -1759,7 +1872,12 @@ void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_Strin serviceType:t.discoverServiceType] autorelease]; t.browser.delegate = t; t.discoverStrategy = (int)strategy; + // Answered for the reason the advertising path is. + int superseded = t.pendingDiscoverRequest; t.pendingDiscoverRequest = requestId; + if (superseded != 0 && superseded != requestId) { + cn1nbTransportOk(superseded); + } [t.browser startBrowsingForPeers]; cn1nbSettleTransportStart(t, NO, requestId); return; @@ -1852,8 +1970,7 @@ void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_Str // entry first freed the block and calling it crashed. void (^handler)(BOOL, MCSession *) = cn1nbTransport == nil ? nil - : [[[cn1nbTransport.invitations objectForKey:pid] - retain] autorelease]; + : [cn1nbTransport takeInvitationForPeer:pid]; if (handler == nil) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, @"there is no invitation from that endpoint"); @@ -1865,14 +1982,12 @@ void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_Str if (cn1nbTransport.advertiseStrategy == CN1_NEARBY_STRATEGY_POINT_TO_POINT && [cn1nbTransport connectedPeerCount] > 0) { - [cn1nbTransport.invitations removeObjectForKey:pid]; handler(NO, nil); cn1nbFailTransport(requestId, CN1_NEARBY_ERR_BUSY, @"POINT_TO_POINT allows one connection at a time;" @" disconnect the current peer first"); return; } - [cn1nbTransport.invitations removeObjectForKey:pid]; handler(YES, [cn1nbTransport sessionFor:pid]); cn1nbTransportOk(requestId); return; @@ -1891,10 +2006,8 @@ void com_codename1_impl_ios_IOSNative_nearbyRejectConnection___java_lang_String( } NSString *pid = toNSString(CN1_THREAD_STATE_PASS_ARG endpointId); void (^handler)(BOOL, MCSession *) = - [[[cn1nbTransport.invitations objectForKey:pid] retain] - autorelease]; + [cn1nbTransport takeInvitationForPeer:pid]; if (handler != nil) { - [cn1nbTransport.invitations removeObjectForKey:pid]; handler(NO, nil); } } @@ -1953,11 +2066,20 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i withCompletionHandler:^(NSError *error) { @autoreleasepool { NSString *encoded = [cn1nbTransport encodePeer:peer]; + // Cancellation is its own status, not a failure. + // PayloadStatus.CANCELED exists precisely so an app + // can tell "I stopped this" from "the link broke", + // and mapping every error to FAILURE hid the one it + // caused itself. + JAVA_INT status = CN1_NEARBY_PAYLOAD_SUCCESS; + if (error != nil) { + status = cn1nbWasCancelled(error) + ? CN1_NEARBY_PAYLOAD_CANCELED + : CN1_NEARBY_PAYLOAD_FAILURE; + } com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), - payloadId, 0, -1, - error == nil ? CN1_NEARBY_PAYLOAD_SUCCESS - : CN1_NEARBY_PAYLOAD_FAILURE); + payloadId, 0, -1, status); // Only THIS recipient's transfer is finished. The // others under the same payload id are still going, // and dropping the whole entry here left them @@ -1997,37 +2119,52 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i if (data != nil) { [framed appendData:data]; } - // One send per peer, because each has its own session now. + // One send per peer, because each has its own session now -- and one + // progress update per peer, reporting what happened to THAT peer. + // + // Reported per recipient rather than suppressed wholesale. Sending to + // three peers where the third fails still delivered the payload to + // the first two, and answering the aggregate request with a failure + // and then skipping the loop entirely meant neither the recipients + // that got it nor the one that did not produced any terminal update + // at all. NSError *err = nil; BOOL sent = [peers count] > 0; + NSMutableArray *outcomes = [NSMutableArray array]; for (NSUInteger i = 0; i < [peers count]; i++) { MCSession *session = [cn1nbTransport sessionFor:[peerIds objectAtIndex:i]]; NSError *one = nil; - if (![session sendData:framed - toPeers:[NSArray arrayWithObject: - [peers objectAtIndex:i]] - withMode:MCSessionSendDataReliable - error:&one]) { + BOOL ok = [session sendData:framed + toPeers:[NSArray arrayWithObject: + [peers objectAtIndex:i]] + withMode:MCSessionSendDataReliable + error:&one]; + [outcomes addObject:[NSNumber numberWithBool:ok]]; + if (!ok) { sent = NO; if (err == nil) { err = one; } } } + for (NSUInteger i = 0; i < [peers count]; i++) { + BOOL ok = [[outcomes objectAtIndex:i] boolValue]; + NSString *encoded = [cn1nbTransport + encodePeer:[peers objectAtIndex:i]]; + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, + ok ? (JAVA_LONG)[data length] : 0, + (JAVA_LONG)[data length], + ok ? CN1_NEARBY_PAYLOAD_SUCCESS + : CN1_NEARBY_PAYLOAD_FAILURE); + } if (!sent) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, [err localizedDescription]); return; } cn1nbTransportOk(requestId); - for (MCPeerID *peer in peers) { - NSString *encoded = [cn1nbTransport encodePeer:peer]; - com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( - CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, - (JAVA_LONG)[data length], (JAVA_LONG)[data length], - CN1_NEARBY_PAYLOAD_SUCCESS); - } return; } #endif @@ -2047,10 +2184,7 @@ void com_codename1_impl_ios_IOSNative_nearbyCancelPayload___int( // reports the failure. A byte payload cannot: sendData has left by the // time anything could ask, which is the same outcome an app gets from // cancelling one anywhere. - NSNumber *key = [NSNumber numberWithInt:payloadId]; - NSArray *all = [[[cn1nbTransport.progressByPayload objectForKey:key] - copy] autorelease]; - [cn1nbTransport.progressByPayload removeObjectForKey:key]; + NSArray *all = [cn1nbTransport takeProgressesForPayload:payloadId]; for (NSProgress *progress in all) { [progress cancel]; } @@ -2094,7 +2228,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStopAllTransport__( cn1nbTransport.browser = nil; } [cn1nbTransport closeAllSessions]; - [cn1nbTransport.invitations removeAllObjects]; + [cn1nbTransport forgetInvitations]; } #endif } diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 01573795710..faf4fc6d4d4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -23,6 +23,7 @@ package com.codename1.nearby; import com.codename1.impl.nearby.LocalNearbyBridge; +import com.codename1.util.AsyncResource; import com.codename1.impl.nearby.NearbyRequests; import com.codename1.impl.nearby.SyntheticNearby; import com.codename1.nearby.companion.AssociationRequest; @@ -581,12 +582,16 @@ public void connected(Endpoint e) { // the request and the acceptance the way it does on a real timer. List queue = new ArrayList(); bridge.deferForTest(queue); - NearbyTransport.requestConnection(e, "me"); + AsyncResource pending = NearbyTransport.requestConnection(e, + "me"); NearbyTransport.stop(); drain(queue); assertTrue(connected.isEmpty(), "a stopped transport must not connect: " + connected); + // Failed rather than left hanging: a resource that never settles is + // worse than one that fails. + assertFailedWith(NearbyError.SESSION_INVALIDATED, pending); } @Test From d5b4ca012dfa9b3219cb7cedc4457b141a9fc5de Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:48:15 +0300 Subject: [PATCH 27/94] Address the fifteenth nearby review round - A deliberate iOS close reports disconnected. Clearing the delegate before disconnect is what suppresses didChangeState:NotConnected, and nothing replaced it -- so after disconnect() or stop() every listener still believed the peer was connected. Reported explicitly now, gated on the same "actually reached Connected" test the delegate applies, so closing during an unanswered invitation stays silent rather than inventing a disconnection that never happened. - An incoming iOS resource that fails mid-transfer reports the sender's payload id. The framed cn1id- parse sat below the early failure branch, so a cancellation at the sender or a dropped link reported under id 0 and the receiver could not match it to the transfer it was watching. Moving the parse above the branch also lets that branch tell a cancellation from a failure. - The simulator stops announcing peers once discovery stops. The queued start ran regardless, resolving successfully and emitting endpointFound for every endpoint on a stopped simulator. It now fails with SESSION_INVALIDATED, and stopDiscovery and stopAdvertising bump the generation the way stopAllTransport already did. - A nearby build requires a modern toolchain. Every nearby build compiles against SDK 33, and the legacy configuration selects an Android Gradle plugin that predates it -- so the project failed during Gradle evaluation with a message naming neither the SDK nor the hint that chose the toolchain. The BuildDaemon copy hit this as a DSL mismatch as well, because its generator switches to the compileSdk property at 33 and AGP 4.1.1 does not have it; this copy always emits compileSdkVersion, so it had only the underlying problem. Both refuse it now. --- .../impl/nearby/LocalNearbyBridge.java | 15 +++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 56 +++++++++++++------ .../builders/AndroidGradleBuilder.java | 20 +++++++ .../com/codename1/nearby/LocalNearbyTest.java | 24 ++++++++ 4 files changed, 99 insertions(+), 16 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 980d61498ca..b94b00c234f 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -498,6 +498,7 @@ public void startAdvertising(final int requestId, String serviceId, @Override public void stopAdvertising() { advertising = false; + transportGeneration++; } @Override @@ -505,9 +506,20 @@ public void startDiscovery(final int requestId, final String serviceId, int strategy) { discovering = true; discoverStrategy = strategy; + final int generation = transportGeneration; answer(new Runnable() { @Override public void run() { + // A stop between the call and this hop means there is no + // discovery to report into. Reporting endpoints anyway had + // the stopped simulator announcing peers nobody had asked + // for, which is not what a device does. + if (!discovering || generation != transportGeneration) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "discovery was stopped before it started"); + return; + } NearbyTransport.deliverRequestOk(requestId); for (SimEndpoint e : endpoints) { e.serviceId = serviceId; @@ -520,6 +532,9 @@ public void run() { @Override public void stopDiscovery() { discovering = false; + // Bumped so a start still queued for this run of discovery can tell + // that it has been stopped, the same way stopAllTransport does. + transportGeneration++; } @Override diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 0fea938e47b..781ec136e92 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -568,6 +568,23 @@ - (void)closeSessionFor:(NSString *)endpointId { // delegate queue that is trying to take the same monitor. session.delegate = nil; [session disconnect]; + // Reported here, because clearing the delegate above is what stops + // didChangeState:NotConnected from reporting it. A deliberate close is + // still a disconnection as far as the app is concerned, and suppressing + // both the callback and its replacement left every listener believing + // the peer was still connected. + // + // Only for a peer that actually reached Connected -- the same test the + // delegate applies, so a close during an unanswered invitation stays + // silent rather than inventing a disconnection that never happened. + if ([self takeEverConnected:endpointId]) { + MCPeerID *peer = [self peerForId:endpointId]; + if (peer != nil) { + com_codename1_impl_ios_IOSNearbyCallbacks_disconnected___java_lang_String( + getThreadLocalData(), + cn1nbJString([self encodePeer:peer])); + } + } } /// Records one recipient's transfer so cancel can reach it. @@ -827,10 +844,29 @@ - (void)session:(MCSession *)session withError:(NSError *)error { @autoreleasepool { NSString *encoded = [self encodePeer:peerID]; + // The sender's id is parsed off the resource name BEFORE anything + // else, because the failure branch below needs it too. Parsed after + // it, a transfer that broke mid-flight -- a cancellation at the + // sender, a dropped link -- reported its failure under id 0 and the + // receiver could not match it to the transfer it was watching. + JAVA_INT filePayloadId = 0; + NSString *bare = resourceName; + if ([bare hasPrefix:@"cn1id-"]) { + NSRange dash = [bare rangeOfString:@"-" + options:0 + range:NSMakeRange(6, [bare length] - 6)]; + if (dash.location != NSNotFound) { + filePayloadId = (JAVA_INT)[[bare substringWithRange: + NSMakeRange(6, dash.location - 6)] intValue]; + bare = [bare substringFromIndex:dash.location + 1]; + } + } if (error != nil || localURL == nil) { com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( - getThreadLocalData(), cn1nbJString(encoded), 0, 0, -1, - CN1_NEARBY_PAYLOAD_FAILURE); + getThreadLocalData(), cn1nbJString(encoded), + filePayloadId, 0, -1, + cn1nbWasCancelled(error) ? CN1_NEARBY_PAYLOAD_CANCELED + : CN1_NEARBY_PAYLOAD_FAILURE); return; } // The URL the framework hands over is in a temporary location it will @@ -842,20 +878,8 @@ - (void)session:(MCSession *)session // move below would then delete and overwrite files elsewhere in the // container. Reduced to its last path component, and anything that // still looks like traversal or a separator is replaced outright. - // The sender framed its payload id into the name; strip it back off - // before the name is used for anything else. - JAVA_INT filePayloadId = 0; - NSString *bare = resourceName; - if ([bare hasPrefix:@"cn1id-"]) { - NSRange dash = [bare rangeOfString:@"-" - options:0 - range:NSMakeRange(6, [bare length] - 6)]; - if (dash.location != NSNotFound) { - filePayloadId = (JAVA_INT)[[bare substringWithRange: - NSMakeRange(6, dash.location - 6)] intValue]; - bare = [bare substringFromIndex:dash.location + 1]; - } - } + // filePayloadId and bare were resolved above, before the failure + // branch that also needs them. NSString *safe = [bare lastPathComponent]; if (safe == nil || [safe length] == 0 || [safe isEqualToString:@"."] 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 93d1989a75f..be1eb65a024 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 @@ -2644,6 +2644,26 @@ public void usesClassMethod(String cls, String method) { // usesHealthStore, NOT usesHealth: com.codename1.health.sensors is // pure BLE and must not drag in Health Connect or a Google Play // health-permissions review. + if (usesNearbyRanging || usesNearbyTransport || usesNearbyCompanion) { + // Every nearby build compiles against SDK 33 -- see the floor + // further down, which AndroidNearbyBackend's use of + // android.companion.AssociationInfo forces. An Android Gradle + // plugin from before that SDK existed cannot build such a + // project: it either rejects the compile SDK outright or, on the + // legacy toolchain, is handed a DSL it does not have. Refused + // here rather than left to fail during Gradle evaluation with a + // message that names none of this. + if (!useGradle8 || gradleVersionInt < 8) { + throw new BuildException( + "com.codename1.nearby needs to compile against" + + " Android SDK 33, which the Android Gradle plugin" + + " for Gradle " + gradleVersion + + " (android.useGradle8=" + useGradle8 + ") predates." + + " Set android.useGradle8=true and leave" + + " android.gradleVersion unset to build a nearby" + + " app."); + } + } if (usesNearbyRanging) { // androidx.core.uwb's AAR declares minAgpVersion=8.9.1 as well as // minCompileSdk=36, and Gradle's dependency check rejects the diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index faf4fc6d4d4..1806e8bbf5b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -555,6 +555,30 @@ void observingSomethingThatIsNotAssociatedIsRefused() { // transport // ------------------------------------------------------------------ + @Test + void stoppingBeforeDiscoveryStartsReportsNoEndpoints() { + // The queued start had no idea discovery had been stopped, so a + // stopped simulator announced peers nobody had asked for -- and + // resolved the start as though discovery were running. + final List found = new ArrayList(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.add(e); + } + }); + List queue = new ArrayList(); + bridge.deferForTest(queue); + AsyncResource pending = NearbyTransport.startDiscovery("chat", + TransportStrategy.CLUSTER); + NearbyTransport.stop(); + drain(queue); + + assertTrue(found.isEmpty(), + "a stopped simulator must not announce peers: " + found); + assertFailedWith(NearbyError.SESSION_INVALIDATED, pending); + } + @Test void stoppingBeforeTheAcceptanceLandsLeavesTheTransportStopped() { // Nothing in the simulation completes inline, which is the point -- From f0c7f671084feef9d6938652fd84fd56e824f02d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:58:50 +0300 Subject: [PATCH 28/94] Address the sixteenth nearby review round - An incoming file's terminal SUCCESS is held back until the copy into app storage has actually succeeded. The URI-copy this branch added earlier left Nearby's SUCCESS reported first and a FAILURE emitted a few lines later when the copy failed -- two contradictory terminal states for one transfer, with the wrong one arriving first, so anything that finalizes on terminal status finalized on it. Emitted once now, when the outcome is known. The payloadRecipients early return cannot swallow it: that map is written only by our own sendPayload, so an incoming payload never has an entry in it. - UWB start() is answered by the subscription rather than before it. subscribeOn puts the actual startRanging on an io thread, so resolving immediately said "ranging started" while the radio had not been asked -- and a rejected channel, address or key then arrived as an invalidation AFTER the caller had been told it succeeded. Whichever comes first answers it now: the first measurement, the first error (which fails the start rather than invalidating a session the caller believes is running), or a bounded backstop for the case with no signal of its own, a session that starts cleanly with nothing yet in range. - Presence observation for an association with no Bluetooth address. It was suggested the association-id overload arrived in API 33; it did not. javap over android-33, android-34 and android-35 finds only the String overload, and android.companion.ObservingDevicePresenceRequest with the startObservingDevicePresence that takes it are API 36. The reason is recorded beside the code. Where that request exists it is used, through reflection so the package keeps compiling against the SDK 33 floor the rest of nearby needs rather than forcing 36 on every app that merely associates a device; where it does not, the port says plainly that this Android version can only observe an association that has an address, instead of returning a bare false from a swallowed exception. --- .../android/nearby/AndroidNearbyBackend.java | 111 +++++++++++++++++- .../nearby/AndroidNearbyTransport.java | 28 ++++- .../android/nearby/AndroidUwbRanging.java | 64 +++++++++- 3 files changed, 193 insertions(+), 10 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index e862a7c2827..9d667f9d029 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -37,6 +37,7 @@ import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.util.Log; import android.net.MacAddress; import android.os.ParcelUuid; @@ -548,8 +549,32 @@ public boolean startObservingPresence(String associationId) { return false; } try { - // Takes the MAC address on every version that has it, which is - // what the encoded address field carries. + // An association with no MAC -- a Wi-Fi or self-managed companion + // -- cannot use the address overload: addressOf falls back to the + // numeric association id, which that overload rejects as not a + // MAC address, and the exception was swallowed into a bare false. + // + // It was suggested the association-id overload arrived in API 33. + // It did not: android.companion.ObservingDevicePresenceRequest, + // and the startObservingDevicePresence(request) that takes it, + // are API 36 -- javap over android-33 through android-35 shows + // only the String overload. So this is the honest split: use the + // request where it exists, and where it does not, say plainly + // that the platform cannot observe this association rather than + // failing with no reason. + if (macOf(cdm, associationId) == null) { + if (Build.VERSION.SDK_INT >= 36 + && observeByAssociationId(cdm, associationId)) { + CN1CompanionDeviceService.register(associationId); + return true; + } + Log.w("CN1", "com.codename1.nearby.companion: this Android" + + " version can only observe an association that has" + + " a Bluetooth address, and association " + + associationId + " has none. Presence observation" + + " for it needs Android 16 or later."); + return false; + } cdm.startObservingDevicePresence(addressOf(cdm, associationId)); CN1CompanionDeviceService.register(associationId); return true; @@ -558,6 +583,81 @@ public boolean startObservingPresence(String associationId) { } } + /// Observes by association id, which only API 36 can do. + /// + /// Reached reflectively so the port still compiles against the SDK 33 + /// floor the rest of the nearby package needs; referencing + /// ObservingDevicePresenceRequest directly would raise that floor to 36 + /// for every app that merely associates a device. + /// + /// #### Parameters + /// + /// - `cdm`: the platform manager + /// - `associationId`: the association to watch + /// + /// #### Returns + /// + /// true when the platform accepted the request + private static boolean observeByAssociationId(CompanionDeviceManager cdm, + String associationId) { + try { + int numeric = Integer.parseInt(associationId); + Class builderClass = Class.forName( + "android.companion.ObservingDevicePresenceRequest$Builder"); + Object builder = builderClass.newInstance(); + builderClass.getMethod("setAssociationId", int.class) + .invoke(builder, Integer.valueOf(numeric)); + Object request = builderClass.getMethod("build").invoke(builder); + Class requestClass = Class.forName( + "android.companion.ObservingDevicePresenceRequest"); + CompanionDeviceManager.class + .getMethod("startObservingDevicePresence", requestClass) + .invoke(cdm, request); + return true; + } catch (Throwable notAvailable) { + return false; + } + } + + /// The API 36 counterpart of observeByAssociationId. + private static void stopObservingByAssociationId( + CompanionDeviceManager cdm, String associationId) { + try { + int numeric = Integer.parseInt(associationId); + Class builderClass = Class.forName( + "android.companion.ObservingDevicePresenceRequest$Builder"); + Object builder = builderClass.newInstance(); + builderClass.getMethod("setAssociationId", int.class) + .invoke(builder, Integer.valueOf(numeric)); + Object request = builderClass.getMethod("build").invoke(builder); + Class requestClass = Class.forName( + "android.companion.ObservingDevicePresenceRequest"); + CompanionDeviceManager.class + .getMethod("stopObservingDevicePresence", requestClass) + .invoke(cdm, request); + } catch (Throwable notAvailable) { + // Stopping something the platform is not watching is not a + // failure the caller can act on. + } + } + + /// The MAC of an association, or null when it has none. + @SuppressLint("MissingPermission") + private static String macOf(CompanionDeviceManager cdm, + String associationId) { + if (Build.VERSION.SDK_INT < 33) { + // Below 33 the id IS the address; there is nothing else to hold. + return associationId; + } + List all = cdm.getMyAssociations(); + for (int i = 0; all != null && i < all.size(); i++) { + if (idOf(all.get(i)).equals(associationId)) { + return macOf(all.get(i)); + } + } + return null; + } + @SuppressLint("MissingPermission") public void stopObservingPresence(String associationId) { CompanionDeviceManager cdm = manager(); @@ -566,6 +666,13 @@ public void stopObservingPresence(String associationId) { return; } try { + if (macOf(cdm, associationId) == null) { + if (Build.VERSION.SDK_INT >= 36) { + stopObservingByAssociationId(cdm, associationId); + } + CN1CompanionDeviceService.unregister(associationId); + return; + } cdm.stopObservingDevicePresence(addressOf(cdm, associationId)); CN1CompanionDeviceService.unregister(associationId); } catch (Throwable t) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index f6f89b8f2da..311686c143d 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -512,10 +512,24 @@ public void onPayloadTransferUpdate(String endpointId, Integer mapped = payloadIds.get(key); int id = mapped == null ? (int) update.getPayloadId() : mapped.intValue(); - NearbyTransport.deliverPayloadProgress( - encode(endpointId, nameOf(endpointId)), id, - update.getBytesTransferred(), update.getTotalBytes(), - statusFor(update.getStatus()).ordinal()); + // An incoming FILE that Nearby calls SUCCESS is not a success + // yet: the copy into app storage below can still fail, and + // reporting terminal SUCCESS here and terminal FAILURE a few + // lines later gave the receiver two contradictory terminal + // states for one transfer -- with the first one arriving + // first, so anything that finalizes on terminal status + // finalized on the wrong one. Held back and emitted once, + // when the outcome is actually known. + boolean incomingFileSuccess = incomingFiles.containsKey(key) + && update.getStatus() + == PayloadTransferUpdate.Status.SUCCESS; + if (!incomingFileSuccess) { + NearbyTransport.deliverPayloadProgress( + encode(endpointId, nameOf(endpointId)), id, + update.getBytesTransferred(), + update.getTotalBytes(), + statusFor(update.getStatus()).ordinal()); + } if (update.getStatus() == PayloadTransferUpdate.Status.IN_PROGRESS) { return; @@ -558,6 +572,12 @@ public void onPayloadTransferUpdate(String endpointId, PayloadStatus.FAILURE.ordinal()); return; } + // The terminal SUCCESS held back above, now that it is true. + NearbyTransport.deliverPayloadProgress( + encode(endpointId, nameOf(endpointId)), + senderIdOf(file), update.getBytesTransferred(), + update.getTotalBytes(), + PayloadStatus.SUCCESS.ordinal()); NearbyTransport.deliverPayloadReceived( encode(endpointId, nameOf(endpointId)), senderIdOf(file), NearbyBridge.PAYLOAD_FILE, null, diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index a17598d96f5..7651012d14b 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -404,29 +404,80 @@ private void run(final int requestId, final Session session, channel, peers, RangingParameters.RANGING_UPDATE_RATE_AUTOMATIC); + // Answered by the subscription, not before it. subscribeOn puts + // the actual startRanging on an io thread, so resolving here said + // "ranging started" while the radio had not been asked yet -- and + // a rejected channel, address or key then arrived as an + // invalidation AFTER the caller had already been told it + // succeeded, which is the opposite of what start() documents. + // + // Whichever comes first wins: the first measurement (ranging is + // demonstrably running), the first error (it is not), or the + // grace timer. The timer is the backstop for the case that has no + // signal of its own -- a session that starts cleanly and simply + // has nothing in range to measure yet. + session.startRequest.set(requestId); session.subscription = UwbClientSessionScopeRx .rangingResultsObservable(session.scope, params) .subscribeOn(Schedulers.io()) .subscribe(new io.reactivex.rxjava3.functions.Consumer< RangingResult>() { public void accept(RangingResult result) { + settleStarted(session); deliver(session.handle, result); } }, new io.reactivex.rxjava3.functions.Consumer< Throwable>() { public void accept(Throwable error) { - RangingSession.deliverInvalidated(session.handle, - NearbyError.SESSION_INVALIDATED.ordinal(), - message(error)); + int pending = session.startRequest.getAndSet(0); + if (pending != 0) { + // It never started, so the caller is told + // that rather than being told it started and + // then invalidated. + fail(pending, NearbyError.SESSION_FAILED, + message(error)); + } else { + RangingSession.deliverInvalidated( + session.handle, + NearbyError.SESSION_INVALIDATED + .ordinal(), + message(error)); + } sessions.remove(Integer.valueOf(session.handle)); } }); - Ranging.deliverSessionStarted(requestId, session.handle); + scheduleStartGrace(session); } catch (Throwable t) { fail(requestId, NearbyError.SESSION_FAILED, message(t)); } } + /// Answers the start request if it is still waiting. + private static void settleStarted(Session session) { + int pending = session.startRequest.getAndSet(0); + if (pending != 0) { + Ranging.deliverSessionStarted(pending, session.handle); + } + } + + /// How long a start is given to fail before it is called a success. + private static final long START_GRACE_MILLIS = 500; + + /// Answers a start that produced neither a measurement nor an error. + /// + /// A session can start perfectly well and have nothing in range to + /// measure, which produces no signal at all -- so without this the + /// caller's AsyncResource would wait for a peer that may never appear. + private void scheduleStartGrace(final Session session) { + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed( + new Runnable() { + @Override + public void run() { + settleStarted(session); + } + }, START_GRACE_MILLIS); + } + private static void deliver(int handle, RangingResult result) { if (result instanceof RangingResult.RangingResultPeerDisconnected) { RangingSession.deliverPeerRemoved(handle, @@ -566,6 +617,11 @@ private static final class Session { private int sessionId; private byte[] sessionKey; private Disposable subscription; + /// The start request still waiting for an answer, or 0 once it has + /// been answered. Answered exactly once, by whichever of the first + /// measurement, the first error, or the grace timer gets there. + private final java.util.concurrent.atomic.AtomicInteger startRequest = + new java.util.concurrent.atomic.AtomicInteger(); private Session(int handle, boolean controller) { this.handle = handle; From 26e5c957f77f672c1a8fbd9dbd2c8eeae160e9d7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:09:08 +0300 Subject: [PATCH 29/94] Address the seventeenth nearby review round Two of these share a root cause: a filter that could not be used was dropped, which quietly turns "show me only devices matching this" into "show me everything" -- and the user can then associate the wrong accessory from a picker that was never meant to offer it. - Android fails an association request whose supplied filter cannot be installed. addFilter already reported it and the loop ignored the answer, leaving the platform request unfiltered. - iOS refuses an address-only filter rather than skipping it. AccessorySetupKit cannot be pointed at one exact address, and skipping left items empty so the empty-list fallback filled the picker from every service in the plist. The broad fallback now applies only to a genuinely empty filter list; a narrow request that produces no usable item fails instead of getting the widest possible picker. NearbyError.INVALID_TOKEN's documentation is widened to cover a device filter, because that vocabulary is what apps branch on and stretching it silently would be worse than saying so. - The transport catalog entry gets a minSdk floor of 21. It was suggested play-services-nearby 18.4.0 forces 23 and that the manifest merger rejects a default build; the AAR declares minSdkVersion 14, as does every artifact in its transitive closure, so nothing rejects the builder's default of 19. The real floor is Nearby Connections itself, which advertises over BLE at API 21 -- and the newer play-services-nearby an app may resolve declares 21 too. Below that the dependency merges cleanly and the transport never starts. The evidence is recorded beside the entry and asserted by a catalog test. --- .../src/com/codename1/nearby/NearbyError.java | 13 ++++++-- .../android/nearby/AndroidNearbyBackend.java | 13 +++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 30 +++++++++++++++++-- .../build/shared/PlatformFeatureCatalog.java | 12 ++++++++ .../shared/PlatformFeatureCatalogTest.java | 4 +++ 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/NearbyError.java b/CodenameOne/src/com/codename1/nearby/NearbyError.java index 36c824a360b..a41abac79da 100644 --- a/CodenameOne/src/com/codename1/nearby/NearbyError.java +++ b/CodenameOne/src/com/codename1/nearby/NearbyError.java @@ -60,9 +60,16 @@ public enum NearbyError { /// resumed. Start a new one. SESSION_INVALIDATED, - /// The supplied token, accessory configuration or endpoint identifier - /// could not be decoded, or came from a different platform. Tokens are - /// opaque and are not portable between iOS and Android. + /// The supplied token, accessory configuration, endpoint identifier or + /// device filter could not be decoded or used, or came from a different + /// platform. Tokens are opaque and are not portable between iOS and + /// Android. + /// + /// A device filter reports this rather than being dropped: an + /// association request whose filter cannot be installed would otherwise + /// offer the user every visible device instead of the ones asked for, + /// and they could associate the wrong accessory from a picker that was + /// never meant to show it. INVALID_TOKEN, /// The platform never delivered a completion callback within the safety diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 9d667f9d029..0a21bd38b53 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -298,8 +298,19 @@ public void associate(final int requestId, int profile, request.setDeviceProfile(deviceProfile); } } + // A supplied filter that cannot be installed FAILS the request. It + // used to be ignored, which quietly turned "show me only devices + // matching this" into "show me everything" -- and the user could then + // associate the wrong accessory from a picker that was never supposed + // to offer it. A malformed service UUID or name pattern is a mistake + // worth reporting, not one worth widening. for (int i = 0; filters != null && i < filters.length; i++) { - addFilter(request, filters[i]); + if (!addFilter(request, filters[i])) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.INVALID_TOKEN.ordinal(), + "this device filter could not be used: " + filters[i]); + return; + } } // No filter is added when the caller gave none. An empty // BluetoothLeDeviceFilter is NOT the neutral choice it looks like: a diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 781ec136e92..de7d7825e72 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1606,11 +1606,14 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan NSString *joined = toNSString(CN1_THREAD_STATE_PASS_ARG joinedFilters); NSMutableArray *items = [NSMutableArray array]; + BOOL unsupportedFilter = NO; + NSUInteger filterCount = 0; for (NSString *line in cn1nbSplitLines(joined)) { NSArray *fields = [line componentsSeparatedByString:@"\t"]; if ([fields count] < 2) { continue; } + filterCount++; int kind = [[fields objectAtIndex:0] intValue]; NSString *value = [fields objectAtIndex:1]; ASDiscoveryDescriptor *descriptor = @@ -1636,9 +1639,14 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan descriptor.SSID = value; } else { // KIND_ADDRESS. AccessorySetupKit discovers accessories; - // it has no way to be pointed at one identifier, and - // widening the picker to everything would be worse than - // skipping the filter. + // it has no way to be pointed at one identifier. Skipping + // the filter used to leave `items` empty, and the + // fallback below then filled the picker from every + // service the plist declares -- so an exact-device + // reconnect offered unrelated accessories and could + // associate one. Refused instead: an address filter this + // platform cannot honour is not a filter it may ignore. + unsupportedFilter = YES; continue; } ASPickerDisplayItem *item = [[[ASPickerDisplayItem alloc] @@ -1647,6 +1655,22 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan descriptor:descriptor] autorelease]; [items addObject:item]; } + if (unsupportedFilter) { + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"AccessorySetupKit cannot search for one exact" + @" address; filter by service UUID or name instead"); + return; + } + if ([items count] == 0 && filterCount > 0) { + // Filters were supplied and none produced an item, so the + // request asked for something this platform cannot express. + // The broad fallback below is for a genuinely EMPTY filter + // list, and using it here would answer a narrow request with + // the widest possible picker. + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_INVALID_TOKEN, + @"none of the supplied device filters could be used"); + return; + } if ([items count] == 0) { // No usable filter. The portable API documents an empty // filter list as "offer every visible device", and the facade diff --git a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java index 9ee961cc72c..763b0102fbb 100644 --- a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java +++ b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java @@ -682,6 +682,18 @@ public final class PlatformFeatureCatalog { .iosPlist("NSLocalNetworkUsageDescription", "Finds and connects to nearby devices running this" + " app.") + // 21, and for the API rather than the artifact. It was + // suggested play-services-nearby 18.4.0 forces 23; it does + // not -- that AAR declares minSdkVersion 14, and so does + // every artifact in its transitive closure + // (play-services-base, -basement, -tasks, androidx.core + // 1.0.0), so no manifest merger rejects the builder's + // default of 19. What genuinely needs a floor is Nearby + // Connections itself: it advertises over BLE, which is API + // 21, and the newer play-services-nearby an app may resolve + // declares 21 too. Below that the dependency merges cleanly + // and the transport simply never starts. + .androidMinimumSdk(21) .description("Nearby device-to-device transport")); e.add(new Entry("com/codename1/nearby/companion/") diff --git a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java index c856db0b69a..35833633a8f 100644 --- a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java +++ b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java @@ -679,6 +679,10 @@ void transportLinksMultipeerAndAsksForTheLocalNetwork() { // Nearby Connections is added through the builder's own Play-services // table, which knows which version this build resolved. assertTrue(e.androidGradleDeps().isEmpty()); + // Nearby Connections advertises over BLE, which is API 21. Below that + // the dependency merges cleanly and the transport never starts, which + // is the failure worth preventing at build time. + assertEquals(21, e.androidMinimumSdk()); } @Test From 6402c76dc84bb4d50d9c10c7e327ec054e90454c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:22:09 +0300 Subject: [PATCH 30/94] Address the eighteenth nearby review round - Coarse and fine location are requested in ONE prompt. AndroidImplementation.checkForPermission issues a one-element requestPermissions, so looping over it asked for fine on its own -- which Android 12 and later reject outright, so the method answered false and the transport could not become authorized without the app asking a second time. The grouped request lives in the nearby package rather than in AndroidImplementation: that file is the always-compiled half of the port and this is not a change worth making there. - iOS ranging start() waits for startup, the way the Android UWB path already does. runWithConfiguration reports a refusal asynchronously through didInvalidateWithError -- the user declining Nearby Interaction, the active-session limit -- so answering straight after the call handed the caller a session that never measured anything. First update, invalidation, or a bounded backstop, whichever comes first. - The simulator implements cancellation. cancelPayload was empty, so a payload cancelled while a send was still queued went on to report SUCCESS and echo -- making the simulator the one place the public cancellation contract was never exercised. It reports CANCELED and delivers nothing. - Every companion profile the API exposes gets its permission. Only WATCH had a path, though CompanionProfile.COMPUTER and GLASSES are public and the backend forwards them on API 33 and 34; without the matching permission Android rejects the association before the chooser opens. inject() takes a comma-separated profile set rather than gaining two more positional booleans, filled from android.nearby.watchProfile, .computerProfile and .glassesProfile. Names match whole, so "watchdog" is not "watch". The guide's hint table lists both new hints. --- .../impl/nearby/LocalNearbyBridge.java | 20 +++++ .../android/nearby/AndroidNearbyBackend.java | 79 +++++++++++++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 46 +++++++++- docs/developer-guide/Nearby-Devices.asciidoc | 11 ++- .../builders/AndroidGradleBuilder.java | 34 ++++++-- .../builders/NearbyManifestFragments.java | 53 +++++++++-- .../builders/NearbyManifestFragmentsTest.java | 87 +++++++++++++------ .../com/codename1/nearby/LocalNearbyTest.java | 44 ++++++++++ 8 files changed, 319 insertions(+), 55 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index b94b00c234f..845c9d44f91 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -121,6 +121,10 @@ public class LocalNearbyBridge implements NearbyBridge { /// /// @hidden not part of the public API; test-only. private int lastAcceptRequestId; + /// Payload ids the app has cancelled, so a delivery already queued for + /// one can report CANCELED instead of SUCCESS. + private final java.util.Set cancelledPayloads = + new java.util.HashSet(); /// Where delayed deliveries go while a test drives the clock, or null in /// normal operation. /// @@ -643,6 +647,8 @@ public void sendPayload(final int requestId, final String[] endpointIds, @Override public void run() { NearbyTransport.deliverRequestOk(requestId); + boolean cancelled = cancelledPayloads.remove( + Integer.valueOf(payloadId)); for (String endpointId : endpointIds) { final SimEndpoint e = findEndpoint(endpointId); if (e == null || !connected.contains(endpointId)) { @@ -650,6 +656,12 @@ public void run() { } long total = payloadType == PAYLOAD_BYTES && bytes != null ? bytes.length : -1; + if (cancelled) { + NearbyTransport.deliverPayloadProgress(e.encode(), + payloadId, 0, total, + PayloadStatus.CANCELED.ordinal()); + continue; + } NearbyTransport.deliverPayloadProgress(e.encode(), payloadId, total < 0 ? 0 : total, total, PayloadStatus.SUCCESS.ordinal()); @@ -664,6 +676,13 @@ public void run() { @Override public void cancelPayload(int payloadId) { + // Cancellation is real here, not a no-op. sendPayload is delayed like + // everything else in this bridge, so an app CAN cancel while a send + // is still in flight -- and doing nothing meant the queued delivery + // went on to report SUCCESS and echo the payload, so the simulator + // was the one place the public cancellation contract was never + // exercised. + cancelledPayloads.add(Integer.valueOf(payloadId)); } @Override @@ -681,6 +700,7 @@ public void stopAllTransport() { advertising = false; discovering = false; transportGeneration++; + cancelledPayloads.clear(); List doomed = new ArrayList(connected); connected.clear(); for (String id : doomed) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 0a21bd38b53..1370d06fc53 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -188,7 +188,7 @@ public void requestPermissions(int requestId, int permissionBits) { // checkForPermission blocks through invokeAndBlock and must run on the // EDT. Display.getInstance().callSerially( - permissionRunnable(requestId, perms)); + permissionRunnable(requestId, perms, activity)); } /// Adds a permission the app has not already been granted. @@ -202,21 +202,84 @@ private void add(ArrayList perms, String permission) { /// Static so the Runnable carries no synthetic outer reference, which /// SpotBugs reports as SIC_INNER_SHOULD_BE_STATIC_ANON. private static Runnable permissionRunnable(final int requestId, - final ArrayList perms) { + final ArrayList perms, final Activity activity) { return new Runnable() { @Override public void run() { - boolean all = true; - for (String permission : perms) { - all = AndroidImplementation.checkForPermission(permission, - "This is required to find nearby devices") && all; - } com.codename1.nearby.ranging.Ranging.deliverPermissionResult( - requestId, all); + requestId, requestTogether(activity, perms)); } }; } + /// Asks for every outstanding permission in ONE prompt. + /// + /// Not a loop over AndroidImplementation.checkForPermission: that issues a + /// one-element requestPermissions, and from Android 12 fine and coarse + /// location must be requested TOGETHER -- the system shows one dialog with + /// a precise/approximate choice and rejects a request for fine on its own. + /// Asked one at a time, the fine request was refused outright, the method + /// answered false, and the transport could not become authorized without + /// the app asking a second time. + /// + /// #### Parameters + /// + /// - `activity`: the foreground activity + /// - `perms`: every permission the operation needs + /// + /// #### Returns + /// + /// true when all of them are granted once the prompt closes + static boolean requestTogether(Activity activity, List perms) { + if (Build.VERSION.SDK_INT < 23) { + return true; + } + if (activity == null) { + return false; + } + List missing = new ArrayList(); + for (int i = 0; i < perms.size(); i++) { + if (activity.checkSelfPermission(perms.get(i)) + != PackageManager.PERMISSION_GRANTED) { + missing.add(perms.get(i)); + } + } + if (missing.isEmpty()) { + return true; + } + if (!(activity instanceof CodenameOneActivity)) { + return false; + } + final CodenameOneActivity host = (CodenameOneActivity) activity; + host.setRequestForPermission(true); + host.setWaitingForPermissionResult(true); + // Request code 1, the one CodenameOneActivity's own result handler + // expects; it clears the flag whatever the code, but matching keeps + // this indistinguishable from the port's other permission requests. + activity.requestPermissions( + missing.toArray(new String[missing.size()]), 1); + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + while (host.isRequestForPermission()) { + try { + Thread.sleep(50); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } + }); + for (int i = 0; i < perms.size(); i++) { + if (activity.checkSelfPermission(perms.get(i)) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + // ------------------------------------------------------------------ // Ranging // ------------------------------------------------------------------ diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index de7d7825e72..8e34efe8369 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -129,6 +129,9 @@ static JAVA_OBJECT cn1nbJBytes(NSData *d) { return [joined componentsSeparatedByString:@"\n"]; } +/// How long a ranging start is given to fail before it is called a success. +#define CN1_NEARBY_RANGING_GRACE_NS (500ull * NSEC_PER_MSEC) + static void cn1nbFailRanging(int requestId, int error, NSString *message) { com_codename1_impl_ios_IOSNearbyCallbacks_rangingFailed___int_int_java_lang_String( getThreadLocalData(), requestId, error, cn1nbJString(message)); @@ -192,6 +195,7 @@ @interface CN1NearbyRangingSession : NSObject @property (nonatomic, assign) int handle; @property (nonatomic, assign) int pendingStartRequest; @property (nonatomic, retain) NISession *session; +- (void)settleStarted; @end static NSMutableDictionary *cn1nbSessions = nil; @@ -250,9 +254,30 @@ - (void)deliver:(NINearbyObject *)object { x, y, z); } +/// Answers a peer-ranging start that is still waiting. +/// +/// runWithConfiguration takes the configuration and reports a refusal +/// asynchronously through didInvalidateWithError -- the user declining Nearby +/// Interaction, the active-session limit -- so answering the caller straight +/// after that call said "ranging started" for a session that never measured +/// anything. Whichever comes first answers it: the first update (it really is +/// measuring), an invalidation (the branch below already fails it), or the +/// grace timer, which is the backstop for a session that starts cleanly and +/// simply has no peer in range yet. +- (void)settleStarted { + int pending = self.pendingStartRequest; + if (pending == 0) { + return; + } + self.pendingStartRequest = 0; + com_codename1_impl_ios_IOSNearbyCallbacks_sessionStarted___int_int( + getThreadLocalData(), pending, self.handle); +} + - (void)session:(NISession *)session didUpdateNearbyObjects:(NSArray *)nearbyObjects { @autoreleasepool { + [self settleStarted]; for (NINearbyObject *o in nearbyObjects) { [self deliver:o]; } @@ -339,6 +364,22 @@ - (void)session:(NISession *)session return [cn1nbSessions objectForKey:[NSNumber numberWithInt:handle]]; } +/// Answers a peer-ranging start once the session has had its chance to fail. +/// +/// A second answer is harmless: the Java side takes the pending request out +/// of its map, so whichever of this, the first update, and an invalidation +/// arrives first wins and the others are dropped. +static void cn1nbSettleRangingStart(CN1NearbyRangingSession *entry) + API_AVAILABLE(ios(14.0)) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)CN1_NEARBY_RANGING_GRACE_NS), + dispatch_get_main_queue(), ^{ + @autoreleasepool { + [entry settleStarted]; + } + }); +} + #endif // CN1_NEARBY_HAS_NI // ===================================================================== @@ -1507,10 +1548,9 @@ void com_codename1_impl_ios_IOSNative_nearbyStartRanging___int_int_byte_1ARRAY( NINearbyPeerConfiguration *config = [[[NINearbyPeerConfiguration alloc] initWithPeerToken:token] autorelease]; - entry.pendingStartRequest = 0; + entry.pendingStartRequest = requestId; [entry.session runWithConfiguration:config]; - com_codename1_impl_ios_IOSNearbyCallbacks_sessionStarted___int_int( - CN1_THREAD_STATE_PASS_ARG requestId, sessionHandle); + cn1nbSettleRangingStart(entry); return; } } diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index 08e0f9b8161..d25948a09c9 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -152,9 +152,12 @@ Ask for `CompanionProfile.GENERIC` unless the device is a watch, a head-mounted display or a computer. A profile is a request for elevated privileges as much as a description, and the specific ones cost the user a stronger prompt. The build scanner can't see which profile a request asks for, -because it arrives as an enum constant; set -`android.nearby.watchProfile=true` if you use `CompanionProfile.WATCH`, so the -manifest carries the permission that profile needs. +because it arrives as an enum constant, so name it yourself: set +`android.nearby.watchProfile`, `android.nearby.computerProfile` or +`android.nearby.glassesProfile` to `true` for whichever of +`CompanionProfile.WATCH`, `COMPUTER` and `GLASSES` you use. Each declares that +profile's own permission, and without it Android rejects the association +before the chooser opens -- which looks to the user like nothing happened. Two platform differences to design around. Presence notifications are Android only: AccessorySetupKit reports an accessory being added to or removed from the @@ -255,6 +258,8 @@ the simulator drives instead. | `ios.nearby.accessoryServices` | unset | Comma-separated Bluetooth service UUIDs the association picker may discover. Required for the picker to find anything on iOS. | `ios.nearby.background` | `false` | Requests the `com.apple.developer.nearby-interaction` entitlement and the matching background mode. Enable the capability on the App ID first. | `android.nearby.watchProfile` | `false` | Declares the watch companion profile permission, for an app that associates with `CompanionProfile.WATCH`. +| `android.nearby.computerProfile` | `false` | The same for `CompanionProfile.COMPUTER`, which Android honours from API 33. +| `android.nearby.glassesProfile` | `false` | The same for `CompanionProfile.GLASSES`, which Android honours from API 34. |=== Everything else is automatic. Referencing a package links its frameworks, 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 be1eb65a024..484de6a0ea8 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 @@ -2568,14 +2568,30 @@ public void usesClassMethod(String cls, String method) { // needs usesPermissionFlags from 33 -- and a flat list cannot say any // of that. // - // android.nearby.watchProfile is a hint rather than something the - // scanner works out: the profile arrives as an enum constant, which - // is a field reference, and Executor.visitFieldInsn is an empty - // override. Defaulted false because REQUEST_COMPANION_PROFILE_WATCH - // is a strong permission to ask for on a guess. + // The profile hints are hints rather than something the scanner + // works out: the profile arrives as an enum constant, which is a + // field reference, and Executor.visitFieldInsn is an empty override. + // Each defaults false because a REQUEST_COMPANION_PROFILE_* is a + // strong permission to ask for on a guess. + // + // All three the portable API exposes have a hint, not only watch: + // AndroidNearbyBackend forwards COMPUTER on API 33 and GLASSES on 34, + // and without the matching permission the platform rejects the + // association before the chooser opens. if (usesNearbyRanging || usesNearbyTransport || usesNearbyCompanion) { - boolean watchProfile = "true".equalsIgnoreCase( - request.getArg("android.nearby.watchProfile", "false")); + StringBuilder profiles = new StringBuilder(); + if ("true".equalsIgnoreCase( + request.getArg("android.nearby.watchProfile", "false"))) { + profiles.append("watch,"); + } + if ("true".equalsIgnoreCase(request.getArg( + "android.nearby.computerProfile", "false"))) { + profiles.append("computer,"); + } + if ("true".equalsIgnoreCase(request.getArg( + "android.nearby.glassesProfile", "false"))) { + profiles.append("glasses,"); + } log("Nearby fragments version " + NearbyManifestFragments.FRAGMENT_VERSION + (usesNearbyRanging ? " ranging" : "") @@ -2584,8 +2600,8 @@ public void usesClassMethod(String cls, String method) { + (usesNearbyPresence ? " presence" : "")); xPermissions = NearbyManifestFragments.inject(xPermissions, usesNearbyRanging, usesNearbyTransport, - usesNearbyCompanion, usesNearbyPresence, watchProfile, - targetSDKVersionInt); + usesNearbyCompanion, usesNearbyPresence, + profiles.toString(), targetSDKVersionInt); String presenceService = NearbyManifestFragments.presenceService(usesNearbyPresence); if (presenceService.length() > 0 diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 95d2f966320..5a3f320730b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -80,7 +80,7 @@ private NearbyManifestFragments() { */ static String inject(String xPermissions, boolean ranging, boolean transport, boolean companion, boolean presence, - boolean watchProfile, int targetSdkVersion) { + String profiles, int targetSdkVersion) { String out = xPermissions == null ? "" : xPermissions; boolean modern = targetSdkVersion >= 31; boolean tiramisu = targetSdkVersion >= 33; @@ -181,16 +181,33 @@ static String inject(String xPermissions, boolean ranging, + "_FROM_BACKGROUND", ""); } } - if (watchProfile) { - // Declared whatever the target SDK is, for the reason - // UWB_RANGING above is: selecting DEVICE_PROFILE_WATCH needs - // this permission on an Android 12 device no matter what the - // app targets, and an app targeting 30 had the association - // rejected there. Older devices ignore it. + // One permission per profile the app says it selects, and all + // three the portable API exposes -- not only WATCH. + // AndroidNearbyBackend forwards COMPUTER on API 33 and GLASSES + // on 34, and without the matching permission the platform + // rejects the association before the chooser opens, which looks + // to the user like nothing happened at all. + // + // Declared whatever the target SDK is, for the reason + // UWB_RANGING above is: selecting a profile needs its permission + // on a device that has the profile no matter what the app + // targets, and an app targeting 30 had the association rejected + // there. Older devices ignore a permission they never heard of. + if (hasProfile(profiles, "watch")) { out = addPermission(out, "android.permission.REQUEST_COMPANION_PROFILE_WATCH", ""); } + if (hasProfile(profiles, "computer")) { + out = addPermission(out, + "android.permission" + + ".REQUEST_COMPANION_PROFILE_COMPUTER", ""); + } + if (hasProfile(profiles, "glasses")) { + out = addPermission(out, + "android.permission" + + ".REQUEST_COMPANION_PROFILE_GLASSES", ""); + } } return out; } @@ -293,6 +310,28 @@ static String widenPermission(String xPermissions, String name, + xPermissions.substring(end + 1); } + /// True when a comma-separated profile list names this profile. + /// + /// Compared on whole entries so "watch" does not match a longer name + /// that merely contains it. + /// + /// @param profiles the comma-separated list, may be null + /// @param profile the profile to look for, lowercase + /// @return whether the list names it + static boolean hasProfile(String profiles, String profile) { + if (profiles == null) { + return false; + } + String[] parts = profiles.split(","); + for (int i = 0; i < parts.length; i++) { + if (parts[i].trim().toLowerCase(java.util.Locale.ROOT) + .equals(profile)) { + return true; + } + } + return false; + } + private static String addFeature(String xPermissions, String name, boolean required) { if (xPermissions.contains("\"" + name + "\"")) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index a98442388c1..187a511e45c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -55,7 +55,7 @@ private static int count(String haystack, String needle) { @Test void rangingPaysForRangingOnly() { String out = NearbyManifestFragments.inject("", true, false, false, - false, false, 34); + false, "", 34); assertTrue(out.contains("android.permission.UWB_RANGING")); assertTrue(out.contains("android:name=\"android.hardware.uwb\"" + " android:required=\"false\"")); @@ -73,10 +73,10 @@ void uwbRangingIsDeclaredWhateverTheTargetSdk() { // and there the runtime request fails unless the manifest declares // this. Older devices ignore a permission they do not know. String legacy = NearbyManifestFragments.inject("", true, false, false, - false, false, 30); + false, "", 30); assertTrue(legacy.contains("android.permission.UWB_RANGING")); String modern = NearbyManifestFragments.inject("", true, false, false, - false, false, 34); + false, "", 34); assertTrue(modern.contains("android.permission.UWB_RANGING")); // The feature stays optional, because that is what keeps the app // installable on a device without the radio. @@ -86,7 +86,7 @@ void uwbRangingIsDeclaredWhateverTheTargetSdk() { @Test void transportCarriesTheAndroid12SplitWithTheLegacyPairCapped() { String out = NearbyManifestFragments.inject("", false, true, false, - false, false, 34); + false, "", 34); assertTrue(out.contains("android:name=\"android.permission.BLUETOOTH\"" + " android:maxSdkVersion=\"30\"")); assertTrue(out.contains( @@ -104,7 +104,7 @@ void transportCarriesTheAndroid12SplitWithTheLegacyPairCapped() { @Test void transportStopsAskingForLocationOnceNearbyWifiExists() { String modern = NearbyManifestFragments.inject("", false, true, false, - false, false, 34); + false, "", 34); assertTrue(modern.contains( "android:name=\"android.permission.NEARBY_WIFI_DEVICES\"" + " android:usesPermissionFlags=\"neverForLocation\"")); @@ -119,7 +119,7 @@ void transportStopsAskingForLocationOnceNearbyWifiExists() { // is still declared, because the app may run on a 13 device and the // runtime asks for what THAT device requires. String older = NearbyManifestFragments.inject("", false, true, false, - false, false, 31); + false, "", 31); assertTrue(older.contains("NEARBY_WIFI_DEVICES")); assertTrue(older.contains( "android:name=\"android.permission.ACCESS_FINE_LOCATION\" />")); @@ -128,7 +128,7 @@ void transportStopsAskingForLocationOnceNearbyWifiExists() { @Test void transportOnALegacyTargetKeepsTheLegacyPairUncapped() { String out = NearbyManifestFragments.inject("", false, true, false, - false, false, 30); + false, "", 30); assertTrue(out.contains( "android:name=\"android.permission.BLUETOOTH\" />")); } @@ -141,7 +141,7 @@ void theSplitPermissionsAreDeclaredEvenForALegacyTarget() { // target-30 app on Android 12 could not ask for these at all. A // device below 31 ignores permissions it has never heard of. String out = NearbyManifestFragments.inject("", false, true, false, - false, false, 30); + false, "", 30); assertTrue(out.contains("BLUETOOTH_SCAN"), out); assertTrue(out.contains("BLUETOOTH_ADVERTISE"), out); assertTrue(out.contains("BLUETOOTH_CONNECT"), out); @@ -152,10 +152,47 @@ void theSplitPermissionsAreDeclaredEvenForALegacyTarget() { "android:name=\"android.permission.BLUETOOTH\" />"), out); } + @Test + void everyProfileTheApiExposesHasItsOwnPermission() { + // AndroidNearbyBackend forwards COMPUTER on API 33 and GLASSES on + // 34, and without the matching permission the platform rejects the + // association before the chooser opens -- which looks to the user + // like nothing happened at all. + String watch = NearbyManifestFragments.inject("", false, false, true, + false, "watch", 34); + assertTrue(watch.contains("REQUEST_COMPANION_PROFILE_WATCH"), watch); + assertFalse(watch.contains("REQUEST_COMPANION_PROFILE_COMPUTER"), + watch); + + String computer = NearbyManifestFragments.inject("", false, false, + true, false, "computer", 34); + assertTrue(computer.contains("REQUEST_COMPANION_PROFILE_COMPUTER"), + computer); + + String glasses = NearbyManifestFragments.inject("", false, false, true, + false, "glasses", 34); + assertTrue(glasses.contains("REQUEST_COMPANION_PROFILE_GLASSES"), + glasses); + + String both = NearbyManifestFragments.inject("", false, false, true, + false, "watch,glasses", 34); + assertTrue(both.contains("REQUEST_COMPANION_PROFILE_WATCH"), both); + assertTrue(both.contains("REQUEST_COMPANION_PROFILE_GLASSES"), both); + } + + @Test + void aProfileNameIsMatchedWholeRatherThanAsASubstring() { + assertFalse(NearbyManifestFragments.hasProfile("watchdog", "watch")); + assertTrue(NearbyManifestFragments.hasProfile(" Watch , glasses", + "watch")); + assertFalse(NearbyManifestFragments.hasProfile(null, "watch")); + assertFalse(NearbyManifestFragments.hasProfile("", "watch")); + } + @Test void associatingWithoutWatchingCostsNoBackgroundPermission() { String out = NearbyManifestFragments.inject("", false, false, true, - false, false, 34); + false, "", 34); assertTrue(out.contains("android.software.companion_device_setup")); // This is the point of tracking presence separately: background // privileges an app never uses are privileges a user is asked about @@ -168,7 +205,7 @@ void associatingWithoutWatchingCostsNoBackgroundPermission() { @Test void watchingEarnsTheBackgroundPermissions() { String out = NearbyManifestFragments.inject("", false, false, true, - true, false, 34); + true, "", 34); assertTrue(out.contains( "android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND")); assertTrue(out.contains( @@ -184,12 +221,12 @@ void theForegroundServiceExemptionArrivesWithApi31NotApi33() { // platform woke its CompanionDeviceService, which is what observing // presence is for. String twelve = NearbyManifestFragments.inject("", false, false, true, - true, false, 31); + true, "", 31); assertTrue(twelve.contains("android.permission.REQUEST_COMPANION" + "_START_FOREGROUND_SERVICES_FROM_BACKGROUND")); // Still absent below the API that has it. String eleven = NearbyManifestFragments.inject("", false, false, true, - true, false, 30); + true, "", 30); assertFalse(eleven.contains( "REQUEST_COMPANION_START_FOREGROUND_SERVICES")); } @@ -197,15 +234,15 @@ void theForegroundServiceExemptionArrivesWithApi31NotApi33() { @Test void theWatchProfilePermissionIsOptInButNotTargetGated() { assertFalse(NearbyManifestFragments.inject("", false, false, true, - false, false, 34) + false, "", 34) .contains("REQUEST_COMPANION_PROFILE_WATCH")); assertTrue(NearbyManifestFragments.inject("", false, false, true, - false, true, 34) + false, "watch", 34) .contains("android.permission.REQUEST_COMPANION_PROFILE_WATCH")); // Selecting DEVICE_PROFILE_WATCH needs this on an Android 12 device // whatever the app targets, so a legacy target must still declare it. assertTrue(NearbyManifestFragments.inject("", false, false, true, - false, true, 30) + false, "watch", 30) .contains("android.permission.REQUEST_COMPANION_PROFILE_WATCH")); } @@ -217,7 +254,7 @@ void nothingIsDeclaredTwiceWhenBluetoothRanFirst() { String afterBluetooth = BluetoothManifestFragments.inject("", true, true, true, false, true, false, 34); String out = NearbyManifestFragments.inject(afterBluetooth, false, - true, false, false, false, 34); + true, false, false, "", 34); assertEquals(1, count(out, "android:name=\"android.permission.BLUETOOTH\"")); assertEquals(1, count(out, @@ -237,7 +274,7 @@ void aQuotedTokenIsWhatSuppressesADuplicate() { String seeded = " \n"; String out = NearbyManifestFragments.inject(seeded, false, true, false, - false, false, 34); + false, "", 34); assertEquals(1, count(out, "android:name=\"android.permission.BLUETOOTH_SCAN\"")); assertEquals(1, count(out, @@ -249,7 +286,7 @@ void aUserDeclaredPermissionIsNotDuplicated() { String seeded = " \n"; String out = NearbyManifestFragments.inject(seeded, true, false, false, - false, false, 34); + false, "", 34); assertEquals(1, count(out, "android.permission.UWB_RANGING")); } @@ -272,7 +309,7 @@ void theServiceElementOnlyExistsForAnAppThatWatches() { @Test void nullInputIsTreatedAsEmpty() { String out = NearbyManifestFragments.inject(null, true, false, false, - false, false, 34); + false, "", 34); assertTrue(out.contains("android.permission.UWB_RANGING")); } @@ -291,7 +328,7 @@ void transportWidensTheLocationCapBluetoothAlreadyDeclared() { "precondition: bluetooth caps it at 30"); String out = NearbyManifestFragments.inject(bluetooth, false, true, - false, false, false, 34); + false, false, "", 34); int at = out.indexOf("ACCESS_FINE_LOCATION"); int elementEnd = out.indexOf('>', at); String element = out.substring(out.lastIndexOf('<', at), elementEnd); @@ -310,7 +347,7 @@ void transportBelowTiramisuRemovesTheCapAltogether() { String bluetooth = BluetoothManifestFragments.inject("", true, false, false, false, true, false, 32); String out = NearbyManifestFragments.inject(bluetooth, false, true, - false, false, false, 32); + false, false, "", 32); int at = out.indexOf("ACCESS_FINE_LOCATION"); String element = out.substring(out.lastIndexOf('<', at), out.indexOf('>', at)); @@ -325,7 +362,7 @@ void coarseLocationIsDeclaredAlongsideFineWithTheSameReach() { // refused when coarse is not declared, so the grant Nearby // Connections needs on 12 and 12L never arrived. String out = NearbyManifestFragments.inject("", false, true, false, - false, false, 34); + false, "", 34); assertTrue(out.contains("android:name=\"android.permission" + ".ACCESS_COARSE_LOCATION\" android:maxSdkVersion=\"32\""), out); @@ -337,7 +374,7 @@ void coarseLocationIsDeclaredAlongsideFineWithTheSameReach() { @Test void coarseLocationIsUncappedBelowATiramisuTarget() { String out = NearbyManifestFragments.inject("", false, true, false, - false, false, 31); + false, "", 31); assertTrue(out.contains("android:name=\"android.permission" + ".ACCESS_COARSE_LOCATION\" />"), out); } @@ -347,7 +384,7 @@ void aCapThatAlreadyReachesFarEnoughIsLeftAlone() { String seeded = " \n"; String out = NearbyManifestFragments.inject(seeded, false, true, false, - false, false, 34); + false, "", 34); assertTrue(out.contains("android:maxSdkVersion=\"33\""), "a wider cap is not narrowed: " + out); } @@ -356,6 +393,6 @@ void aCapThatAlreadyReachesFarEnoughIsLeftAlone() { void usingNoneOfItChangesNothing() { String seeded = " \n"; assertEquals(seeded, NearbyManifestFragments.inject(seeded, false, - false, false, false, false, 34)); + false, false, false, "", 34)); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 1806e8bbf5b..e33e9a1dd14 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -555,6 +555,50 @@ void observingSomethingThatIsNotAssociatedIsRefused() { // transport // ------------------------------------------------------------------ + @Test + void cancellingAPayloadInFlightReportsCanceledAndSendsNothing() { + // sendPayload is delayed like everything else here, so an app really + // can cancel while a send is in flight -- and the simulator used to + // ignore it, report SUCCESS and echo the payload anyway, which made + // it the one place the public cancellation contract was never + // exercised. + final List progress = + new ArrayList(); + final List received = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void payloadProgress(Endpoint e, PayloadTransferUpdate u) { + progress.add(u); + } + + @Override + public void payloadReceived(Endpoint e, Payload p) { + received.add(p); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + value(NearbyTransport.requestConnection(e, "me")); + + List queue = new ArrayList(); + bridge.deferForTest(queue); + Payload p = Payload.fromBytes(new byte[] {1, 2, 3}); + NearbyTransport.send(e, p); + NearbyTransport.cancel(p.getId()); + drain(queue); + + assertTrue(received.isEmpty(), + "a cancelled payload must not be delivered: " + received); + assertEquals(1, progress.size()); + assertSame(PayloadStatus.CANCELED, progress.get(0).getStatus()); + } + @Test void stoppingBeforeDiscoveryStartsReportsNoEndpoints() { // The queued start had no idea discovery had been stopped, so a From 56ccc419163086ababa8720f13dcf058bc519044 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:29:59 +0300 Subject: [PATCH 31/94] Address the nineteenth nearby review round - Android stop() stops advertising and discovery, not just the endpoints. Those are three independent operations in Nearby Connections, and stopAllEndpoints only disconnects peers -- so an app that closed its feature UI carried on broadcasting and scanning, burning the radio and still taking endpoint and connection callbacks, while the public stop() documents exactly the opposite. - A simulated send with no connected recipient fails instead of resolving with nothing in it. Answering ok and then skipping every recipient left the caller holding a resolved resource and waiting for a terminal payloadProgress that could never come, which is the state transfer UI hangs on. It now fails with PEER_UNAVAILABLE, as the iOS path already did. --- .../impl/nearby/LocalNearbyBridge.java | 19 +++++++++++++++++ .../nearby/AndroidNearbyTransport.java | 8 +++++++ .../com/codename1/nearby/LocalNearbyTest.java | 21 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 845c9d44f91..eb221704ff4 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -646,6 +646,25 @@ public void sendPayload(final int requestId, final String[] endpointIds, answer(new Runnable() { @Override public void run() { + // Nobody to send to is a failure, not a success with nothing + // in it. Answering ok and then skipping every recipient left + // the caller holding a resolved resource and waiting for a + // terminal payloadProgress that could never come, which is + // exactly the state transfer UI hangs on. + boolean any = false; + for (String endpointId : endpointIds) { + if (findEndpoint(endpointId) != null + && connected.contains(endpointId)) { + any = true; + break; + } + } + if (!any) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + "none of those endpoints is connected"); + return; + } NearbyTransport.deliverRequestOk(requestId); boolean cancelled = cancelledPayloads.remove( Integer.valueOf(payloadId)); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 311686c143d..871905cb275 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -378,6 +378,14 @@ public void disconnect(String endpointId) { } public void stopAllTransport() { + // All three, because they are three independent operations. + // stopAllEndpoints disconnects peers and leaves advertising and + // discovery running, so an app that closed its feature UI carried on + // broadcasting and scanning -- burning the radio and still taking + // endpoint and connection callbacks -- while the public stop() + // documents exactly the opposite. + client().stopAdvertising(); + client().stopDiscovery(); client().stopAllEndpoints(); endpointNames.clear(); endpointServices.clear(); diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index e33e9a1dd14..152d6053549 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -555,6 +555,27 @@ void observingSomethingThatIsNotAssociatedIsRefused() { // transport // ------------------------------------------------------------------ + @Test + void sendingToNobodyFailsRatherThanResolvingWithNothingInIt() { + // Answering ok and then skipping every recipient left the caller + // holding a resolved resource and waiting for a terminal + // payloadProgress that could never come -- the state transfer UI + // hangs on. + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + assertNotNull(e); + // Discovered but never connected. + assertFailedWith(NearbyError.PEER_UNAVAILABLE, + NearbyTransport.send(e, Payload.fromBytes(new byte[] {1}))); + } + @Test void cancellingAPayloadInFlightReportsCanceledAndSendsNothing() { // sendPayload is delayed like everything else here, so an app really From a729219a47ceeec39802d5c11f6bca79ec7c9e4b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:39:28 +0300 Subject: [PATCH 32/94] Address the twentieth nearby review round - Guard checkSelfPermission below API 23. Both add() helpers called it unguarded, and it does not exist before 23 -- so it threw NoSuchMethodError rather than answering. That is reachable now: the transport's minimum SDK is 21, which this branch set itself last round. Below 23 nothing is ever outstanding anyway, because permissions are granted at install time. NearbyPermissions.allGranted and requestTogether already had the guard; these two did not. - Weak-link the nearby frameworks tvOS does not have. The tvOS slice inherits the iOS link phase, so a framework linked for com.codename1.nearby.ranging or .companion fails the tvOS archive while resolving it. CN1Nearby.m already compiles both halves out through the TARGET_OS_TV undefs, so nothing there calls into them -- this was only the link phase, which is why the tvOS clang check never caught it. Which frameworks was measured against the tvOS SDK rather than mirrored from the watch list: tvOS has no NearbyInteraction and no AccessorySetupKit, and it does ship MultipeerConnectivity, so that one is deliberately left out. TvNativeBuilderNearbyTest pins both halves, because weak-linking a framework the platform has would only obscure the distinction. --- .../android/nearby/AndroidNearbyBackend.java | 9 +++ .../nearby/AndroidNearbyTransport.java | 9 +++ .../codename1/builders/TvNativeBuilder.java | 15 ++++- .../builders/TvNativeBuilderNearbyTest.java | 63 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 1370d06fc53..932057aa36a 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -192,7 +192,16 @@ public void requestPermissions(int requestId, int permissionBits) { } /// Adds a permission the app has not already been granted. + /// + /// Below API 23 nothing is ever outstanding: permissions are granted at + /// install time, and Context.checkSelfPermission does not exist there -- + /// calling it threw NoSuchMethodError rather than answering, which a + /// transport app on Android 5.0 or 5.1 can reach, since the transport's + /// minimum is 21. private void add(ArrayList perms, String permission) { + if (Build.VERSION.SDK_INT < 23) { + return; + } if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { perms.add(permission); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 871905cb275..d6f9c657192 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -170,7 +170,16 @@ public void requestPermissions(int requestId, int permissionBits) { } /// Adds a permission the app has not already been granted. + /// + /// Below API 23 nothing is ever outstanding: permissions are granted at + /// install time, and Context.checkSelfPermission does not exist there -- + /// calling it threw NoSuchMethodError rather than answering, which a + /// transport app on Android 5.0 or 5.1 can reach, since the transport's + /// minimum is 21. private void add(ArrayList perms, String permission) { + if (Build.VERSION.SDK_INT < 23) { + return; + } if (context.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { perms.add(permission); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java index eb54f34d443..16dd92fdde8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java @@ -109,7 +109,20 @@ class TvNativeBuilder { // HealthKit does not exist on tvOS at all. The iOS slice links it when the app // references com.codename1.health, so weak-link it here or the tvOS slice fails // to link. CN1Health.m additionally compiles itself out via TARGET_OS_TV. - + "HealthKit.framework"; + + "HealthKit.framework;" + // NearbyInteraction and AccessorySetupKit are absent from the + // tvOS SDK; the iOS slice links them when the app references + // com.codename1.nearby.ranging or .companion, so weak-link them + // here or the tvOS slice fails while resolving the framework. + // CN1Nearby.m already compiles both halves out via the + // TARGET_OS_TV undefs in CodenameOne_GLViewController.h, so + // nothing on the tvOS slice calls into them. + // + // MultipeerConnectivity is deliberately NOT here, and that was + // measured rather than assumed: the tvOS SDK ships it, so the + // transport links normally and weak-linking would only obscure + // that. Same distinction the CoreSpotlight note below draws. + + "NearbyInteraction.framework;AccessorySetupKit.framework"; // CoreSpotlight is deliberately NOT in this list, although the watch list carries it. // // The two platforms differ, and it was measured rather than reasoned about. On the diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java new file mode 100644 index 00000000000..1efb2597c38 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java @@ -0,0 +1,63 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The tvOS slice inherits the iOS link phase, so a framework the iOS slice + * links for {@code com.codename1.nearby} has to be weak-linked here or the + * tvOS archive fails while resolving it. + * + *

Which ones is a measured fact, not a symmetry with the watch list: on the + * Xcode 26.3 SDKs tvOS has no NearbyInteraction and no AccessorySetupKit, and + * does ship MultipeerConnectivity. Weak-linking the one it has would only + * obscure that, so this pins both halves of the distinction.

+ */ +class TvNativeBuilderNearbyTest { + + private static String optionalFrameworks() throws Exception { + Field f = TvNativeBuilder.class + .getDeclaredField("TV_OPTIONAL_FRAMEWORKS"); + f.setAccessible(true); + return (String) f.get(null); + } + + @Test + void theFrameworksTvosLacksAreWeakLinked() throws Exception { + String list = optionalFrameworks(); + assertTrue(list.contains("NearbyInteraction.framework"), list); + assertTrue(list.contains("AccessorySetupKit.framework"), list); + } + + @Test + void theFrameworkTvosShipsIsNotWeakLinked() throws Exception { + String list = optionalFrameworks(); + assertFalse(list.contains("MultipeerConnectivity.framework"), list); + } +} From 00f89ce2429a9b3058c3e138f2e0ac3442a105b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:54:51 +0300 Subject: [PATCH 33/94] Address the twenty-first nearby review round - Accepting a simulated incoming request reports the connection. On a real platform the connection callback supplies that event and here nothing did, so accept() added the endpoint, answered the request and left the listener waiting for the outcome its own javadoc promises. - Advertising, discovery and connections get their own generation counters. The single counter this branch added last round meant stopAdvertising() failed an unrelated discovery that was still starting, and an in-flight connection request with it. stopAllTransport bumps all three, because stop() really does end everything. PMD then reported advertiseGeneration as an unused private field, which was true and was the point: startAdvertising answered inline and never captured a generation, so advertising still had the race discovery had just been given a guard for. It now fails a start that was stopped before it was answered, which is both the fix and what makes the field read. - Android removes its payload mappings when the handoff fails. Nearby rejected the send, so no transfer update was ever going to arrive to clear them: every failed send left a pair of entries behind for the life of the process, with cancelPayload scanning stale payloads. --- .../impl/nearby/LocalNearbyBridge.java | 66 +++++++++++++++---- .../nearby/AndroidNearbyTransport.java | 9 +++ .../com/codename1/nearby/LocalNearbyTest.java | 65 ++++++++++++++++++ 3 files changed, 129 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index eb221704ff4..311b7cb2c55 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -110,12 +110,20 @@ public class LocalNearbyBridge implements NearbyBridge { /// passed no strategy is given. private int advertiseStrategy = TransportStrategy.CLUSTER.ordinal(); private int discoverStrategy = TransportStrategy.CLUSTER.ordinal(); - /// Bumped by every stop, so work queued by an earlier run of the - /// transport can tell that it is answering for a transport that has - /// since been stopped. Nothing in the simulation completes inline, which - /// is the point -- and that means a delayed acceptance really can outlive - /// the stop() that was supposed to have ended it. + /// Bumped when connections are dropped, so work queued by an earlier run + /// of the transport can tell that it is answering for a transport that + /// has since been stopped. Nothing in the simulation completes inline, + /// which is the point -- and that means a delayed acceptance really can + /// outlive the stop() that was supposed to have ended it. + /// + /// One counter per operation, because advertising, discovery and + /// connections are independent. A single shared counter meant + /// stopAdvertising() invalidated an unrelated discovery that was still + /// starting, and an in-flight connection request with it -- failing calls + /// the app never asked to stop. private int transportGeneration; + private int discoverGeneration; + private int advertiseGeneration; /// The id of the most recent acceptConnection, so a test can answer it /// the way a port would. /// @@ -496,13 +504,28 @@ public void startAdvertising(final int requestId, String serviceId, String localName, int strategy) { advertising = true; advertiseStrategy = strategy; - answerOk(requestId); + final int generation = advertiseGeneration; + answer(new Runnable() { + @Override + public void run() { + // Stopped before the start was answered, the same race + // discovery has: the answer is queued, and a stop can land + // in front of it. + if (!advertising || generation != advertiseGeneration) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "advertising was stopped before it started"); + return; + } + NearbyTransport.deliverRequestOk(requestId); + } + }); } @Override public void stopAdvertising() { advertising = false; - transportGeneration++; + advertiseGeneration++; } @Override @@ -510,7 +533,7 @@ public void startDiscovery(final int requestId, final String serviceId, int strategy) { discovering = true; discoverStrategy = strategy; - final int generation = transportGeneration; + final int generation = discoverGeneration; answer(new Runnable() { @Override public void run() { @@ -518,7 +541,7 @@ public void run() { // discovery to report into. Reporting endpoints anyway had // the stopped simulator announcing peers nobody had asked // for, which is not what a device does. - if (!discovering || generation != transportGeneration) { + if (!discovering || generation != discoverGeneration) { NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_INVALIDATED.ordinal(), "discovery was stopped before it started"); @@ -537,8 +560,9 @@ public void run() { public void stopDiscovery() { discovering = false; // Bumped so a start still queued for this run of discovery can tell - // that it has been stopped, the same way stopAllTransport does. - transportGeneration++; + // that it has been stopped -- and only THIS counter, so an unrelated + // advertise or connection in flight is left alone. + discoverGeneration++; } @Override @@ -620,6 +644,22 @@ public void acceptConnection(final int requestId, String endpointId) { connected.add(endpointId); } answerOk(requestId); + // The lifecycle event, which on a real platform arrives from the + // connection callback and here had no other source. accept() + // documents its outcome as connected or connectionFailed, and + // answering the request alone left a listener waiting for an event + // that was never going to come. + final String accepted = endpointId; + answer(new Runnable() { + @Override + public void run() { + SimEndpoint e = findEndpoint(accepted); + if (e != null && connected.contains(accepted)) { + NearbyTransport.deliverConnectionResult(e.encode(), true, + 0, null); + } + } + }); } @Override @@ -718,7 +758,11 @@ public void disconnect(String endpointId) { public void stopAllTransport() { advertising = false; discovering = false; + // All three: stop() ends every operation, so anything queued for any + // of them is answering for a transport that no longer exists. transportGeneration++; + discoverGeneration++; + advertiseGeneration++; cancelledPayloads.clear(); List doomed = new ArrayList(connected); connected.clear(); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index d6f9c657192..5376d5cfa18 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -356,6 +356,7 @@ public void sendPayload(final int requestId, String[] endpointIds, payloadRecipients.put(Long.valueOf(payload.getId()), Integer.valueOf(endpointIds.length)); java.util.List targets = java.util.Arrays.asList(endpointIds); + final Long platformKey = Long.valueOf(payload.getId()); client().sendPayload(targets, payload) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { @@ -364,6 +365,14 @@ public void onSuccess(Void unused) { }) .addOnFailureListener(new OnFailureListener() { public void onFailure(Exception e) { + // The mappings go with the failure. Nearby rejected + // the handoff, so no transfer update will ever arrive + // to clear them -- and every failed send left a pair + // of entries behind for the life of the process, with + // cancelPayload scanning stale payloads for good + // measure. + payloadIds.remove(platformKey); + payloadRecipients.remove(platformKey); NearbyTransport.deliverRequestFailed(requestId, NearbyError.IO_ERROR.ordinal(), e.getMessage()); } diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 152d6053549..6acb2043164 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -25,6 +25,7 @@ import com.codename1.impl.nearby.LocalNearbyBridge; import com.codename1.util.AsyncResource; import com.codename1.impl.nearby.NearbyRequests; +import com.codename1.impl.nearby.NearbyWire; import com.codename1.impl.nearby.SyntheticNearby; import com.codename1.nearby.companion.AssociationRequest; import com.codename1.nearby.companion.CompanionDevice; @@ -818,6 +819,70 @@ public void connectionFailed(Endpoint e, NearbyException error) { assertSame(NearbyError.PEER_UNAVAILABLE, failures.get(0).getError()); } + @Test + void acceptingAnIncomingRequestReportsTheConnection() { + // accept() documents its outcome as connected or connectionFailed. On + // a real platform the connection callback supplies it; here nothing + // did, so a listener waited for an event that was never coming. + final AtomicReference held = + new AtomicReference(); + final List connected = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void connectionRequested(IncomingConnection request) { + held.set(request); + } + + @Override + public void connected(Endpoint e) { + connected.add(e); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + assertNotNull(e); + NearbyTransport.deliverConnectionRequested( + NearbyWire.encodeEndpoint(e), "9876"); + held.get().accept(); + assertEquals(1, connected.size()); + assertEquals(e.getId(), connected.get(0).getId()); + } + + @Test + void stoppingBeforeAdvertisingStartsFailsThatStart() { + // The same race discovery has: the answer is queued, and a stop can + // land in front of it. + List queue = new ArrayList(); + bridge.deferForTest(queue); + AsyncResource pending = NearbyTransport.startAdvertising( + "chat", "me", TransportStrategy.CLUSTER); + NearbyTransport.stopAdvertising(); + drain(queue); + assertFailedWith(NearbyError.SESSION_INVALIDATED, pending); + } + + @Test + void stoppingAdvertisingLeavesAStartingDiscoveryAlone() { + // Advertising, discovery and connections are independent. One shared + // generation counter meant stopAdvertising() failed an unrelated + // discovery that was still starting. + List queue = new ArrayList(); + bridge.deferForTest(queue); + AsyncResource discovery = NearbyTransport.startDiscovery( + "chat", TransportStrategy.CLUSTER); + NearbyTransport.stopAdvertising(); + drain(queue); + // value() asserts it succeeded, naming the failure when it did not. + assertTrue(value(discovery).booleanValue(), + "an unrelated stop must not fail discovery"); + } + @Test void aConnectionRequestNobodyHeardIsRejectedRatherThanLeftHanging() { // With no listener at all nobody will ever answer, and the far side From 1cc8af0800383c94df659c654f7e84358136bf4c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:05:30 +0300 Subject: [PATCH 34/94] Address the twenty-second nearby review round - iOS stops reporting association-set events as presence. AccessorySetupKit reports an accessory entering or leaving the app's SET, which is not the same event as it coming into or going out of RANGE -- and that difference is why startObservingPresence answers false on iOS and the documentation calls presence Android-only. Forwarding it reported an accessory sitting in a drawer as present the moment it was associated, and disassociation as walking out of range; the presence parking this branch added in an earlier round made it worse, because a false sighting could be replayed to a listener registered much later. The association result is encoded present:NO for the same reason: choosing an accessory in a picker says the user picked it, not that it is nearby, and this port has nothing that would ever correct the claim. handleEvent: and the presenceChanged callback had no honest source left, so both are removed rather than kept as dead weight. deliverPresenceChanged stays where presence is real -- Android and the simulator. - An ambiguous iOS association id is refused rather than guessed. An accessory with no bluetoothIdentifier has no stable identifier in that API, so the SSID and display-name fallbacks can collide: two Wi-Fi accessories on one network, or two sharing a name. accessoryForId now matches only when exactly one accessory derives the id, so disassociate reports no such association instead of forgetting a different one. - Both builders gate on the resolved Gradle version rather than the android.useGradle8 hint, which does not imply an old toolchain -- newFirebaseMessaging selects the modern one on its own. This copy carried the same redundant term even though its gradleVersionInt comes from probing the gradle executable. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 54 +++++++++++-------- .../impl/ios/IOSNearbyCallbacks.java | 9 ---- .../builders/AndroidGradleBuilder.java | 22 ++++---- 3 files changed, 45 insertions(+), 40 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 8e34efe8369..2a60ebd1584 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1282,29 +1282,24 @@ - (ASAccessory *)accessoryForId:(NSString *)associationId { if (self.session == nil || associationId == nil) { return nil; } + // Exactly one, or none. + // + // An accessory with no bluetoothIdentifier has no stable identifier in + // this API at all -- SSID and display name are the only handles, and two + // Wi-Fi accessories on one network, or two accessories sharing a name, + // derive the same id. Returning the first match let disassociate remove + // whichever happened to be encountered first, which is worse than not + // finding it: the app asked to forget one accessory and forgot another. + ASAccessory *match = nil; for (ASAccessory *a in self.session.accessories) { if ([cn1nbAccessoryId(a) isEqualToString:associationId]) { - return a; - } - } - return nil; -} - -- (void)handleEvent:(ASAccessoryEvent *)event { - @autoreleasepool { - if (event.accessory == nil) { - return; - } - BOOL added = event.eventType == ASAccessoryEventTypeAccessoryAdded; - BOOL removed = event.eventType == ASAccessoryEventTypeAccessoryRemoved; - if (!added && !removed) { - return; + if (match != nil) { + return nil; + } + match = a; } - com_codename1_impl_ios_IOSNearbyCallbacks_presenceChanged___java_lang_String_boolean( - getThreadLocalData(), - cn1nbJString([self encode:event.accessory present:added]), - added ? JAVA_TRUE : JAVA_FALSE); } + return match; } - (void)activate { @@ -1313,10 +1308,20 @@ - (void)activate { } self.activated = YES; self.session = [[[ASAccessorySession alloc] init] autorelease]; - CN1NearbyCompanion *weakSelf = self; + // The event handler is required to activate the session and is + // deliberately empty. + // + // AccessorySetupKit reports an accessory entering or leaving the app's + // SET, which is not the same event as it coming into or going out of + // RANGE -- and that difference is why startObservingPresence answers + // false on iOS and the public documentation calls presence Android-only. + // Forwarding these as presence reported an accessory sitting in a drawer + // as present the moment it was associated, and reported disassociation as + // walking out of range. Nothing else needs them either: the association + // is answered from the picker completion and getAssociations reads the + // set directly. [self.session activateWithQueue:dispatch_get_main_queue() eventHandler:^(ASAccessoryEvent *event) { - [weakSelf handleEvent:event]; }]; } @@ -1773,10 +1778,15 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan @"the picker returned no accessory"); return; } + // present:NO. Associating an accessory says the user + // chose it, not that it is in range -- and this port + // reports no presence at all, so claiming YES here was + // the one place a CompanionDevice arrived on iOS + // asserting something nothing would ever correct. com_codename1_impl_ios_IOSNearbyCallbacks_associated___int_java_lang_String( getThreadLocalData(), requestId, cn1nbJString([companion encode:picked - present:YES])); + present:NO])); } }]; return; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java index 0ac537b8826..8f7a78fd49c 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java @@ -67,7 +67,6 @@ final class IOSNearbyCallbacks { associated(0, null); disassociated(0); companionFailed(0, 0, null); - presenceChanged(null, false); transportOk(0); transportFailed(0, 0, null); endpointFound(null, false); @@ -227,14 +226,6 @@ static void companionFailed(int requestId, int errorOrdinal, CompanionDevices.deliverRequestFailed(requestId, errorOrdinal, message); } - /// Called from native when an observed accessory comes or goes. - static void presenceChanged(String encodedDevice, boolean present) { - if (dceGuard) { - return; - } - CompanionDevices.deliverPresenceChanged(encodedDevice, present); - } - /// Called from native when a transport request succeeds. static void transportOk(int requestId) { if (dceGuard) { 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 484de6a0ea8..87298f99bf9 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 @@ -2669,13 +2669,17 @@ public void usesClassMethod(String cls, String method) { // legacy toolchain, is handed a DSL it does not have. Refused // here rather than left to fail during Gradle evaluation with a // message that names none of this. - if (!useGradle8 || gradleVersionInt < 8) { + // The Gradle that will actually run, not the hint that usually + // selects it. android.useGradle8=false does not always mean an + // old toolchain -- android.newFirebaseMessaging selects the + // modern one on its own -- so testing the hint rejected a + // configuration whose plugin was perfectly capable. + if (gradleVersionInt < 8) { throw new BuildException( "com.codename1.nearby needs to compile against" + " Android SDK 33, which the Android Gradle plugin" - + " for Gradle " + gradleVersion - + " (android.useGradle8=" + useGradle8 + ") predates." - + " Set android.useGradle8=true and leave" + + " for Gradle " + gradleVersion + " predates. Set" + + " android.useGradle8=true and leave" + " android.gradleVersion unset to build a nearby" + " app."); } @@ -2700,15 +2704,15 @@ public void usesClassMethod(String cls, String method) { // copy selects the plugin by exact Gradle version and has to // test for the modern pairing instead; the conditions differ // because the selections do. - if (!useGradle8 || gradleVersionInt < 8) { + if (gradleVersionInt < 8) { throw new BuildException( "com.codename1.nearby.ranging needs androidx.core.uwb," + " whose Android Gradle plugin floor is 8.9.1, but" + " this build would use Gradle " + gradleVersion - + " (android.useGradle8=" + useGradle8 + ") and an" - + " older plugin with it. Set android.useGradle8=true" - + " and leave android.gradleVersion unset to build a" - + " ranging app."); + + " and an older plugin with it. Set" + + " android.useGradle8=true and leave" + + " android.gradleVersion unset to build a ranging" + + " app."); } } From 2baf4ee9bf338d0774c555317677724b35051a58 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:15:05 +0300 Subject: [PATCH 35/94] Address the twenty-third nearby review round - The iOS ranging session registry is locked. NISession delivers on a queue per session, so two peers invalidating at once -- or a stop() landing while an invalidation runs -- had concurrent readers, writers and removals on an NSMutableDictionary, which is not thread-safe. This is the ranging half of the same problem the transport dictionaries had a few rounds ago; fixing one family and leaving the other was the mistake. The lock is a dispatch_once object rather than the dictionary itself, which is created lazily and would be nil to lock on before the first session. - A failed UWB start no longer strands the facade session. The deferred start this branch added made the error consumer remove the backend session, but Ranging.deliverRequestFailed deliberately leaves the Java session open and retryable -- so the retry the facade invites answered "no such session" for a session isClosed() still reported as open. The scope is still valid and only that subscription failed, so the session stays; the removal happens on a genuine invalidation. - Peer names are truncated by UTF-8 BYTES on a character boundary. MCPeerID accepts 63 bytes and RAISES on more -- a crash, not an error -- and cutting at twenty UTF-16 units leaves about eighty bytes for emoji and can split a surrogate pair. A live crash path for anyone whose device name is not ASCII. Both construction paths now walk back by composed character sequences until the byte count fits. --- .../android/nearby/AndroidUwbRanging.java | 21 ++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 74 +++++++++++++++++-- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index 7651012d14b..f4cb4af9a81 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -434,15 +434,24 @@ public void accept(Throwable error) { // It never started, so the caller is told // that rather than being told it started and // then invalidated. + // + // The backend session STAYS. A failed start + // leaves the facade session open and + // retryable -- Ranging.deliverRequestFailed + // only clears the in-progress flag -- so + // dropping it here meant the retry the facade + // invites answered "no such session" for a + // session isClosed() still reported as open. + // The scope is still valid; only this + // subscription failed. fail(pending, NearbyError.SESSION_FAILED, message(error)); - } else { - RangingSession.deliverInvalidated( - session.handle, - NearbyError.SESSION_INVALIDATED - .ordinal(), - message(error)); + return; } + RangingSession.deliverInvalidated( + session.handle, + NearbyError.SESSION_INVALIDATED.ordinal(), + message(error)); sessions.remove(Integer.valueOf(session.handle)); } }); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 2a60ebd1584..94c398ed6b5 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -199,8 +199,27 @@ - (void)settleStarted; @end static NSMutableDictionary *cn1nbSessions = nil; +/// Guards cn1nbSessions. +/// +/// NISession delivers on a queue per session, so two peers invalidating at +/// once -- or an app calling stop() while an invalidation is running -- had +/// concurrent readers, writers and removals on a dictionary that is not +/// thread-safe. A separate object rather than the dictionary itself, because +/// the dictionary is created lazily and there would be nothing to lock on +/// before the first session. +static NSObject *cn1nbSessionsLock = nil; + +/// Creates the lock, on the one thread that can be first: every entry point +/// below reaches this before touching the registry. +static void cn1nbSessionsLockInit(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + cn1nbSessionsLock = [[NSObject alloc] init]; + }); +} static void cn1nbSessionsInit(void) { + cn1nbSessionsLockInit(); if (cn1nbSessions == nil) { cn1nbSessions = [[NSMutableDictionary alloc] init]; } @@ -337,7 +356,11 @@ - (void)session:(NISession *)session didInvalidateWithError:(NSError *)error { com_codename1_impl_ios_IOSNearbyCallbacks_sessionInvalidated___int_int_java_lang_String( getThreadLocalData(), handle, code, cn1nbJString([error localizedDescription])); - [cn1nbSessions removeObjectForKey:[NSNumber numberWithInt:handle]]; + cn1nbSessionsLockInit(); + @synchronized (cn1nbSessionsLock) { + [cn1nbSessions removeObjectForKey: + [NSNumber numberWithInt:handle]]; + } } } @@ -361,7 +384,9 @@ - (void)session:(NISession *)session static CN1NearbyRangingSession *cn1nbSessionFor(int handle) API_AVAILABLE(ios(14.0)) { cn1nbSessionsInit(); - return [cn1nbSessions objectForKey:[NSNumber numberWithInt:handle]]; + @synchronized (cn1nbSessionsLock) { + return [cn1nbSessions objectForKey:[NSNumber numberWithInt:handle]]; + } } /// Answers a peer-ranging start once the session has had its chance to fail. @@ -495,6 +520,35 @@ @interface CN1NearbyTransport : NSObject 0) { + // rangeOfComposedCharacterSequenceAtIndex keeps the cut off the + // middle of a surrogate pair or a combining sequence. + NSRange last = [name rangeOfComposedCharacterSequenceAtIndex:end - 1]; + end = last.location; + NSString *candidate = [name substringToIndex:end]; + if ([candidate lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + <= 63) { + return [candidate length] == 0 ? @"Codename One" : candidate; + } + } + return @"Codename One"; +} + static NSString *cn1nbServiceType(NSString *serviceId) { NSMutableString *out = [NSMutableString stringWithCapacity:15]; NSString *lower = [serviceId lowercaseString]; @@ -1099,7 +1153,7 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { // MCPeerID rejects a display name longer than 63 UTF-8 bytes. if (wanted != nil && [wanted lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) { - wanted = [wanted substringToIndex:20]; + wanted = cn1nbPeerName(wanted); } if (t.localPeer != nil && wanted != nil && ![t.localPeer.displayName isEqualToString:wanted] @@ -1140,7 +1194,7 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { NSString *name = wanted != nil ? wanted : [[UIDevice currentDevice] name]; if ([name lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) { - name = [name substringToIndex:20]; + name = cn1nbPeerName(name); } t.localPeer = [[[MCPeerID alloc] initWithDisplayName:name] autorelease]; @@ -1497,8 +1551,10 @@ void com_codename1_impl_ios_IOSNative_nearbyPrepareSession___int_int_boolean( [err localizedDescription]); return; } - [cn1nbSessions setObject:entry - forKey:[NSNumber numberWithInt:sessionHandle]]; + @synchronized (cn1nbSessionsLock) { + [cn1nbSessions setObject:entry + forKey:[NSNumber numberWithInt:sessionHandle]]; + } com_codename1_impl_ios_IOSNearbyCallbacks_sessionPrepared___int_int_boolean_byte_1ARRAY( CN1_THREAD_STATE_PASS_ARG requestId, sessionHandle, controller, cn1nbJBytes(archived)); @@ -1629,8 +1685,10 @@ void com_codename1_impl_ios_IOSNative_nearbyStopSession___int( // Cleared before invalidate so the delegate callback that // invalidation triggers finds nothing left to report -- the // app asked for this and does not need to be told. - [cn1nbSessions removeObjectForKey: - [NSNumber numberWithInt:sessionHandle]]; + @synchronized (cn1nbSessionsLock) { + [cn1nbSessions removeObjectForKey: + [NSNumber numberWithInt:sessionHandle]]; + } entry.session.delegate = nil; [entry.session invalidate]; } From cce16177886ab7159cc02823d5b1a7e5cd60d07e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:30:55 +0300 Subject: [PATCH 36/94] Address the twenty-fourth nearby review round - The companion profile round-trips. join() hardcoded ordinal 0, so every association came back GENERIC even though AssociationInfo .getDeviceProfile reports the one it was made under from API 33 -- contradicting CompanionDevice.getProfile and leaving an app unable to tell its profile-specific companions apart. CN1CompanionDeviceService shares the backend's mapping now, so a presence event cannot contradict the association the app already holds. Fixing it surfaced a build break this branch had introduced on its own: profileFor named AssociationRequest.DEVICE_PROFILE_GLASSES, which is API 34, while the nearby compile-SDK floor allows exactly 33 -- so an app built against 33 could not compile the excluded package at all. The profile names are compile-time String constants with stable role-name values, so the literals are what the constant would have inlined and they work at every level. The package now compiles against android-33 and android-35, both checked. - The presence replay keeps its order. Clearing the backlog under the lock was not enough: an event arriving after the lock was released but before the replay finished queueing saw an empty backlog and dispatched straight away, so a parked appearance followed by a live disappearance could reach the listener in the wrong order and leave it holding the wrong final state. A replaying marker keeps everything parking until the drain loop finds nothing left. - The iOS picker is answered with the accessory it ADDED, not the last in the array -- the same defect the Android association path had, and the accessories array documents no order either. The set is snapshotted before the picker opens. --- .../nearby/companion/CompanionDevices.java | 53 ++++++++++--- .../android/nearby/AndroidNearbyBackend.java | 75 ++++++++++++++++--- .../nearby/CN1CompanionDeviceService.java | 7 +- Ports/iOSPort/nativeSources/CN1Nearby.m | 28 ++++++- 4 files changed, 135 insertions(+), 28 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index f227098d14d..998b019bd72 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -97,6 +97,16 @@ public final class CompanionDevices { /// not accumulate events forever; the oldest is dropped first, because the /// most recent sighting is the one worth reporting. private static final int MAX_PENDING_PRESENCE = 64; + /// True while a replay is handing the backlog to the EDT. + /// + /// Clearing the backlog under the lock is not enough on its own: an event + /// arriving after the lock is released but before the replay has finished + /// queueing sees an empty backlog, dispatches straight away, and can land + /// on the EDT ahead of parked events that are older than it. A parked + /// appearance followed by a live disappearance then arrived in the wrong + /// order and left the listener holding the wrong final state. While this + /// is set, everything parks and the replay loop picks it up. + private static boolean replayingPresence; /// One parked presence event. Static so it holds no implicit reference to /// anything but the device it carries. @@ -273,21 +283,38 @@ public static void addPresenceListener(PresenceListener l) { if (l == null) { return; } - List replay = null; synchronized (LISTENERS) { LISTENERS.add(l); - if (!PENDING_PRESENCE.isEmpty()) { - // Taking the backlog under the same monitor that - // deliverPresenceChanged parks into is what keeps the order - // right: an event arriving between the registration and the - // replay finds the queue still non-empty and parks behind the - // backlog rather than overtaking it on the EDT. - replay = new ArrayList(PENDING_PRESENCE); - PENDING_PRESENCE.clear(); + if (PENDING_PRESENCE.isEmpty() || replayingPresence) { + // Nothing parked, or another registration is already draining + // it -- and that drain will pick up anything that arrives + // while it runs. + return; } + replayingPresence = true; } - if (replay != null) { - for (PendingPresence parked : replay) { + replayPresence(); + } + + /// Hands the parked backlog to the EDT, oldest first, until nothing is + /// left. + /// + /// Loops rather than taking one batch: an event that arrives while the + /// batch is being dispatched parks behind it (deliverPresenceChanged sees + /// replayingPresence), and the next turn of this loop sends it on. That + /// is what keeps a live event from overtaking older parked ones. + private static void replayPresence() { + while (true) { + List batch; + synchronized (LISTENERS) { + if (PENDING_PRESENCE.isEmpty()) { + replayingPresence = false; + return; + } + batch = new ArrayList(PENDING_PRESENCE); + PENDING_PRESENCE.clear(); + } + for (PendingPresence parked : batch) { dispatchPresence(parked.device, parked.present); } } @@ -322,6 +349,7 @@ public static void resetForTest() { synchronized (LISTENERS) { LISTENERS.clear(); PENDING_PRESENCE.clear(); + replayingPresence = false; } } @@ -407,7 +435,8 @@ public static void deliverPresenceChanged(String encodedDevice, return; } synchronized (LISTENERS) { - if (LISTENERS.isEmpty() || !PENDING_PRESENCE.isEmpty()) { + if (LISTENERS.isEmpty() || !PENDING_PRESENCE.isEmpty() + || replayingPresence) { while (PENDING_PRESENCE.size() >= MAX_PENDING_PRESENCE) { PENDING_PRESENCE.remove(0); } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 932057aa36a..d1f1a8f1ffb 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -856,6 +856,22 @@ public void stopAllTransport() { // Internals // ------------------------------------------------------------------ + /// The platform profile role names, written out rather than referenced. + /// + /// AssociationRequest.DEVICE_PROFILE_GLASSES is API 34, and the nearby + /// compile-SDK floor is 33 -- so naming that constant would fail to + /// compile for an app built against exactly 33, which the builder allows. + /// These are compile-time String constants in the platform too, and their + /// values are stable role names, so the literal is what the constant + /// would have inlined anyway and it also works where the constant does + /// not exist yet. + private static final String PROFILE_WATCH = + "android.app.role.COMPANION_DEVICE_WATCH"; + private static final String PROFILE_GLASSES = + "android.app.role.COMPANION_DEVICE_GLASSES"; + private static final String PROFILE_COMPUTER = + "android.app.role.COMPANION_DEVICE_COMPUTER"; + private static String profileFor(int profile) { // The ordinals of com.codename1.nearby.companion.CompanionProfile. if (Build.VERSION.SDK_INT < 31) { @@ -863,18 +879,16 @@ private static String profileFor(int profile) { } switch (profile) { case 1: - return AssociationRequest.DEVICE_PROFILE_WATCH; + return PROFILE_WATCH; case 2: // GLASSES is API 34 and COMPUTER is 33 -- not the other way // round, which is the order the enum happens to declare them // in. Passing the platform a profile string it does not know // throws, so these two gates were checked against the SDK's // own api-versions.xml rather than guessed from the ordinal. - return Build.VERSION.SDK_INT >= 34 - ? AssociationRequest.DEVICE_PROFILE_GLASSES : null; + return Build.VERSION.SDK_INT >= 34 ? PROFILE_GLASSES : null; case 3: - return Build.VERSION.SDK_INT >= 33 - ? AssociationRequest.DEVICE_PROFILE_COMPUTER : null; + return Build.VERSION.SDK_INT >= 33 ? PROFILE_COMPUTER : null; default: // GENERIC. Deliberately no profile at all rather than a // harmless-looking one: a profile is a request for elevated @@ -883,6 +897,40 @@ private static String profileFor(int profile) { } } + /// The CompanionProfile ordinal an association was made under. + /// + /// #### Parameters + /// + /// - `info`: the association + /// + /// #### Returns + /// + /// the ordinal, or 0 for GENERIC and for anything this API does not model + static int profileOrdinalOf(AssociationInfo info) { + if (info == null || Build.VERSION.SDK_INT < 33) { + return 0; + } + String profile; + try { + profile = info.getDeviceProfile(); + } catch (Throwable unreadable) { + return 0; + } + if (PROFILE_WATCH.equals(profile)) { + return 1; + } + if (PROFILE_GLASSES.equals(profile)) { + return 2; + } + if (PROFILE_COMPUTER.equals(profile)) { + return 3; + } + // Null, or one of the profiles the portable API does not model -- + // app streaming, automotive projection. GENERIC is the honest answer + // for both. + return 0; + } + private static boolean addFilter(AssociationRequest.Builder request, String encoded) { String[] fields = encoded == null ? null : encoded.split("\t", -1); @@ -972,24 +1020,27 @@ private static String encode(AssociationInfo info, boolean present) { String mac = macOf(info); CharSequence name = info.getDisplayName(); return join(idOf(info), name == null ? "" : name.toString(), - mac == null ? "" : mac, present); + mac == null ? "" : mac, profileOrdinalOf(info), present); } private static String encodeLegacy(String id, String mac, boolean present) { + // No AssociationInfo below API 33, so no profile to read either. return join(id, mac == null ? "" : mac, mac == null ? "" : mac, - present); + 0, present); } /// Builds the record `com.codename1.impl.nearby.NearbyWire` decodes. /// - /// The profile field is always zero: Android does not report back which - /// profile an association was made under, and guessing would be worse - /// than saying GENERIC. + /// The profile is read back from the association rather than hardcoded to + /// GENERIC: AssociationInfo.getDeviceProfile reports the one the + /// association was made under from API 33, and reporting GENERIC for a + /// watch contradicted CompanionDevice.getProfile and left an app unable + /// to tell its profile-specific companions apart. private static String join(String id, String name, String address, - boolean present) { + int profileOrdinal, boolean present) { return sanitize(id) + '\t' + sanitize(name) + '\t' + sanitize(address) - + "\t0\t" + (present ? '1' : '0'); + + '\t' + profileOrdinal + '\t' + (present ? '1' : '0'); } private static String sanitize(String s) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index 81f8a5a726d..2493a207bc7 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -139,9 +139,14 @@ private void deliver(AssociationInfo info, boolean present) { return; } CharSequence name = info.getDisplayName(); + // The profile the association was actually made under, not a + // hardcoded GENERIC -- the same record AndroidNearbyBackend builds, + // and it has to agree with it or one presence event would contradict + // the association the app already holds. String encoded = sanitize(id) + '\t' + sanitize(name == null ? "" : name.toString()) + '\t' - + sanitize(mac == null ? "" : mac) + "\t0\t" + + sanitize(mac == null ? "" : mac) + '\t' + + AndroidNearbyBackend.profileOrdinalOf(info) + '\t' + (present ? '1' : '0'); CompanionDevices.deliverPresenceChanged(encoded, present); } diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 94c398ed6b5..1acc254ce04 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1819,6 +1819,12 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan return; } CN1NearbyCompanion *companion = cn1nbCompanionInit(); + // Taken BEFORE the picker opens, so the accessory it adds can be + // told apart from the ones this app already had. + NSMutableSet *before = [NSMutableSet set]; + for (ASAccessory *a in companion.session.accessories) { + [before addObject:cn1nbAccessoryId(a)]; + } [companion.session showPickerForDisplayItems:items completionHandler:^(NSError *error) { @autoreleasepool { @@ -1828,12 +1834,28 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan [error localizedDescription]); return; } - ASAccessory *picked = - [companion.session.accessories lastObject]; + // The one that is NEW, not the last in the array. The + // accessories array documents no order, so an app that + // already held associations could be handed one the user + // did not pick -- and then persist or disassociate the + // wrong device. + ASAccessory *picked = nil; + for (ASAccessory *a in companion.session.accessories) { + if (![before containsObject:cn1nbAccessoryId(a)]) { + if (picked != nil) { + // Two arrived while the picker was open; + // neither can be claimed as the user's pick. + picked = nil; + break; + } + picked = a; + } + } if (picked == nil) { cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_USER_CANCELED, - @"the picker returned no accessory"); + @"the picker added no accessory this app" + @" did not already have"); return; } // present:NO. Associating an accessory says the user From f5a2c5bc8bd7343f01bfbab8aa0a2feb710edb9c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:39:45 +0300 Subject: [PATCH 37/94] Address the twenty-fifth nearby review round - A cancelled start stops its session instead of leaving the radio running. deliverSessionPrepared already did this; deliverSessionStarted and deliverAccessoryConfiguration did not, and take() hands back a cancelled resource just the same -- so completing it was a no-op while the session was marked running, keeping the UWB or NI radio alive for a caller that had already walked away, with its listeners still receiving updates. - Lost iOS peers are released from the transport registries. lostPeer calls encodePeer, which WRITES the peer back into peersById and serviceIdByPeer, and nothing ever removed them -- so the singleton transport retained an MCPeerID for every device it had ever seen, for the life of the process. The stale ids staying resolvable is the worse half: a send addressed to a peer that had gone away found a mapping and looked like it might work. Forgotten after the event is delivered, because encoding it needs the mappings, and only for a peer nothing is connected to: lostPeer means the BROWSER can no longer see it, which says nothing about an open session, so dropping it unconditionally would have broken sending to a peer that is still connected. A full stop clears them all. --- .../com/codename1/nearby/ranging/Ranging.java | 14 +++++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 41 +++++++++++++++++++ .../com/codename1/nearby/LocalNearbyTest.java | 22 ++++++++++ 3 files changed, 77 insertions(+) diff --git a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java index 5f34970fb2a..ef36a3973fc 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -286,6 +286,13 @@ public static void deliverSessionStarted(int requestId, if (s == null) { r.error(new NearbyException(NearbyError.SESSION_INVALIDATED, "the session was closed before it started")); + } else if (r.isCancelled()) { + // Cancelled counts as nobody waiting, the same way + // deliverSessionPrepared treats it: completing a cancelled + // resource is a no-op, so marking the session running left a + // radio session alive that the caller had already walked away + // from -- and its listeners still receiving updates. + s.stop(); } else { s.markRunning(); r.complete(s); @@ -309,6 +316,13 @@ public static void deliverAccessoryConfiguration(int requestId, EdtResult r = PENDING_ACCESSORY.take(requestId); if (r != null) { RangingSession s = RangingSession.lookup(sessionHandle); + if (s != null && r.isCancelled()) { + // As deliverSessionStarted: the caller walked away, so the + // handshake bytes have nowhere to go and the session must not + // be left holding the radio. + s.stop(); + return; + } if (s == null) { // Mirrors deliverSessionStarted. A stop() that lands while the // start is in flight deregisters the session, and completing diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 1acc254ce04..a321e3d7fd1 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -761,6 +761,43 @@ - (void)markEverConnected:(NSString *)pid { } } +/// True when this peer has reached Connected and has not been forgotten. +- (BOOL)isEverConnected:(NSString *)pid { + @synchronized (self) { + return [self.everConnected containsObject:pid]; + } +} + +/// Forgets a peer nothing is talking to any more. +/// +/// The singleton transport retains an MCPeerID for every device it has ever +/// seen, so a long discovery in a busy place accumulated one per device for +/// the life of the process -- and their endpoint ids stayed resolvable, which +/// is worse than the memory: a send addressed to a peer that went away found +/// a mapping and looked like it might work. +/// +/// Only for a peer nothing is connected to. lostPeer means the BROWSER can no +/// longer see it, which says nothing about an open session -- dropping the +/// mapping there would have broken sending to a peer that is still connected. +- (void)forgetPeer:(NSString *)pid { + if (pid == nil || [self isEverConnected:pid]) { + return; + } + @synchronized (self) { + [self.peersById removeObjectForKey:pid]; + [self.serviceIdByPeer removeObjectForKey:pid]; + } +} + +/// Forgets every peer, for a full stop. +- (void)forgetAllPeers { + @synchronized (self) { + [self.peersById removeAllObjects]; + [self.serviceIdByPeer removeAllObjects]; + [self.everConnected removeAllObjects]; + } +} + /// True when this peer had reached Connected, forgetting it either way. - (BOOL)takeEverConnected:(NSString *)pid { @synchronized (self) { @@ -1099,6 +1136,9 @@ - (void)browser:(MCNearbyServiceBrowser *)browser service:self.discoverServiceId]; com_codename1_impl_ios_IOSNearbyCallbacks_endpointFound___java_lang_String_boolean( getThreadLocalData(), cn1nbJString(encoded), JAVA_FALSE); + // Forgotten after the event is delivered, because encoding it needs + // the mappings. + [self forgetPeer:cn1nbIdForPeer(peerID)]; } } @@ -2407,6 +2447,7 @@ void com_codename1_impl_ios_IOSNative_nearbyStopAllTransport__( } [cn1nbTransport closeAllSessions]; [cn1nbTransport forgetInvitations]; + [cn1nbTransport forgetAllPeers]; } #endif } diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 6acb2043164..a11e2c00e86 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -248,6 +248,28 @@ void aSecondStartOnARunningSessionIsRefusedRatherThanQueued() { assertFailedWith(NearbyError.BUSY, s.start(peerToken())); } + @Test + void cancellingAStartStopsTheSessionRatherThanLeavingItRunning() { + // Completing a cancelled resource is a no-op, so marking the session + // running left a radio session alive that the caller had already + // walked away from -- and its listeners still receiving updates. + RangingSession s = value(Ranging.prepareSession( + RangingRole.CONTROLLER)); + assertEquals(1, bridge.getSessionHandles().length); + + // The clock is held so the cancel lands before the port answers, + // which is the ordering a real port produces. + List queue = new ArrayList(); + bridge.deferForTest(queue); + AsyncResource starting = s.start(peerToken()); + starting.cancel(true); + drain(queue); + + assertFalse(s.isRunning()); + assertEquals(0, bridge.getSessionHandles().length, + "the radio session must be released"); + } + @Test void aStoppedSessionCannotBeRestarted() { RangingSession s = running(); From 200321145f2fda927cf5009ef392c230eaf02f16 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:49:11 +0300 Subject: [PATCH 38/94] Refuse an association while the activity-result channel is busy CodenameOneActivity.setIntentResultListener SILENTLY ignores a registration while another activity-result flow is outstanding -- the camera, the scanner, anything that called startActivityForResult. The association launched its chooser regardless, so the result went to that other listener and this request's AsyncResource stayed pending for good. listenForResult now reports whether it actually installed, and associate fails with BUSY rather than starting a flow nothing will answer. An activity that is not a CodenameOneActivity is refused for the same reason: without a listener the result can never arrive, so launching the chooser is guaranteed to strand the caller. --- .../android/nearby/AndroidNearbyBackend.java | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index d1f1a8f1ffb..70067a2c90e 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -392,7 +392,16 @@ public void associate(final int requestId, int profile, // the platform scan all three transports, which is what the portable // API promises for an empty filter list. pendingAssociateRequest = requestId; - listenForResult(requestId, cdm); + if (!listenForResult(requestId, cdm)) { + // Nothing would ever answer this request, so it is refused now + // rather than left pending while another flow takes its result. + pendingAssociateRequest = 0; + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.BUSY.ordinal(), + "another activity result is outstanding; try again when" + + " it has finished"); + return; + } cdm.associate(request.build(), new CompanionDeviceManager.Callback() { @Override public void onDeviceFound(IntentSender chooserLauncher) { @@ -439,10 +448,26 @@ private void releaseResultListener() { } } - private void listenForResult(final int requestId, + /// Installs the result listener for the association chooser. + /// + /// #### Returns + /// + /// true when the listener is in place, false when the activity-result + /// channel could not take it -- in which case the chooser must not be + /// launched at all + private boolean listenForResult(final int requestId, final CompanionDeviceManager cdm) { if (!(activity instanceof CodenameOneActivity)) { - return; + return false; + } + // setIntentResultListener SILENTLY ignores a registration while + // another activity-result flow is outstanding -- the camera, the + // scanner, anything that called startActivityForResult. Launching + // the chooser anyway sent its result to that other listener and left + // this request's AsyncResource pending for good, so the caller is + // told the truth instead. + if (((CodenameOneActivity) activity).isWaitingForResult()) { + return false; } // Taken BEFORE the chooser opens, so the association it creates can be // told apart from the ones this app already had. @@ -472,6 +497,7 @@ public void onActivityResult(int requestCode, int resultCode, } } }); + return true; } /// The association the chooser just created. From 993a32549897bc7534605c38825e9ba3a1e324f7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:57:24 +0300 Subject: [PATCH 39/94] Address the twenty-sixth nearby review round - A replaced advertiser or browser can no longer fail its successor's request. Stopping the old one left its delegate attached, and didNotStartAdvertisingPeer ignores its advertiser argument entirely -- it consumes pendingAdvertiseRequest, which by then belongs to the REPLACEMENT, so a late failure from a dead advertiser failed a start that was fine. Both replacement paths detach the delegate, and both callbacks also check that the object is still the current one, because clearing a delegate does not recall a callback already in flight. - Background ranging is reported when the app is actually configured for it. CAP_BACKGROUND was hardcoded off, reasoning that the com.apple.developer.nearby-interaction entitlement cannot be injected for everyone -- true, but it left isBackgroundRangingSupported() false even in the configuration that ENABLES the feature, so an app gating on it disabled ranging it genuinely had. The nearby-interaction background mode is the signal: it is in the Info.plist and readable at runtime, the entitlement is not, and the builder writes neither without the other. The guide already described this behaviour; the code was the half that did not match. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 46 ++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index a321e3d7fd1..a9bb15a002f 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1101,6 +1101,13 @@ - (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser - (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser didNotStartAdvertisingPeer:(NSError *)error { @autoreleasepool { + if (advertiser != self.advertiser) { + // From an advertiser that has since been replaced. Clearing the + // delegate above should stop this, but a callback already in + // flight is not recalled by it, and answering would fail the + // replacement's request with a dead advertiser's error. + return; + } // MultipeerConnectivity rejects advertising asynchronously -- an // unavailable radio, a service type it will not take -- and // startAdvertising has already resolved true by the time this fires. @@ -1145,6 +1152,11 @@ - (void)browser:(MCNearbyServiceBrowser *)browser - (void)browser:(MCNearbyServiceBrowser *)browser didNotStartBrowsingForPeers:(NSError *)error { @autoreleasepool { + if (browser != self.browser) { + // From a browser that has since been replaced; answering would + // fail the replacement's request with a dead browser's error. + return; + } // Same as advertising: the answer is already out, so the request id is // held to fail it when the framework changes its mind. int requestId = self.pendingDiscoverRequest; @@ -1538,11 +1550,27 @@ - (void)activate { } } } - // CAP_BACKGROUND is deliberately never set. Background ranging needs the - // com.apple.developer.nearby-interaction entitlement, which the builder - // never injects on its own because it has to be enabled on the App ID - // first -- so claiming it here would be a promise the binary usually - // cannot keep. + // CAP_BACKGROUND is reported from the app's own configuration, not + // assumed either way. + // + // Background ranging needs the com.apple.developer.nearby-interaction + // entitlement AND the nearby-interaction background mode, which the + // builder injects together and only for ios.nearby.background=true -- + // because the entitlement has to be enabled on the App ID first, so it + // cannot be turned on for everyone. Never setting the bit made + // isBackgroundRangingSupported() false even in the configuration that + // enables the feature, so an app that gates on it disabled ranging it + // actually had. + // + // The background MODE is the signal: it is in the Info.plist, which is + // readable at runtime, whereas the entitlement is not -- and the builder + // writes neither without the other. + NSArray *modes = [[NSBundle mainBundle] + objectForInfoDictionaryKey:@"UIBackgroundModes"]; + if ([modes isKindOfClass:[NSArray class]] + && [modes containsObject:@"nearby-interaction"]) { + bits |= CN1_NEARBY_CAP_BACKGROUND; + } #endif return bits; } @@ -2016,6 +2044,12 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str NSString *name = toNSString(CN1_THREAD_STATE_PASS_ARG localName); CN1NearbyTransport *t = cn1nbTransportInit(sid, name, YES); if (t.advertiser != nil) { + // The delegate goes with it. A replaced advertiser can still + // deliver didNotStartAdvertisingPeer, and that callback consumes + // pendingAdvertiseRequest -- which by then belongs to the + // REPLACEMENT, so the new start was failed with the old + // advertiser's error. + t.advertiser.delegate = nil; [t.advertiser stopAdvertisingPeer]; t.advertiser = nil; } @@ -2082,6 +2116,8 @@ void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_Strin } CN1NearbyTransport *t = cn1nbTransportInit(sid, nil, NO); if (t.browser != nil) { + // Detached for the reason the advertiser above is. + t.browser.delegate = nil; [t.browser stopBrowsingForPeers]; t.browser = nil; } From 3400ab93a2e36d64b48cce403a0f9ad4784ecca6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:07:06 +0300 Subject: [PATCH 40/94] Address the twenty-seventh nearby review round - The Android backend rebinds to the current activity. The bridge is cached for the life of the process while Android recreates the activity freely -- a configuration change, or "Don't keep activities" -- so the held activity was destroyed long before the app associated a device: the chooser launched on a dead activity while the result listener waited on a host nothing would deliver to. Every use goes through currentActivity() now, falling back to the constructor's only when the port has none. The optional sub-backends take the APPLICATION context instead. They live as long as the bridge and use it only for package-manager, permission and content-resolver lookups, so holding a destroyed activity was a leak with no upside. - Each discovery and advertising callback captures its own service id. Starting discovery for "files" while "chat" was running overwrote the shared field, and the callback still installed for chat then labelled chat's endpoints as files -- which happens even when Google rejects the second start as already discovering. The fields remain for the state a later call genuinely needs; the callbacks no longer read them. - cancelPayload stops every transfer under the portable id. The same immutable Payload handed to two send() calls mints two platform ids for one portable id, so returning after the first left the other running and free to report SUCCESS after the app had cancelled it. The ids are collected first and cancelled outside the iteration, because cancelPayload can reach back into that map through a transfer update. --- .../android/nearby/AndroidNearbyBackend.java | 59 ++++++++++++++----- .../nearby/AndroidNearbyTransport.java | 53 +++++++++++++---- 2 files changed, 85 insertions(+), 27 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 70067a2c90e..7f1dbec6429 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -72,26 +72,55 @@ public class AndroidNearbyBackend implements NearbyBridge { /// the port's own IntentResultListener constants. private static final int ASSOCIATE_REQUEST = 0x4E42; - private final Activity activity; + /// The activity this backend was built with, used only when the port has + /// no current one. + private final Activity initialActivity; private final NearbyBridge ranging; private final NearbyBridge transport; private int pendingAssociateRequest; public AndroidNearbyBackend(Activity activity) { - this.activity = activity; + this.initialActivity = activity; this.ranging = load("com.codename1.impl.android.nearby." + "AndroidUwbRanging"); this.transport = load("com.codename1.impl.android.nearby." + "AndroidNearbyTransport"); } + /// The activity to launch from and ask permissions on, now. + /// + /// NOT the one this backend was constructed with. The bridge is cached + /// for the life of the process while Android recreates the activity + /// freely -- a configuration change, or "Don't keep activities" -- so a + /// held activity is destroyed long before the app associates a device, + /// and the chooser was launched on it while the result listener waited on + /// a host nothing would ever deliver to. + private Activity currentActivity() { + Activity current = AndroidImplementation.getActivity(); + return current != null ? current : initialActivity; + } + + /// The context the optional backends hold. + /// + /// The application context, not the activity: these live as long as the + /// bridge does and use it only for package manager, permission and + /// content-resolver lookups, so holding a destroyed activity would be a + /// leak with no upside. + private android.content.Context contextForBackends() { + if (initialActivity == null) { + return null; + } + android.content.Context app = initialActivity.getApplicationContext(); + return app != null ? app : initialActivity; + } + private NearbyBridge load(String className) { Object instance = null; try { Class clazz = Class.forName(className); instance = clazz.getConstructor(Context.class) - .newInstance(activity); + .newInstance(contextForBackends()); } catch (Throwable t) { // The builder deletes the half an app did not reference, so this // is the ordinary path rather than an error. @@ -172,7 +201,7 @@ public void requestPermissions(int requestId, int permissionBits) { // 12 uses the legacy permissions and location, and asking it for // BLUETOOTH_SCAN left the grant it needed unrequested. List transport = NearbyPermissions.transportPermissions( - activity, permissionBits); + currentActivity(), permissionBits); for (int i = 0; i < transport.size(); i++) { add(perms, transport.get(i)); } @@ -188,7 +217,7 @@ public void requestPermissions(int requestId, int permissionBits) { // checkForPermission blocks through invokeAndBlock and must run on the // EDT. Display.getInstance().callSerially( - permissionRunnable(requestId, perms, activity)); + permissionRunnable(requestId, perms, currentActivity())); } /// Adds a permission the app has not already been granted. @@ -202,7 +231,7 @@ private void add(ArrayList perms, String permission) { if (Build.VERSION.SDK_INT < 23) { return; } - if (activity.checkSelfPermission(permission) + if (currentActivity().checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { perms.add(permission); } @@ -335,11 +364,11 @@ public void stopRangingSession(int sessionHandle) { // ------------------------------------------------------------------ private CompanionDeviceManager manager() { - if (Build.VERSION.SDK_INT < 26 || activity == null) { + if (Build.VERSION.SDK_INT < 26 || currentActivity() == null) { return null; } try { - return (CompanionDeviceManager) activity.getSystemService( + return (CompanionDeviceManager) currentActivity().getSystemService( Context.COMPANION_DEVICE_SERVICE); } catch (Throwable t) { return null; @@ -428,7 +457,7 @@ public void onFailure(CharSequence error) { private void launch(IntentSender chooserLauncher, int requestId) { try { - activity.startIntentSenderForResult(chooserLauncher, + currentActivity().startIntentSenderForResult(chooserLauncher, ASSOCIATE_REQUEST, null, 0, 0, 0); } catch (IntentSender.SendIntentException e) { pendingAssociateRequest = 0; @@ -443,8 +472,9 @@ private void launch(IntentSender chooserLauncher, int requestId) { /// Hands the activity-result channel back, so the next /// startActivityForResult caller can install its own listener. private void releaseResultListener() { - if (activity instanceof CodenameOneActivity) { - ((CodenameOneActivity) activity).restoreIntentResultListener(); + Activity current = currentActivity(); + if (current instanceof CodenameOneActivity) { + ((CodenameOneActivity) current).restoreIntentResultListener(); } } @@ -457,7 +487,8 @@ private void releaseResultListener() { /// launched at all private boolean listenForResult(final int requestId, final CompanionDeviceManager cdm) { - if (!(activity instanceof CodenameOneActivity)) { + Activity current = currentActivity(); + if (!(current instanceof CodenameOneActivity)) { return false; } // setIntentResultListener SILENTLY ignores a registration while @@ -466,13 +497,13 @@ private boolean listenForResult(final int requestId, // the chooser anyway sent its result to that other listener and left // this request's AsyncResource pending for good, so the caller is // told the truth instead. - if (((CodenameOneActivity) activity).isWaitingForResult()) { + if (((CodenameOneActivity) current).isWaitingForResult()) { return false; } // Taken BEFORE the chooser opens, so the association it creates can be // told apart from the ones this app already had. final Set before = associationKeys(cdm); - final CodenameOneActivity host = (CodenameOneActivity) activity; + final CodenameOneActivity host = (CodenameOneActivity) current; host.setIntentResultListener(new IntentResultListener() { public void onActivityResult(int requestCode, int resultCode, Intent data) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 5376d5cfa18..b688b76dedb 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -56,6 +56,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; /// The nearby transport on Android, over Google's Nearby Connections. @@ -212,13 +213,15 @@ public void run() { public void startAdvertising(final int requestId, String serviceId, String localName, int strategy) { - this.advertisingServiceId = serviceId == null ? "" : serviceId; + // Captured for THIS callback, for the reason startDiscovery does it. + final String started = serviceId == null ? "" : serviceId; + this.advertisingServiceId = started; this.localName = localName == null ? "" : localName; AdvertisingOptions options = new AdvertisingOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); - client().startAdvertising(this.localName, this.advertisingServiceId, - connectionCallback(), options) + client().startAdvertising(this.localName, started, + connectionCallback(started), options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { NearbyTransport.deliverRequestOk(requestId); @@ -239,12 +242,18 @@ public void stopAdvertising() { public void startDiscovery(final int requestId, String serviceId, int strategy) { - this.discoveryServiceId = serviceId == null ? "" : serviceId; + // Captured for THIS callback rather than read back out of the field + // when an endpoint turns up. Starting discovery for "files" while + // "chat" was running overwrote the field, and the callback still + // installed for chat then labelled chat's endpoints as files -- which + // happens even when Google rejects the second start as already + // discovering. The field remains for the state a later call needs. + final String started = serviceId == null ? "" : serviceId; + this.discoveryServiceId = started; DiscoveryOptions options = new DiscoveryOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); - client().startDiscovery(this.discoveryServiceId, discoveryCallback(), - options) + client().startDiscovery(started, discoveryCallback(started), options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { NearbyTransport.deliverRequestOk(requestId); @@ -267,7 +276,11 @@ public void requestConnection(final int requestId, String endpointId, String localName) { String name = localName == null || localName.length() == 0 ? this.localName : localName; - client().requestConnection(name, endpointId, connectionCallback()) + // Connecting OUT, so the endpoint belongs to whatever discovery + // found it -- the field is the right source here, and the mapping + // discoveryCallback recorded is left alone when one already exists. + client().requestConnection(name, endpointId, + connectionCallback(discoveryServiceId)) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { NearbyTransport.deliverRequestOk(requestId); @@ -380,14 +393,26 @@ public void onFailure(Exception e) { } public void cancelPayload(int payloadId) { + List doomed = new ArrayList(); synchronized (payloadIds) { for (Map.Entry e : payloadIds.entrySet()) { if (e.getValue().intValue() == payloadId) { - client().cancelPayload(e.getKey().longValue()); - return; + // Collected, not cancelled in place: cancelPayload can + // reach back into payloadIds through a transfer update, + // and mutating the map mid-iteration is not something to + // rely on. + doomed.add(e.getKey()); } } } + // EVERY transfer under this portable id, not the first. The same + // immutable Payload can be handed to two send() calls, which mints + // two platform ids for one portable id -- so returning after the + // first left the other running, free to report SUCCESS after the app + // had cancelled it. + for (Long platformId : doomed) { + client().cancelPayload(platformId.longValue()); + } } public void disconnect(String endpointId) { @@ -416,13 +441,14 @@ public void stopAllTransport() { // Callbacks // ------------------------------------------------------------------ - private EndpointDiscoveryCallback discoveryCallback() { + private EndpointDiscoveryCallback discoveryCallback( + final String serviceId) { return new EndpointDiscoveryCallback() { @Override public void onEndpointFound(String endpointId, DiscoveredEndpointInfo info) { endpointNames.put(endpointId, info.getEndpointName()); - endpointServices.put(endpointId, discoveryServiceId); + endpointServices.put(endpointId, serviceId); NearbyTransport.deliverEndpointFound( encode(endpointId, info.getEndpointName()), true); } @@ -442,7 +468,8 @@ public void onEndpointLost(String endpointId) { }; } - private ConnectionLifecycleCallback connectionCallback() { + private ConnectionLifecycleCallback connectionCallback( + final String serviceId) { return new ConnectionLifecycleCallback() { @Override public void onConnectionInitiated(String endpointId, @@ -452,7 +479,7 @@ public void onConnectionInitiated(String endpointId, // discovered came in through advertising, so that is the // service it belongs to. if (!endpointServices.containsKey(endpointId)) { - endpointServices.put(endpointId, advertisingServiceId); + endpointServices.put(endpointId, serviceId); } NearbyTransport.deliverConnectionRequested( encode(endpointId, info.getEndpointName()), From 29aade703f8e166d98894799a4c56071474e89f5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:27:59 +0300 Subject: [PATCH 41/94] Address the twenty-eighth nearby review round - Two service ids can no longer fold onto one Bonjour type. com.example.chat, com-example-chat and com.example.charts all reduced to com-example-cha, so three unrelated apps discovered and connected to each other while NearbyTransport promises service ids match exactly. The type is ten readable characters plus four derived from the whole id -- com-exampl-jd3q -- which stays recognisable and inside Apple's fifteen. The suffix is FNV-1a over the trimmed, ASCII-lowercased UTF-8 bytes and has to be identical in three places: cn1nbServiceType in CN1Nearby.m, this builder, and the BuildDaemon copy. The type the device registers must equal the type the build declared in the Info.plist or iOS drops the traffic with no error, so the three were compared by running the Objective-C helper standalone against the Java for eight inputs rather than by reading them. Trimmed because a comma-separated hint hands the builder " files " while the runtime is handed "files". ASCII-lowercased because "Chat" and "chat" are one service and always were under the old fold; hashing the raw id would have split them in two. StandardCharsets rather than the encoding name, so there is no unreachable catch whose fallback would use the platform default and compute a different suffix on a different machine -- the one failure mode this change exists to prevent. Ten existing tests asserted the old fold literally. They encoded exactly what changed, so each was updated to the new shape while keeping its intent, and the guide's advice to keep ids short is replaced by what actually happens now. - Android keeps an endpoint's name until disconnect completes. disconnect() cleared the cache before Google's asynchronous onDisconnected encoded the endpoint through nameOf(), so the listener was handed an endpoint with an empty name instead of the peer's advertised one -- and that callback already removes both mappings itself. --- .../nearby/AndroidNearbyTransport.java | 6 +- Ports/iOSPort/nativeSources/CN1Nearby.m | 51 ++++++++ docs/developer-guide/Nearby-Devices.asciidoc | 14 ++- .../com/codename1/builders/IPhoneBuilder.java | 70 +++++++++++ .../NearbyBonjourServiceTypeTest.java | 115 ++++++++++++++---- 5 files changed, 223 insertions(+), 33 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index b688b76dedb..0a0f176c7c7 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -416,8 +416,12 @@ public void cancelPayload(int payloadId) { } public void disconnect(String endpointId) { + // The name stays until onDisconnected has used it. That callback + // encodes the endpoint through nameOf(), and clearing the cache here + // handed the listener an endpoint with an empty name instead of the + // peer's advertised one -- and onDisconnected already removes both + // mappings itself. client().disconnectFromEndpoint(endpointId); - endpointNames.remove(endpointId); } public void stopAllTransport() { diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index a9bb15a002f..2589999c555 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -549,6 +549,39 @@ @interface CN1NearbyTransport : NSObject = 'A' && b <= 'Z') { + b += 'a' - 'A'; + } + hash ^= (uint32_t)b; + hash *= 16777619u; + } + } + uint32_t value = hash % 1679616u; + char digits[5]; + digits[4] = '\0'; + for (int i = 3; i >= 0; i--) { + uint32_t digit = value % 36u; + digits[i] = (char)(digit < 10 ? ('0' + digit) : ('a' + digit - 10)); + value /= 36u; + } + return [NSString stringWithUTF8String:digits]; +} + static NSString *cn1nbServiceType(NSString *serviceId) { NSMutableString *out = [NSMutableString stringWithCapacity:15]; NSString *lower = [serviceId lowercaseString]; @@ -566,6 +599,24 @@ @interface CN1NearbyTransport : NSObject 10) { + [out deleteCharactersInRange:NSMakeRange(10, [out length] - 10)]; + } + while ([out hasSuffix:@"-"]) { + [out deleteCharactersInRange:NSMakeRange([out length] - 1, 1)]; + } + if ([out length] == 0) { + [out appendString:@"cn1"]; + } + [out appendString:@"-"]; + [out appendString:cn1nbBonjourSuffix(serviceId)]; // At least one ASCII LETTER, not merely one legal character. Apple // requires it, and an all-digit id like "123" folded to "123" -- which // reads as legal and makes MCNearbyServiceAdvertiser RAISE rather than diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index d25948a09c9..86f7738e051 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -216,12 +216,14 @@ build can't see the strings you pass to `startAdvertising`, so name them in with a message telling you which id to add, which beats an app that finds nothing and says nothing. -Keep each id short. The platform restricts the folded type to fifteen -characters of lowercase letters, digits and hyphens, so a reverse-DNS string -that's legal on Android is folded to fit -- and `com.example.chat` and -`com.example.charts` fold to the same thing, which would have two unrelated -apps discovering each other's peers. The build log names every type it -declared. +The platform restricts the type to fifteen characters of lowercase letters, +digits and hyphens, so a reverse-DNS string that's legal on Android is folded +to fit: `com.example.chat` becomes something like `com-exampl-jd3q`. The last +four characters are derived from the whole id, because the fold alone is lossy +-- `com.example.chat` and `com.example.charts` both reduce to +`com-example-cha`, and without the suffix two unrelated apps would discover +each other's peers. Case doesn't split a service: `Chat` and `chat` are the +same id and get the same type. The build log names every type it declared. Byte payloads are capped at `NearbyTransport.getMaxPayloadSize()`, a few kilobytes on both platforms; anything larger goes as a file payload, which 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 df228aa2c0d..9462b1c29ef 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 @@ -320,6 +320,26 @@ static String foldBonjourServiceType(String serviceId) { while (out.length() > 0 && out.charAt(out.length() - 1) == '-') { out.setLength(out.length() - 1); } + // A stable suffix derived from the WHOLE id, because the fold above + // is lossy and the truncation is brutal: "com.example.chat", + // "com-example-chat" and "com.example.charts" all reduce to + // "com-example-cha", so three unrelated apps would have discovered + // and connected to each other while NearbyTransport promises service + // ids match exactly. Ten characters of the readable fold plus four of + // hash keeps the type recognisable in a packet trace and inside the + // fifteen Apple allows. + if (out.length() > 10) { + out.setLength(10); + } + while (out.length() > 0 && out.charAt(out.length() - 1) == '-') { + out.setLength(out.length() - 1); + } + if (out.length() == 0) { + out.append("cn1"); + } + out.append('-').append(bonjourSuffix(serviceId)); + // NOTE: bonjourSuffix trims and ASCII-lowercases, so the suffix + // matches whatever the runtime computes for the same logical id. // At least one ASCII LETTER, not merely one legal character. Apple // requires it, and an all-digit id like "123" folded to "123" -- which // reads as legal and makes MCNearbyServiceAdvertiser RAISE rather than @@ -346,6 +366,56 @@ static String foldBonjourServiceType(String serviceId) { return out.toString(); } + /// Four base-36 characters derived from the whole service id. + /// + /// FNV-1a over the id's UTF-8 bytes, written out rather than borrowed so + /// that `cn1nbServiceType` in CN1Nearby.m can compute the identical value + /// -- the type this build declares in the Info.plist has to be the type + /// the device registers, or iOS drops the traffic. NearbyBonjourServiceTypeTest + /// compares the two. + /// + /// #### Parameters + /// + /// - `serviceId`: the caller's id, unfolded + /// + /// #### Returns + /// + /// exactly four characters of `[0-9a-z]` + static String bonjourSuffix(String serviceId) { + int hash = 0x811c9dc5; + // Trimmed, because a comma-separated hint hands this " files " while + // the runtime is handed "files" -- and the two have to agree. + String id = serviceId == null ? "" : serviceId.trim(); + // StandardCharsets rather than the String name, so there is no + // unreachable catch whose fallback would silently use the platform + // default encoding and produce a different suffix on a different + // machine. + byte[] bytes = id.getBytes(java.nio.charset.StandardCharsets.UTF_8); + for (int i = 0; i < bytes.length; i++) { + int b = bytes[i] & 0xff; + // ASCII-lowercased before hashing, so the suffix is as + // case-insensitive as the fold above -- "Chat" and "chat" are one + // service, and always were. Done here rather than with + // toLowerCase so the Objective-C side can do exactly the same + // thing to exactly the same bytes. + if (b >= 'A' && b <= 'Z') { + b += 'a' - 'A'; + } + hash ^= b; + hash *= 16777619; + } + long positive = ((long) hash) & 0xffffffffL; + long value = positive % 1679616L; + char[] digits = new char[4]; + for (int i = 3; i >= 0; i--) { + int digit = (int) (value % 36); + digits[i] = (char) (digit < 10 ? ('0' + digit) + : ('a' + digit - 10)); + value /= 36; + } + return new String(digits); + } + /// Escapes the three characters that cannot sit in plist text content. /// /// Small and local rather than borrowed: the neighbouring builders each diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java index 1e9b03f582d..5e01de4a227 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java @@ -27,6 +27,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -76,8 +77,11 @@ private static void assertLegal(String type) { @Test void anExplicitHintIsUsedAsGiven() { - assertEquals("chat", only( - request("com.example.app", "chat"))); + // The readable half is the hint; the four-character suffix keeps two + // different ids from folding onto one type. + String type = only(request("com.example.app", "chat")); + assertLegal(type); + assertEquals("chat-" + IPhoneBuilder.bonjourSuffix("chat"), type); } @Test @@ -87,11 +91,12 @@ void aReverseDnsPackageIsFoldedRatherThanRejected() { String type = only( request("com.example.chat", null)); assertLegal(type); - // Sixteen characters folded, fifteen allowed -- so even this - // unremarkable package name is truncated, which is why the builder - // logs the derived type and the guide tells you to set - // ios.nearby.serviceType yourself. - assertEquals("com-example-cha", type); + // Ten readable characters plus the suffix, because the fold is lossy + // and the truncation is brutal -- which is why the builder logs the + // derived type and the guide tells you to set ios.nearby.serviceType + // yourself. + assertEquals("com-exampl-" + + IPhoneBuilder.bonjourSuffix("com.example.chat"), type); } @Test @@ -116,27 +121,31 @@ void runsOfIllegalCharactersCollapseToOneHyphen() { String type = only( request("com...example___app", null)); assertLegal(type); - assertEquals("com-example-app", type); + assertEquals("com-exampl-" + + IPhoneBuilder.bonjourSuffix("com...example___app"), type); } @Test void uppercaseIsLowered() { - assertEquals("mychat", only( - request("com.example.app", "MyChat"))); + assertEquals("mychat-" + IPhoneBuilder.bonjourSuffix("MyChat"), + only(request("com.example.app", "MyChat"))); } @Test void somethingWithNoUsableCharactersFallsBackRatherThanRaising() { - assertEquals("cn1-nearby", only( - request("...", null))); - assertEquals("cn1-nearby", only( - request(null, null))); + // Nothing readable survives, so the type is the fallback plus the + // suffix -- still legal, and still distinct per id. + assertLegal(only(request("...", null))); + assertTrue(only(request("...", null)).startsWith("cn1-"), + only(request("...", null))); + assertLegal(only(request(null, null))); } @Test void ablankHintFallsBackToThePackageRatherThanToTheDefault() { - assertEquals("com-example-app", only( - request("com.example.app", " "))); + assertEquals("com-exampl-" + + IPhoneBuilder.bonjourSuffix("com.example.app"), + only(request("com.example.app", " "))); } @Test @@ -159,9 +168,12 @@ void aCommaSeparatedHintDeclaresEveryServiceTheAppUses() { List types = IPhoneBuilder.bonjourServiceTypes( request("com.example.app", "chat, files , telemetry")); assertEquals(3, types.size()); - assertEquals("chat", types.get(0)); - assertEquals("files", types.get(1)); - assertEquals("telemetry", types.get(2)); + assertEquals("chat-" + IPhoneBuilder.bonjourSuffix("chat"), + types.get(0)); + assertEquals("files-" + IPhoneBuilder.bonjourSuffix("files"), + types.get(1)); + assertEquals("telemetry-" + IPhoneBuilder.bonjourSuffix("telemetry"), + types.get(2)); for (String t : types) { assertLegal(t); } @@ -169,10 +181,13 @@ void aCommaSeparatedHintDeclaresEveryServiceTheAppUses() { @Test void idsThatFoldToTheSameTypeAreDeclaredOnce() { + // "Chat" is the same service as "chat" -- the suffix is + // ASCII-lowercased before hashing precisely so case does not split a + // service in two. List types = IPhoneBuilder.bonjourServiceTypes( request("com.example.app", "chat,chat,Chat")); assertEquals(1, types.size()); - assertEquals("chat", types.get(0)); + assertEquals("chat-4xwr", types.get(0)); } @Test @@ -182,12 +197,18 @@ void theFoldIsTheSameOneTheRuntimeApplies() { // disagree the app browses a type the plist does not declare, and iOS // answers with silence rather than an error -- so the build-side fold // is exposed on its own and pinned here. - assertEquals("chat", IPhoneBuilder.foldBonjourServiceType("chat")); - assertEquals("com-example-cha", + assertEquals("chat-4xwr", + IPhoneBuilder.foldBonjourServiceType("chat")); + assertEquals("com-exampl-jd3q", IPhoneBuilder.foldBonjourServiceType("com.example.chat")); - assertEquals("mychat", IPhoneBuilder.foldBonjourServiceType("MyChat")); - assertEquals("", IPhoneBuilder.foldBonjourServiceType("...")); + // Case-insensitive, suffix included: these are one service. + assertEquals(IPhoneBuilder.foldBonjourServiceType("mychat"), + IPhoneBuilder.foldBonjourServiceType("MyChat")); assertEquals("", IPhoneBuilder.foldBonjourServiceType(null)); + // The literals above are the values CN1Nearby.m's cn1nbServiceType + // produces for the same input, checked by compiling and running it. + // If either side changes, this fails rather than the app silently + // browsing a type its own plist does not declare. } @Test @@ -229,8 +250,11 @@ void aDigitsAndPunctuationIdAlsoGetsALetter() { @Test void anIdThatAlreadyHasALetterIsNotPrefixed() { - assertEquals("chat", IPhoneBuilder.foldBonjourServiceType("chat")); - assertEquals("a1", IPhoneBuilder.foldBonjourServiceType("a1")); + // The readable half is untouched; only the suffix is appended. + assertTrue(IPhoneBuilder.foldBonjourServiceType("chat") + .startsWith("chat-")); + assertTrue(IPhoneBuilder.foldBonjourServiceType("a1") + .startsWith("a1-")); } private static boolean hasLetter(String s) { @@ -242,4 +266,43 @@ private static boolean hasLetter(String s) { } return false; } + + @Test + void idsThatFoldTheSameStillGetDifferentServiceTypes() { + // The fold is lossy and the truncation is brutal: these three all + // reduced to "com-example-cha", so three unrelated apps discovered + // and connected to each other while NearbyTransport promises service + // ids match exactly. + String a = IPhoneBuilder.foldBonjourServiceType("com.example.chat"); + String b = IPhoneBuilder.foldBonjourServiceType("com-example-chat"); + String c = IPhoneBuilder.foldBonjourServiceType("com.example.charts"); + assertNotEquals(a, b); + assertNotEquals(a, c); + assertNotEquals(b, c); + for (String t : new String[] {a, b, c}) { + assertTrue(t.length() <= 15, t); + assertTrue(t.startsWith("com-exampl"), "still recognisable: " + t); + } + } + + @Test + void theSameIdAlwaysFoldsToTheSameType() { + // The device registers this type and the build declares it in the + // Info.plist; if they ever disagreed iOS would drop the traffic. + assertEquals(IPhoneBuilder.foldBonjourServiceType("com.example.chat"), + IPhoneBuilder.foldBonjourServiceType("com.example.chat")); + } + + @Test + void theSuffixIsFourLowercaseAlphanumerics() { + for (String id : new String[] {"chat", "123", "a", "com.example.x"}) { + String suffix = IPhoneBuilder.bonjourSuffix(id); + assertEquals(4, suffix.length(), suffix); + for (int i = 0; i < suffix.length(); i++) { + char ch = suffix.charAt(i); + assertTrue((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z'), + suffix); + } + } + } } From 5fafce1f30cf485f7a8ce6c2b515e9079c07963f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:37:22 +0300 Subject: [PATCH 42/94] Address the twenty-ninth nearby review round - cn1nbSessionFor returns the ranging entry retained past any removal the caller performs. The registry is normally the only owner -- the entry was created autoreleased and that pool drained long ago -- so stopping a session, which looks the entry up, removes it and then messages it, was messaging freed memory. The lock added last round serialises the DICTIONARY and does nothing for the lifetime of what comes out of it. Fixed in the lookup rather than in the one stop path, so every caller gets it. This is the same mistake as the MCSession, invitation-block and session-close ones earlier in this branch: dropping the sole owning reference and then using the object. - A companion profile the running Android does not have fails the request. profileFor returns null both for GENERIC, which wants no profile at all, and for a profile that arrived later than this device -- WATCH below API 31, COMPUTER below 33, GLASSES below 34. Treating them alike submitted a generic association for a caller that asked for an elevated one, so the chooser succeeded WITHOUT the privileges requested and handed back a device reporting GENERIC. NOT_SUPPORTED now, and the guide says which profile arrived when: falling back to GENERIC is the app's decision, not the port's. --- .../android/nearby/AndroidNearbyBackend.java | 24 +++++++++++++++---- Ports/iOSPort/nativeSources/CN1Nearby.m | 12 +++++++++- docs/developer-guide/Nearby-Devices.asciidoc | 7 ++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 7f1dbec6429..23bdf7677e4 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -393,11 +393,27 @@ public void associate(final int requestId, int profile, } AssociationRequest.Builder request = new AssociationRequest.Builder(); request.setSingleDevice(singleDevice); - if (Build.VERSION.SDK_INT >= 31) { - String deviceProfile = profileFor(profile); - if (deviceProfile != null) { - request.setDeviceProfile(deviceProfile); + // A profile this Android version does not have FAILS the request. + // + // profileFor returns null both for GENERIC, which wants no profile at + // all, and for a profile that arrived too early -- WATCH below API 31, + // COMPUTER below 33, GLASSES below 34. Treating the two alike + // submitted a generic association for a caller that asked for an + // elevated one, so the chooser succeeded WITHOUT the privileges + // requested and handed back a device reporting GENERIC. A profile is + // not a preference to drop quietly. + if (profile != 0) { + String deviceProfile = Build.VERSION.SDK_INT >= 31 + ? profileFor(profile) : null; + if (deviceProfile == null) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.NOT_SUPPORTED.ordinal(), + "this Android version has no companion profile " + + profile + "; associate with CompanionProfile.GENERIC" + + " or check CompanionDevices.isSupported first"); + return; } + request.setDeviceProfile(deviceProfile); } // A supplied filter that cannot be installed FAILS the request. It // used to be ignored, which quietly turned "show me only devices diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 2589999c555..97dcbf64e10 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -381,11 +381,21 @@ - (void)session:(NISession *)session @end +/// The session for a handle, retained past any removal the caller performs. +/// +/// The registry is normally the ONLY owner: the entry was created +/// autoreleased and the pool it was created in drained long ago. So a caller +/// that looks the entry up, removes it, and then messages it -- which is +/// exactly what stopping a session does -- was messaging freed memory. The +/// lock added for the registry serialises the dictionary; it does nothing for +/// the lifetime of what comes out of it, which is a separate problem with the +/// same shape as the MCSession and invitation-block ones. static CN1NearbyRangingSession *cn1nbSessionFor(int handle) API_AVAILABLE(ios(14.0)) { cn1nbSessionsInit(); @synchronized (cn1nbSessionsLock) { - return [cn1nbSessions objectForKey:[NSNumber numberWithInt:handle]]; + return [[[cn1nbSessions objectForKey:[NSNumber numberWithInt:handle]] + retain] autorelease]; } } diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index 86f7738e051..b8f4f8deed5 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -159,6 +159,13 @@ because it arrives as an enum constant, so name it yourself: set profile's own permission, and without it Android rejects the association before the chooser opens -- which looks to the user like nothing happened. +Profiles arrived at different Android versions: `WATCH` at 12, `COMPUTER` at +13, `GLASSES` at 14. Asking for one the running device doesn't have fails with +`NearbyError.NOT_SUPPORTED` rather than associating without it. A profile is a +request for elevated privileges, and an association that lacks them without +saying so is worse than one that didn't happen. Fall back to +`CompanionProfile.GENERIC` yourself if that's what you want. + Two platform differences to design around. Presence notifications are Android only: AccessorySetupKit reports an accessory being added to or removed from the app's set, which isn't the same event as it coming into range, so From 05f20172169b860e7773b85d0d459e0f9138788a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:46:56 +0300 Subject: [PATCH 43/94] Address the thirtieth nearby review round Both of these are consequences of earlier fixes on this branch. - In-flight invitations count toward the strategy limit. The enforcement added a few rounds ago looked only at CONNECTED peers, so two requestConnection calls made before either peer answered both saw zero, both invitations went out, and the discoverer ended up holding two sessions under STAR or POINT_TO_POINT with neither call ever told BUSY. The slot is reserved before the invitation goes out and released when the peer answers, either way. - A simulated cancel is recorded only for a send that is actually pending. The cancellation added a few rounds ago recorded unconditionally, so cancelling a transfer that had already finished -- or an id that was never sent -- left a marker in the set for good, and reusing the same immutable Payload in a later send() consumed it and reported that perfectly good transfer as CANCELED. Repeated unknown ids also grew the set until stop(). --- .../impl/nearby/LocalNearbyBridge.java | 20 ++++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 44 ++++++++++++++++++- .../com/codename1/nearby/LocalNearbyTest.java | 37 ++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 311b7cb2c55..e3def107cad 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -133,6 +133,15 @@ public class LocalNearbyBridge implements NearbyBridge { /// one can report CANCELED instead of SUCCESS. private final java.util.Set cancelledPayloads = new java.util.HashSet(); + /// Payload ids whose send has been queued and not yet delivered. + /// + /// A cancel is only worth recording for one of these. Recorded + /// unconditionally, a cancel for an id that had already completed -- or + /// one that was never sent -- sat in the set for good, and reusing the + /// same immutable Payload in a later send() then consumed the stale + /// marker and reported that perfectly good transfer as CANCELED. + private final java.util.Set pendingPayloads = + new java.util.HashSet(); /// Where delayed deliveries go while a test drives the clock, or null in /// normal operation. /// @@ -683,6 +692,7 @@ public List getRejectedEndpoints() { public void sendPayload(final int requestId, final String[] endpointIds, final int payloadId, final int payloadType, final byte[] bytes, final String path) { + pendingPayloads.add(Integer.valueOf(payloadId)); answer(new Runnable() { @Override public void run() { @@ -691,6 +701,7 @@ public void run() { // the caller holding a resolved resource and waiting for a // terminal payloadProgress that could never come, which is // exactly the state transfer UI hangs on. + pendingPayloads.remove(Integer.valueOf(payloadId)); boolean any = false; for (String endpointId : endpointIds) { if (findEndpoint(endpointId) != null @@ -741,7 +752,13 @@ public void cancelPayload(int payloadId) { // went on to report SUCCESS and echo the payload, so the simulator // was the one place the public cancellation contract was never // exercised. - cancelledPayloads.add(Integer.valueOf(payloadId)); + // + // Only for a send that is actually pending: cancelling something that + // has finished, or an id that was never sent, is a no-op on a real + // platform and must not leave a marker behind here either. + if (pendingPayloads.contains(Integer.valueOf(payloadId))) { + cancelledPayloads.add(Integer.valueOf(payloadId)); + } } @Override @@ -764,6 +781,7 @@ public void stopAllTransport() { discoverGeneration++; advertiseGeneration++; cancelledPayloads.clear(); + pendingPayloads.clear(); List doomed = new ArrayList(connected); connected.clear(); for (String id : doomed) { diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 97dcbf64e10..ef355421af5 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -441,6 +441,14 @@ @interface CN1NearbyTransport : NSObject 0) { + && [cn1nbTransport heldPeerCount] > 0) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_BUSY, cn1nbTransport.discoverStrategy == CN1_NEARBY_STRATEGY_POINT_TO_POINT @@ -2261,6 +2297,10 @@ void com_codename1_impl_ios_IOSNative_nearbyRequestConnection___int_java_lang_St @"the endpoint was lost while renaming this device"); return; } + // Reserved BEFORE the invitation goes out, so a second + // requestConnection made while this one is still unanswered sees the + // slot taken. + [cn1nbTransport markInviting:pid]; [cn1nbTransport.browser invitePeer:peer toSession:[cn1nbTransport sessionFor:pid] withContext:nil @@ -2296,7 +2336,7 @@ void com_codename1_impl_ios_IOSNative_nearbyAcceptConnection___int_java_lang_Str // makes this device the star's centre. if (cn1nbTransport.advertiseStrategy == CN1_NEARBY_STRATEGY_POINT_TO_POINT - && [cn1nbTransport connectedPeerCount] > 0) { + && [cn1nbTransport heldPeerCount] > 0) { handler(NO, nil); cn1nbFailTransport(requestId, CN1_NEARBY_ERR_BUSY, @"POINT_TO_POINT allows one connection at a time;" diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index a11e2c00e86..8ed8c313f60 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -599,6 +599,43 @@ public void endpointFound(Endpoint e) { NearbyTransport.send(e, Payload.fromBytes(new byte[] {1}))); } + @Test + void cancellingAFinishedPayloadDoesNotPoisonTheNextSend() { + // Recorded unconditionally, a cancel for an id that had already + // completed sat in the set for good -- and reusing the same immutable + // Payload in a later send() consumed the stale marker and reported + // that perfectly good transfer as CANCELED. + final List progress = + new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void payloadProgress(Endpoint e, PayloadTransferUpdate u) { + progress.add(u); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + value(NearbyTransport.requestConnection(e, "me")); + + Payload p = Payload.fromBytes(new byte[] {1, 2, 3}); + value(NearbyTransport.send(e, p)); + assertEquals(1, progress.size()); + assertSame(PayloadStatus.SUCCESS, progress.get(0).getStatus()); + + // Too late, and for an id nothing is waiting on. + NearbyTransport.cancel(p.getId()); + progress.clear(); + value(NearbyTransport.send(e, p)); + assertEquals(1, progress.size()); + assertSame(PayloadStatus.SUCCESS, progress.get(0).getStatus()); + } + @Test void cancellingAPayloadInFlightReportsCanceledAndSendsNothing() { // sendPayload is delayed like everything else here, so an app really From 01621e49f7cbccd37e96b774eab5ec1794b26d22 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:55:33 +0300 Subject: [PATCH 44/94] Address the thirty-first nearby review round - The exact-address device filter is guarded like the others. setAddress and build() throw IllegalArgumentException for anything that is not a MAC, and this was the one branch that let it escape -- past the caller, out of the backend and into application code -- orphaning the AsyncResource associate() had already registered instead of failing it with INVALID_TOKEN. The Wi-Fi branch is guarded too: it compiles a pattern and had the same exposure. - stop() no longer clears endpoint metadata the disconnect callbacks still need. stopAllEndpoints disconnects asynchronously and onDisconnected encodes each endpoint through nameOf(), so clearing immediately handed every one of those callbacks an endpoint with an empty name and service id -- the same defect the single-endpoint disconnect() path had, in the path that was not revisited with it. Splitting it correctly needs to tell an endpoint a callback will visit from one it will not, which nothing tracked: connectedEndpoints does that now. A connected endpoint's metadata is removed by its own callback; a merely discovered one has none coming and is dropped at stop. --- .../android/nearby/AndroidNearbyBackend.java | 32 +++++++++++++------ .../nearby/AndroidNearbyTransport.java | 32 +++++++++++++++++-- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 23bdf7677e4..6014ce5c8a0 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -1043,17 +1043,31 @@ private static boolean addFilter(AssociationRequest.Builder request, } } if (kind == 2) { - request.addDeviceFilter( - new android.companion.BluetoothDeviceFilter.Builder() - .setAddress(value) - .build()); - return true; + // Guarded like the two above. setAddress and build() throw + // IllegalArgumentException for anything that is not a MAC, and + // this was the one branch that let it escape -- past the caller, + // out of the backend, and into application code, leaving the + // AsyncResource that associate() had already registered orphaned + // instead of failing with INVALID_TOKEN. + try { + request.addDeviceFilter( + new android.companion.BluetoothDeviceFilter.Builder() + .setAddress(value) + .build()); + return true; + } catch (Throwable notAnAddress) { + return false; + } } if (kind == 3) { - request.addDeviceFilter(new WifiDeviceFilter.Builder() - .setNamePattern(Pattern.compile(Pattern.quote(value))) - .build()); - return true; + try { + request.addDeviceFilter(new WifiDeviceFilter.Builder() + .setNamePattern(Pattern.compile(Pattern.quote(value))) + .build()); + return true; + } catch (Throwable notUsable) { + return false; + } } return false; } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 0a0f176c7c7..21bb566e8eb 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -95,6 +95,14 @@ public class AndroidNearbyTransport implements NearbyBridge { /// discovering service A had the later call overwrite it, and endpoints /// found under A were then encoded as B -- which Endpoint.getServiceId() /// documents as the service they were found under. + /// Endpoints currently connected. + /// + /// stop() has to clear the metadata of endpoints nothing will call back + /// about, and leave alone the metadata onDisconnected still needs. Only + /// this tells the two apart. + private final java.util.Set connectedEndpoints = + java.util.Collections.synchronizedSet( + new java.util.HashSet()); private final Map endpointServices = Collections.synchronizedMap(new HashMap()); private String advertisingServiceId = ""; @@ -434,8 +442,22 @@ public void stopAllTransport() { client().stopAdvertising(); client().stopDiscovery(); client().stopAllEndpoints(); - endpointNames.clear(); - endpointServices.clear(); + // Only the endpoints nothing will call back about. stopAllEndpoints + // disconnects asynchronously and onDisconnected encodes each endpoint + // through nameOf(), so clearing everything handed those callbacks an + // endpoint with an empty name and service id -- the same defect the + // single-endpoint disconnect() path had. A connected endpoint's + // metadata is removed by its own callback; a merely discovered one + // has no callback coming and is dropped here. + synchronized (connectedEndpoints) { + List discoveredOnly = new ArrayList( + endpointNames.keySet()); + discoveredOnly.removeAll(connectedEndpoints); + for (String id : discoveredOnly) { + endpointNames.remove(id); + endpointServices.remove(id); + } + } payloadIds.clear(); payloadRecipients.clear(); incomingFiles.clear(); @@ -495,6 +517,11 @@ public void onConnectionResult(String endpointId, ConnectionResolution resolution) { boolean ok = resolution.getStatus().getStatusCode() == ConnectionsStatusCodes.STATUS_OK; + if (ok) { + connectedEndpoints.add(endpointId); + } else { + connectedEndpoints.remove(endpointId); + } NearbyTransport.deliverConnectionResult( encode(endpointId, nameOf(endpointId)), ok, ok ? 0 : NearbyError.SESSION_FAILED.ordinal(), @@ -505,6 +532,7 @@ public void onConnectionResult(String endpointId, public void onDisconnected(String endpointId) { NearbyTransport.deliverDisconnected( encode(endpointId, nameOf(endpointId))); + connectedEndpoints.remove(endpointId); endpointNames.remove(endpointId); // Cleared with the name, for the reason onEndpointLost does. endpointServices.remove(endpointId); From 7bceb956e9eb2e146bbdbff6095bd7807e179b04 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:05:01 +0300 Subject: [PATCH 45/94] Address the thirty-second nearby review round Three narrower cases of races this branch had already tried to close. The shape repeats: testing a post-hoc count covers only the path it was written for, while RESERVING before the asynchronous step holds for all of them. All four paths reserve now. - Accepting an incoming invitation reserves the slot, not just inviting. Two invitations accepted before either session reached Connected both saw a held count of zero, because taking an invitation added it to nothing, so POINT_TO_POINT let the second through. - The simulator reserves in-flight connections too. It had exactly the bug the real ports had, so two requestConnection calls made before the first delayed acceptance ran established two connections under STAR or POINT_TO_POINT -- which means desktop testing would hide the topology error instead of surfacing it, and surfacing it is what the simulator is for. - A peer the browser lost while it was still connected is forgotten when it disconnects. forgetPeer rightly refuses to drop a connected peer's mappings, since a send to it must still resolve, but nothing retried afterwards -- so a peer lost mid-session was never forgotten at all and its stale endpoint id stayed resolvable for the life of the process. --- .../impl/nearby/LocalNearbyBridge.java | 18 ++++++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 36 ++++++++++++++++++- .../com/codename1/nearby/LocalNearbyTest.java | 23 ++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index e3def107cad..95aac5734e3 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -105,6 +105,14 @@ public class LocalNearbyBridge implements NearbyBridge { /// Endpoints whose connection requests were rejected. Recorded so a /// test can tell an immediate refusal from silence. private final List rejected = new ArrayList(); + /// Endpoints invited and not yet answered. + /// + /// Counted with the connected ones when a strategy limit is enforced. + /// Two requestConnection calls made before the first delayed acceptance + /// ran both saw an empty connected list, so the simulator established two + /// connections the real ports refuse -- which is exactly the topology bug + /// a simulator exists to surface rather than hide. + private final List connecting = new ArrayList(); /// The topology each half was started with, as a TransportStrategy /// ordinal. CLUSTER is the default, which is also what a caller that /// passed no strategy is given. @@ -589,12 +597,15 @@ public void requestConnection(final int requestId, final String endpointId, // centre. A simulator that let an app hold three connections under // POINT_TO_POINT would teach it a topology no device will honour. if (discoverStrategy != TransportStrategy.CLUSTER.ordinal() - && !connected.isEmpty()) { + && !(connected.isEmpty() && connecting.isEmpty())) { answer(new TransportFailure(requestId, NearbyError.BUSY, "this strategy allows one connection at a time;" + " disconnect the current peer first")); return; } + // Reserved before the first hop is queued, released when the request + // settles either way. + connecting.add(endpointId); final int generation = transportGeneration; answer(new Runnable() { @Override @@ -604,6 +615,7 @@ public void run() { // caller's AsyncResource pending for good -- a resource // that never settles is worse than one that fails, which // is the whole reason EdtResult exists. + connecting.remove(endpointId); NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_INVALIDATED.ordinal(), "the transport was stopped before the connection" @@ -622,6 +634,7 @@ public void run() { // adding the endpoint then reported a connection on // a transport that had been stopped and never // restarted. + connecting.remove(endpointId); if (generation != transportGeneration) { return; } @@ -641,7 +654,7 @@ public void acceptConnection(final int requestId, String endpointId) { // side. STAR does not: accepting many is what makes this device the // centre of the star. if (advertiseStrategy == TransportStrategy.POINT_TO_POINT.ordinal() - && !connected.isEmpty() + && !(connected.isEmpty() && connecting.isEmpty()) && !connected.contains(endpointId)) { rejectConnection(endpointId); answer(new TransportFailure(requestId, NearbyError.BUSY, @@ -780,6 +793,7 @@ public void stopAllTransport() { transportGeneration++; discoverGeneration++; advertiseGeneration++; + connecting.clear(); cancelledPayloads.clear(); pendingPayloads.clear(); List doomed = new ArrayList(connected); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index ef355421af5..d5e004f239a 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -449,6 +449,13 @@ @interface CN1NearbyTransport : NSObject found = discoverAll(TransportStrategy.POINT_TO_POINT); + assertTrue(found.size() >= 2, "need two synthetic peers to test this"); + + List queue = new ArrayList(); + bridge.deferForTest(queue); + AsyncResource first = + NearbyTransport.requestConnection(found.get(0), "me"); + // Made while the first is still queued, which is the whole point. + AsyncResource second = + NearbyTransport.requestConnection(found.get(1), "me"); + // The refusal is delayed like everything else here, so both answers + // arrive on the drain rather than inline. + drain(queue); + assertTrue(value(first).booleanValue()); + assertFailedWith(NearbyError.BUSY, second); + } + @Test void clusterAllowsTheSecondConnectionPointToPointRefuses() { List found = discoverAll(TransportStrategy.CLUSTER); From 3621cd14f15bdceafb8b7a5b581b2b7fe21d02e1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:14:49 +0300 Subject: [PATCH 46/94] Address the thirty-third nearby review round - The iOS rename guard counts pending invitations. Rebuilding the peer id tears down every session, and doing that while an invitation was outstanding disconnected it without ever reporting connected or connectionFailed -- so the app waited for a lifecycle outcome that was never coming. - An association survives activity recreation. currentActivity() rebound where the backend LAUNCHES from, but the result listener still lived on the destroyed instance, and Android delivers the chooser's result to whichever activity is alive when it closes. The result therefore went somewhere the backend was not listening: the resource never settled and pendingAssociateRequest stayed set, so every later association answered BUSY. AndroidImplementation.init is the one place that knows the activity changed, so it tells the bridge, which forwards to the backend reflectively -- the always-compiled shell cannot reference the optional package. The backend re-installs the listener on the new activity, or fails the request and releases the pending slot when it cannot, rather than leaving the association wedged. --- .../impl/android/AndroidImplementation.java | 10 +++++ .../impl/android/AndroidNearbyBridge.java | 33 +++++++++++++++ .../android/nearby/AndroidNearbyBackend.java | 40 +++++++++++++++++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 7 +++- 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 2014995c09b..01a0ea833bb 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1580,6 +1580,16 @@ public void init(Object m) { setActivity(null); setContext((Context)m); } + // The nearby bridge is cached for the life of the process while + // Android recreates the activity freely -- a configuration change, + // or "Don't keep activities". An association chooser opened by the + // old activity delivers its result to the NEW one, where the + // backend's result listener is not installed, so the association + // resource never settled and every later association answered BUSY. + // Told here because this is the one place that knows it changed. + if (nearbyBridge != null) { + nearbyBridge.onActivityChanged(); + } instance = this; if(getActivity() != null && getActivity().hasUI()){ diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java b/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java index 173d2c124ab..4749efa04dc 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java @@ -49,6 +49,11 @@ public class AndroidNearbyBridge implements NearbyBridge { private final NearbyBridge delegate; + /// The backend's activity-changed hook, or null when there is no backend + /// or it predates the hook. Resolved once, because reflection per + /// activity change would be paid on every rotation. + private final java.lang.reflect.Method activityChanged; + /// Loads the optional backend, or `null` when the build did not include /// it. @@ -76,6 +81,34 @@ public AndroidNearbyBridge(Activity activity) { // rule rather than a per-port exception. this.delegate = instance instanceof NearbyBridge ? (NearbyBridge) instance : null; + java.lang.reflect.Method hook = null; + if (this.delegate != null) { + try { + hook = this.delegate.getClass() + .getMethod("onActivityChanged"); + } catch (Throwable noHook) { + hook = null; + } + } + this.activityChanged = hook; + } + + /// Tells the backend the host activity has been replaced. + /// + /// Called from `AndroidImplementation.init`, which is the one place that + /// knows. A backend holding a destroyed activity would launch the + /// association chooser on it and wait for a result the new activity + /// receives instead. + public void onActivityChanged() { + if (activityChanged == null) { + return; + } + try { + activityChanged.invoke(delegate); + } catch (Throwable ignored) { + // A backend that cannot rebind is no worse off than one that was + // never told. + } } // ------------------------------------------------------------------ diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 6014ce5c8a0..1770d9ed219 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -88,6 +88,44 @@ public AndroidNearbyBackend(Activity activity) { + "AndroidNearbyTransport"); } + /// The activity the association's result listener is installed on, or + /// null when none is. + private Activity listeningOn; + + /// Re-installs the association result listener on the activity that + /// replaced the one it was on. + /// + /// Android delivers the chooser's result to whichever activity is alive + /// when it closes, and the listener lives on the instance -- so a + /// recreation mid-chooser sent the result somewhere the backend was not + /// listening, leaving the association resource unsettled and + /// pendingAssociateRequest set, which made every later association answer + /// BUSY. Called from AndroidImplementation.init through + /// AndroidNearbyBridge, the one place that knows the activity changed. + public void onActivityChanged() { + if (pendingAssociateRequest == 0) { + return; + } + Activity current = currentActivity(); + if (current == null || current == listeningOn) { + return; + } + CompanionDeviceManager cdm = manager(); + if (cdm == null || !listenForResult(pendingAssociateRequest, cdm)) { + // Nothing can answer it now, so it is failed rather than left to + // hang -- and the pending slot is released so the next + // association is not refused as BUSY for a chooser nobody is + // waiting on any more. + int requestId = pendingAssociateRequest; + pendingAssociateRequest = 0; + listeningOn = null; + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.USER_CANCELED.ordinal(), + "the screen was recreated while the device chooser was" + + " open; associate again"); + } + } + /// The activity to launch from and ask permissions on, now. /// /// NOT the one this backend was constructed with. The bridge is cached @@ -488,6 +526,7 @@ private void launch(IntentSender chooserLauncher, int requestId) { /// Hands the activity-result channel back, so the next /// startActivityForResult caller can install its own listener. private void releaseResultListener() { + listeningOn = null; Activity current = currentActivity(); if (current instanceof CodenameOneActivity) { ((CodenameOneActivity) current).restoreIntentResultListener(); @@ -520,6 +559,7 @@ private boolean listenForResult(final int requestId, // told apart from the ones this app already had. final Set before = associationKeys(cdm); final CodenameOneActivity host = (CodenameOneActivity) current; + listeningOn = current; host.setIntentResultListener(new IntentResultListener() { public void onActivityResult(int requestCode, int resultCode, Intent data) { diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index d5e004f239a..9ad46ac3f36 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1330,9 +1330,14 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { && [wanted lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) { wanted = cn1nbPeerName(wanted); } + // heldPeerCount, not connectedPeerCount: an invitation that has gone out + // and not been answered counts too. Rebuilding the peer id tears down + // every session, and doing that while an invitation was pending + // disconnected it without ever reporting connected or connectionFailed, + // so the app waited for a lifecycle outcome that was never coming. if (t.localPeer != nil && wanted != nil && ![t.localPeer.displayName isEqualToString:wanted] - && [t connectedPeerCount] == 0) { + && [t heldPeerCount] == 0) { BOOL wasAdvertising = t.advertiser != nil; BOOL wasBrowsing = t.browser != nil; if (wasAdvertising) { From 09eb4d6a765ca180066ad92be33fa8779ece3c98 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:27:10 +0300 Subject: [PATCH 47/94] Address the thirty-fourth nearby review round - AccessorySetupKit activation is awaited. activateWithQueue returns before the session is usable and the event saying otherwise arrives on the queue it was given -- which the handler, emptied in an earlier round of this branch, was throwing away. So the first picker of a fresh process could fail and getAssociations could report an empty list for an app that has associations. The handler records the activated event and cn1nbCompanionInit waits for it, bounded, and never on the main thread: that is where the event is delivered, so waiting there would deadlock. Codename One natives run on the EDT, a thread of its own on iOS. - A file handoff that never started fails. sendResourceAtURL returning nil means the framework did not take the transfer, so its completion handler never runs -- yet the request resolved successfully and the caller waited forever for progress that could not come. Reported per recipient, and the aggregate fails when nothing started at all. - SUCCESS for a byte payload means arrived, not queued. sendData returning YES says the message was accepted for sending, while PayloadStatus.SUCCESS documents that every byte reached the peer -- which is what Android reports, because Nearby tells it so. MultipeerConnectivity has no such signal, so the transport now carries one: the frame gained a kind byte, the receiver sends a one-frame acknowledgement, and the sender reports IN_PROGRESS on queue and SUCCESS on the acknowledgement. A peer that disconnects with sends outstanding fails them rather than leaving them pending, which is the rule the rest of this branch already follows. The guide says what the two mean. --- Ports/iOSPort/nativeSources/CN1Nearby.h | 7 + Ports/iOSPort/nativeSources/CN1Nearby.m | 209 +++++++++++++++++-- docs/developer-guide/Nearby-Devices.asciidoc | 6 + 3 files changed, 206 insertions(+), 16 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.h b/Ports/iOSPort/nativeSources/CN1Nearby.h index ffad71f6169..cda79bc5b1c 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.h +++ b/Ports/iOSPort/nativeSources/CN1Nearby.h @@ -104,6 +104,13 @@ #define CN1_NEARBY_CAP_BACKGROUND 32 // com.codename1.nearby.spi.NearbyBridge payload types. +/// The transport's own frame header: one kind byte then a four-byte payload +/// id. Both ends of a MultipeerConnectivity session are this port, so the +/// framing is symmetric by construction. +#define CN1_NEARBY_FRAME_HEADER 5 +#define CN1_NEARBY_FRAME_DATA 0 +#define CN1_NEARBY_FRAME_ACK 1 + #define CN1_NEARBY_PAYLOAD_BYTES 0 #define CN1_NEARBY_PAYLOAD_FILE 1 diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 9ad46ac3f36..254920925d8 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -456,6 +456,14 @@ @interface CN1NearbyTransport : NSObject > 24) & 0xff); + frame[2] = (unsigned char)((payloadId >> 16) & 0xff); + frame[3] = (unsigned char)((payloadId >> 8) & 0xff); + frame[4] = (unsigned char)(payloadId & 0xff); + NSError *ignored = nil; + [session sendData:[NSData dataWithBytes:frame + length:CN1_NEARBY_FRAME_HEADER] + toPeers:[NSArray arrayWithObject:peer] + withMode:MCSessionSendDataReliable + error:&ignored]; +} + +/// Records a payload sent to a peer and waiting for its acknowledgement. +- (void)awaitAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { + @synchronized (self) { + NSMutableSet *ids = [self.awaitingAck objectForKey:pid]; + if (ids == nil) { + ids = [NSMutableSet set]; + [self.awaitingAck setObject:ids forKey:pid]; + } + [ids addObject:[NSNumber numberWithInt:(int)payloadId]]; + } +} + +/// Takes the acknowledgement for one payload, answering whether it was +/// outstanding -- so a duplicate or unknown ack reports nothing. +- (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { + @synchronized (self) { + NSMutableSet *ids = [self.awaitingAck objectForKey:pid]; + NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; + if (![ids containsObject:key]) { + return NO; + } + [ids removeObject:key]; + if ([ids count] == 0) { + [self.awaitingAck removeObjectForKey:pid]; + } + return YES; + } +} + +/// Takes every payload still waiting on a peer, for a disconnect. +- (NSArray *)takeAllAcksFromPeer:(NSString *)pid { + @synchronized (self) { + NSArray *ids = [[[self.awaitingAck objectForKey:pid] allObjects] copy]; + [self.awaitingAck removeObjectForKey:pid]; + return [ids autorelease]; + } +} + /// Records an invitation this device is about to send. - (void)markInviting:(NSString *)pid { @synchronized (self) { @@ -907,6 +974,7 @@ - (void)forgetAllPeers { [self.everConnected removeAllObjects]; [self.inviting removeAllObjects]; [self.lostWhileConnected removeAllObjects]; + [self.awaitingAck removeAllObjects]; } } @@ -1036,6 +1104,14 @@ - (void)session:(MCSession *)session peer:(MCPeerID *)peerID lostWhileUp = [self.lostWhileConnected containsObject:pid]; } if ([self takeEverConnected:pid]) { + // Anything still waiting on this peer will never be + // acknowledged, so it is failed rather than left pending. + for (NSNumber *stranded in [self takeAllAcksFromPeer:pid]) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), + (JAVA_INT)[stranded intValue], 0, -1, + CN1_NEARBY_PAYLOAD_FAILURE); + } if (lostWhileUp) { // The browser lost it before the session ended, so this is // the moment its mappings can finally go. @@ -1063,14 +1139,35 @@ - (void)session:(MCSession *)session didReceiveData:(NSData *)data // could tell two of them apart, or match one to its progress events -- // which Payload.getId() promises it can. Both ends of an MPC session // are Codename One, so the framing is symmetric by construction. - JAVA_INT payloadId = 0; - NSData *body = data; - if ([data length] >= 4) { - const unsigned char *b = (const unsigned char *)[data bytes]; - payloadId = (JAVA_INT)((b[0] << 24) | (b[1] << 16) | (b[2] << 8) - | b[3]); - body = [data subdataWithRange:NSMakeRange(4, [data length] - 4)]; + // Frame: one kind byte, four id bytes, then the body. The kind + // distinguishes a payload from the acknowledgement the receiver sends + // back, which is what lets the SENDER report a terminal SUCCESS that + // means "arrived" rather than "queued". + if ([data length] < CN1_NEARBY_FRAME_HEADER) { + return; + } + const unsigned char *b = (const unsigned char *)[data bytes]; + unsigned char kind = b[0]; + JAVA_INT payloadId = (JAVA_INT)((b[1] << 24) | (b[2] << 16) + | (b[3] << 8) | b[4]); + NSString *pid = cn1nbIdForPeer(peerID); + if (kind == CN1_NEARBY_FRAME_ACK) { + // The far side has the bytes. Reported once: a duplicate or + // unknown ack is dropped rather than emitting a second terminal + // status for a payload already finished. + if ([self takeAck:payloadId fromPeer:pid]) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), payloadId, + 0, -1, CN1_NEARBY_PAYLOAD_SUCCESS); + } + return; } + NSData *body = [data subdataWithRange: + NSMakeRange(CN1_NEARBY_FRAME_HEADER, + [data length] - CN1_NEARBY_FRAME_HEADER)]; + // Acknowledged before the payload is handed up, so a listener that + // takes a while cannot delay the sender's terminal status. + [self sendAck:payloadId toPeer:peerID inSession:session]; // The terminal SUCCESS update, for the reason the file path emits // one: a receiver that releases per-payload state or dismisses its // transfer UI on the documented terminal status waited forever on @@ -1391,6 +1488,7 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { cn1nbTransport.everConnected = [NSMutableSet set]; cn1nbTransport.inviting = [NSMutableSet set]; cn1nbTransport.lostWhileConnected = [NSMutableSet set]; + cn1nbTransport.awaitingAck = [NSMutableDictionary dictionary]; cn1nbTransport.sessionsById = [NSMutableDictionary dictionary]; cn1nbTransport.serviceIdByPeer = [NSMutableDictionary dictionary]; } @@ -1460,6 +1558,17 @@ static void cn1nbSettleTransportStart(CN1NearbyTransport *t, BOOL advertising, @interface CN1NearbyCompanion : NSObject @property (nonatomic, retain) ASAccessorySession *session; @property (nonatomic, assign) BOOL activated; +/// Signalled when the session reports itself active. +/// +/// activateWithQueue returns before the session is usable, and the event +/// saying so arrives on the queue it was given. Showing a picker or reading +/// accessories before then made the first association of a fresh process fail +/// and getAssociations answer with an empty list for an app that had +/// associations. +@property (nonatomic, assign) dispatch_semaphore_t activeSignal; +@property (nonatomic, assign) BOOL active; +- (BOOL)awaitActive; +- (void)activate; @end // Typed as id rather than CN1NearbyCompanion *: a file-scope variable of an @@ -1471,9 +1580,34 @@ @implementation CN1NearbyCompanion - (void)dealloc { [_session release]; + if (_activeSignal != NULL) { + dispatch_release(_activeSignal); + } [super dealloc]; } +/// Blocks briefly for the session to become active. +/// +/// Bounded, and never called on the main thread: the activation event is +/// delivered on the main queue, so waiting there would deadlock. Codename One +/// natives run on the EDT, which on iOS is a thread of its own. +- (BOOL)awaitActive { + if (self.active) { + return YES; + } + if (self.activeSignal == NULL || [NSThread isMainThread]) { + return self.active; + } + dispatch_semaphore_wait(self.activeSignal, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2ull * NSEC_PER_SEC))); + // Signalled back, because more than one caller may be waiting and the + // semaphore is a latch rather than a queue. + if (self.active) { + dispatch_semaphore_signal(self.activeSignal); + } + return self.active; +} + /// Encodes an accessory the way NearbyWire.decodeCompanionDevice expects. /// /// The address field carries the per-app CoreBluetooth identifier rather than @@ -1543,9 +1677,10 @@ - (void)activate { return; } self.activated = YES; + self.activeSignal = dispatch_semaphore_create(0); self.session = [[[ASAccessorySession alloc] init] autorelease]; - // The event handler is required to activate the session and is - // deliberately empty. + CN1NearbyCompanion *weakSelf = self; + // The handler records ACTIVATION and nothing else. // // AccessorySetupKit reports an accessory entering or leaving the app's // SET, which is not the same event as it coming into or going out of @@ -1558,6 +1693,10 @@ - (void)activate { // set directly. [self.session activateWithQueue:dispatch_get_main_queue() eventHandler:^(ASAccessoryEvent *event) { + if (event.eventType == ASAccessoryEventTypeActivated) { + weakSelf.active = YES; + dispatch_semaphore_signal(weakSelf.activeSignal); + } }]; } @@ -1569,6 +1708,9 @@ - (void)activate { } CN1NearbyCompanion *companion = (CN1NearbyCompanion *)cn1nbCompanion; [companion activate]; + // Waited for here rather than at each call site, so nothing reaches + // showPicker or session.accessories before the session is usable. + [companion awaitActive]; return companion; } @@ -2446,6 +2588,7 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i // had nothing to report but zero. NSString *sentName = [NSString stringWithFormat:@"cn1id-%d-%@", (int)payloadId, [p lastPathComponent]]; + NSUInteger started = 0; for (NSUInteger i = 0; i < [peers count]; i++) { MCPeerID *peer = [peers objectAtIndex:i]; MCSession *session = [cn1nbTransport @@ -2491,25 +2634,46 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i // kept only the last, so cancel() stopped one transfer and // the other two ran to completion reporting success. if (progress != nil) { + started++; [progressHolder addObject:progress]; [cn1nbTransport rememberProgress:progress forPayload:payloadId]; + } else { + // No NSProgress means the framework did not take the + // transfer -- the peer went away, or it could not be + // scheduled -- so the completion handler will never run + // for it. Reported per recipient, like a failed byte send. + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), + cn1nbJString([cn1nbTransport encodePeer:peer]), + payloadId, 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); } } + if (started == 0) { + // Nothing was handed to the platform, so answering the + // request successfully promised a transfer that will never + // report anything at all. + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, + @"the file could not be handed to any of those" + @" endpoints"); + return; + } cn1nbTransportOk(requestId); return; } NSData *data = cn1nbDataFromJavaArray(bytes); // Framed with the payload id -- see didReceiveData for why. NSMutableData *framed = [NSMutableData dataWithCapacity: - (data == nil ? 0 : [data length]) + 4]; - unsigned char header[4] = { + (data == nil ? 0 : [data length]) + + CN1_NEARBY_FRAME_HEADER]; + unsigned char header[CN1_NEARBY_FRAME_HEADER] = { + CN1_NEARBY_FRAME_DATA, (unsigned char)((payloadId >> 24) & 0xff), (unsigned char)((payloadId >> 16) & 0xff), (unsigned char)((payloadId >> 8) & 0xff), (unsigned char)(payloadId & 0xff) }; - [framed appendBytes:header length:4]; + [framed appendBytes:header length:CN1_NEARBY_FRAME_HEADER]; if (data != nil) { [framed appendData:data]; } @@ -2546,12 +2710,25 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i BOOL ok = [[outcomes objectAtIndex:i] boolValue]; NSString *encoded = [cn1nbTransport encodePeer:[peers objectAtIndex:i]]; + if (!ok) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), + payloadId, 0, (JAVA_LONG)[data length], + CN1_NEARBY_PAYLOAD_FAILURE); + continue; + } + // Queued, not delivered. sendData returning YES says the message + // was accepted for sending, and PayloadStatus.SUCCESS documents + // that every byte ARRIVED -- which is what Android reports, + // because Nearby tells it so. So this reports progress now and + // the terminal status when the receiver's acknowledgement comes + // back, or FAILURE if the peer disconnects first. + [cn1nbTransport awaitAck:payloadId + fromPeer:[peerIds objectAtIndex:i]]; com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, - ok ? (JAVA_LONG)[data length] : 0, - (JAVA_LONG)[data length], - ok ? CN1_NEARBY_PAYLOAD_SUCCESS - : CN1_NEARBY_PAYLOAD_FAILURE); + (JAVA_LONG)[data length], (JAVA_LONG)[data length], + CN1_NEARBY_PAYLOAD_IN_PROGRESS); } if (!sent) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index b8f4f8deed5..774c83acd05 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -237,6 +237,12 @@ kilobytes on both platforms; anything larger goes as a file payload, which streams and reports progress. Call `NearbyTransport.stop()` when the feature's UI closes -- both platforms keep the radios busy until something says stop. +A terminal `PayloadStatus.SUCCESS` means the bytes reached the peer, not that +they were handed to the radio. Watch for it rather than treating a resolved +`send()` as delivery: the resource resolves when the platform accepts the +payload, which is earlier. If the peer disappears between the two you get +`FAILURE`, so every send reaches one terminal status or the other. + === Developing Without Hardware The simulator, the desktop ports and the JavaScript port carry a working From f0db93d4c5055edac6eaf76c2a8daa41998ef381 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:41:06 +0300 Subject: [PATCH 48/94] Address the thirty-fifth nearby review round Both correct fixes made earlier on this branch that turned out to cover less than they looked like they did. - The presence replay marker covers queue EXECUTION, not just submission. dispatchPresence only queues, so clearing the marker on the draining thread released it while the backlog was still waiting to run -- and a live event delivered on the EDT then ran inline in front of it, which is the ordering the marker exists to prevent. It is cleared by a sentinel queued behind the replay, which also picks up anything that parked while the backlog was in flight. - A simulated cancel stays in force until every send carrying that id has settled. The same immutable Payload handed to two send() calls is one portable id across two pending sends, and the single marker was consumed by the first: the second reported SUCCESS and echoed data the app had cancelled. pendingPayloads counts rather than sets, and the marker is read before the count is decremented -- dropping it with the last pending send and then reading it made that send miss its own cancellation. PMD's UnnecessaryFullyQualifiedName on the new field also turned up an orphaned doc fragment left by the edit that introduced it: the previous field's javadoc tail, describing behaviour that no longer existed, sitting above the new one. Both fixed. --- .../impl/nearby/LocalNearbyBridge.java | 50 +++++++++++++------ .../nearby/companion/CompanionDevices.java | 25 +++++++++- .../com/codename1/nearby/LocalNearbyTest.java | 45 +++++++++++++++++ 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 95aac5734e3..966f4fb4f94 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -38,9 +38,12 @@ import com.codename1.ui.Display; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /// A working `com.codename1.nearby` implementation with no radio behind it, /// used by the simulator, the desktop ports and the JavaScript port. @@ -139,17 +142,20 @@ public class LocalNearbyBridge implements NearbyBridge { private int lastAcceptRequestId; /// Payload ids the app has cancelled, so a delivery already queued for /// one can report CANCELED instead of SUCCESS. - private final java.util.Set cancelledPayloads = - new java.util.HashSet(); - /// Payload ids whose send has been queued and not yet delivered. + private final Set cancelledPayloads = new HashSet(); + /// Payload id to the number of queued sends still carrying it. /// - /// A cancel is only worth recording for one of these. Recorded - /// unconditionally, a cancel for an id that had already completed -- or - /// one that was never sent -- sat in the set for good, and reusing the - /// same immutable Payload in a later send() then consumed the stale - /// marker and reported that perfectly good transfer as CANCELED. - private final java.util.Set pendingPayloads = - new java.util.HashSet(); + /// A cancel is only recorded for an id that is in here. Recorded + /// unconditionally, a cancel for a transfer that had already completed -- + /// or an id that was never sent -- sat in the set for good, and reusing + /// the same immutable Payload in a later send() consumed the stale marker + /// and reported that perfectly good transfer as CANCELED. + /// + /// A count rather than a set, because the same Payload can be handed to + /// several send() calls at once: one portable id, several pending sends, + /// and a cancel has to stay in force until the last of them settles. + private final Map pendingPayloads = + new HashMap(); /// Where delayed deliveries go while a test drives the clock, or null in /// normal operation. /// @@ -705,7 +711,10 @@ public List getRejectedEndpoints() { public void sendPayload(final int requestId, final String[] endpointIds, final int payloadId, final int payloadType, final byte[] bytes, final String path) { - pendingPayloads.add(Integer.valueOf(payloadId)); + Integer key = Integer.valueOf(payloadId); + Integer outstanding = pendingPayloads.get(key); + pendingPayloads.put(key, Integer.valueOf( + outstanding == null ? 1 : outstanding.intValue() + 1)); answer(new Runnable() { @Override public void run() { @@ -714,7 +723,20 @@ public void run() { // the caller holding a resolved resource and waiting for a // terminal payloadProgress that could never come, which is // exactly the state transfer UI hangs on. - pendingPayloads.remove(Integer.valueOf(payloadId)); + // Settled: one fewer send carrying this id. The cancel + // marker outlives it and is dropped with the last one. + Integer id = Integer.valueOf(payloadId); + // Read BEFORE the count is decremented: the marker is + // dropped with the last pending send, and this may be it. + boolean cancelled = cancelledPayloads.contains(id); + Integer left = pendingPayloads.get(id); + int remaining = left == null ? 0 : left.intValue() - 1; + if (remaining > 0) { + pendingPayloads.put(id, Integer.valueOf(remaining)); + } else { + pendingPayloads.remove(id); + cancelledPayloads.remove(id); + } boolean any = false; for (String endpointId : endpointIds) { if (findEndpoint(endpointId) != null @@ -730,8 +752,6 @@ public void run() { return; } NearbyTransport.deliverRequestOk(requestId); - boolean cancelled = cancelledPayloads.remove( - Integer.valueOf(payloadId)); for (String endpointId : endpointIds) { final SimEndpoint e = findEndpoint(endpointId); if (e == null || !connected.contains(endpointId)) { @@ -769,7 +789,7 @@ public void cancelPayload(int payloadId) { // Only for a send that is actually pending: cancelling something that // has finished, or an id that was never sent, is a no-op on a real // platform and must not leave a marker behind here either. - if (pendingPayloads.contains(Integer.valueOf(payloadId))) { + if (pendingPayloads.containsKey(Integer.valueOf(payloadId))) { cancelledPayloads.add(Integer.valueOf(payloadId)); } } diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index 998b019bd72..ddf5506a323 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -308,7 +308,18 @@ private static void replayPresence() { List batch; synchronized (LISTENERS) { if (PENDING_PRESENCE.isEmpty()) { - replayingPresence = false; + // Cleared from a runnable queued BEHIND the replay, not + // here. dispatchPresence only queues -- so clearing on + // this thread released the marker while the backlog was + // still waiting to run, and a live event delivered on the + // EDT then ran inline in front of it. The sentinel takes + // its turn after every callback this drain queued. + NearbyRequests.onEdt(new Runnable() { + @Override + public void run() { + finishReplay(); + } + }); return; } batch = new ArrayList(PENDING_PRESENCE); @@ -320,6 +331,18 @@ private static void replayPresence() { } } + /// Ends the replay, or continues it when events parked while the backlog + /// was in flight. + private static void finishReplay() { + synchronized (LISTENERS) { + if (PENDING_PRESENCE.isEmpty()) { + replayingPresence = false; + return; + } + } + replayPresence(); + } + /// Removes a listener added by [#addPresenceListener]. /// /// #### Parameters diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index cf8d7c2b76a..5515d4c1027 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -599,6 +599,51 @@ public void endpointFound(Endpoint e) { NearbyTransport.send(e, Payload.fromBytes(new byte[] {1}))); } + @Test + void cancellingReachesEverySendCarryingThatPayloadId() { + // The same immutable Payload can be handed to two send() calls, which + // is one portable id across two pending sends. Consumed by the first, + // the second reported SUCCESS and echoed data the app had cancelled. + final List progress = + new ArrayList(); + final List received = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void payloadProgress(Endpoint e, PayloadTransferUpdate u) { + progress.add(u); + } + + @Override + public void payloadReceived(Endpoint e, Payload p) { + received.add(p); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + value(NearbyTransport.requestConnection(e, "me")); + + List queue = new ArrayList(); + bridge.deferForTest(queue); + Payload p = Payload.fromBytes(new byte[] {1, 2, 3}); + NearbyTransport.send(e, p); + NearbyTransport.send(e, p); + NearbyTransport.cancel(p.getId()); + drain(queue); + + assertTrue(received.isEmpty(), + "neither send may be delivered: " + received); + assertEquals(2, progress.size()); + for (PayloadTransferUpdate u : progress) { + assertSame(PayloadStatus.CANCELED, u.getStatus()); + } + } + @Test void cancellingAFinishedPayloadDoesNotPoisonTheNextSend() { // Recorded unconditionally, a cancel for an id that had already From 92d2694c19f57529f8603b9750e62f904b5d92d7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:52:37 +0300 Subject: [PATCH 49/94] Address the thirty-sixth nearby review round - Outstanding acknowledgements are COUNTED per peer and payload rather than deduplicated. The same immutable Payload sent to one peer twice before either answer arrived was two accepted sends under one portable id, and a set kept one -- so the first acknowledgement emitted the only terminal status and the second send never got one. A disconnect now strands one update per outstanding send too. - A send naming an endpoint this transport no longer knows fails the handoff. It was dropped from the peer list and the payload went to the rest, resolving successfully, so the omitted recipient got neither the data nor a progress event -- while the same send reports a per-recipient failure for a peer that is merely unreachable. - The companion service starts the Codename One context, so a parked presence event sits in an initialized runtime rather than being dispatched inline on a binder thread, and stops it again on destroy. It does NOT bootstrap the application lifecycle, which was the suggestion. Running an app's init() inside a CompanionDeviceService means running code that may build a Form in a process with no UI thread, and the framework already settled this question for App Intents the other way -- deliverPendingIntentRequests parks anything non-headless until the app is brought forward. Doing it for presence alone would have one feature run app code in a state nothing else does, and would need the generated stub changed in both builder copies. The reasoning is recorded beside the service. What that costs is now stated rather than implied: the javadoc said "the platform launched the app to deliver it" and now says that the platform starting the process does not make the application run, and the guide calls presence a record of what happened while the app was away rather than a background execution mechanism, pointing at the notifications chapter for work that must happen without the app. --- .../nearby/companion/CompanionDevices.java | 17 +++-- .../nearby/CN1CompanionDeviceService.java | 63 +++++++++++++++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 55 +++++++++++++--- docs/developer-guide/Nearby-Devices.asciidoc | 8 +++ 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index ddf5506a323..803de8c9e21 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -270,11 +270,18 @@ public static void stopObservingPresence(String associationId) { /// Registers a presence listener. Callbacks arrive on the EDT. /// /// Register from the app's `init()`: presence is exactly the event that - /// can arrive during a cold start, because the platform launched the app - /// to deliver it. An event that arrived before any listener existed is - /// replayed to the listeners as soon as the first one registers, so a - /// wake-up delivered into a process whose `init()` had not run yet is not - /// lost. At most the 64 most recent are kept. + /// can arrive during a cold start, because the platform may start the + /// process to deliver it. An event that arrived before any listener + /// existed is replayed to the listeners as soon as the first one + /// registers, so a sighting delivered into a process whose `init()` had + /// not run yet is not lost. At most the 64 most recent are kept. + /// + /// This is not background execution. The platform starting the process + /// does not make the application run: Android hands the event to a + /// service, and Codename One does not initialize an app there, because an + /// `init()` may build a `Form` and a service has nowhere to put one. The + /// listener hears about the sighting, in order, when the app next + /// initializes. /// /// #### Parameters /// diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index 2493a207bc7..23408a09372 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -27,7 +27,9 @@ import android.companion.CompanionDeviceService; import android.os.Build; +import com.codename1.impl.android.AndroidImplementation; import com.codename1.nearby.companion.CompanionDevices; +import com.codename1.ui.Display; import java.util.Collections; import java.util.HashSet; @@ -42,10 +44,31 @@ /// appeared while the app was not running. /// /// The platform may start the process for THIS service alone, with no activity -/// and therefore no initialized Codename One and no registered listener yet. -/// The event is not dispatched and dropped in that case: `CompanionDevices` -/// parks it and replays it to the first listener that registers, which in a -/// cold start is the one the app adds from `init()`. +/// and therefore no registered listener yet. The event is not dispatched and +/// dropped in that case: `CompanionDevices` parks it and replays it to the +/// first listener that registers, which in a cold start is the one the app +/// adds from `init()`. +/// +/// #### What this does NOT do, and why +/// +/// It does not run the application's `init()` headlessly, so an app is not +/// executing code the moment a watch walks into range -- it hears about it +/// when it next initializes, replayed in order. +/// +/// It was suggested this service should bootstrap the whole lifecycle. That +/// is a bigger promise than Codename One makes anywhere else on Android, and +/// deliberately so: an app's `init()` is allowed to touch a `Form`, and a +/// service has no UI thread to touch one on. The framework already had to +/// decide this once, for App Intents, and decided the same way -- see the +/// note on `AndroidImplementation.deliverPendingIntentRequests`, where a +/// handler that is not headless can only ask for the app to be brought +/// forward. Inventing a headless-lifecycle contract for presence alone would +/// make this one feature run app code in a state nothing else does. +/// +/// What it does do is start the Codename One context, so the event is parked +/// in an initialized runtime with a real event thread rather than dispatched +/// inline on a binder thread. The public documentation says plainly that the +/// listener hears about the sighting when the app initializes. /// /// The builder writes the `` element that binds this, guarded by /// `android.permission.BIND_COMPANION_DEVICE_SERVICE` and the @@ -54,6 +77,9 @@ @SuppressLint("NewApi") public class CN1CompanionDeviceService extends CompanionDeviceService { + /// Whether this service is the one that started the Codename One context. + private boolean startedContext; + private static final Set OBSERVED = Collections.synchronizedSet(new HashSet()); @@ -80,6 +106,35 @@ public static void unregister(String associationId) { } } + @Override + public void onCreate() { + super.onCreate(); + // Started so the parked event sits in an initialized runtime: without + // it Display.isInitialized() is false and the delivery runs inline on + // whatever binder thread the platform used. + if (!Display.isInitialized()) { + startedContext = true; + try { + AndroidImplementation.startContext(this); + } catch (Throwable notStartable) { + startedContext = false; + } + } + } + + @Override + public void onDestroy() { + if (startedContext) { + startedContext = false; + try { + AndroidImplementation.stopContext(this); + } catch (Throwable alreadyGone) { + // Nothing to do: the context is going away either way. + } + } + super.onDestroy(); + } + @Override public void onDeviceAppeared(AssociationInfo associationInfo) { deliver(associationInfo, true); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 254920925d8..d02e865b16f 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -880,12 +880,20 @@ - (void)sendAck:(JAVA_INT)payloadId toPeer:(MCPeerID *)peer /// Records a payload sent to a peer and waiting for its acknowledgement. - (void)awaitAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { @synchronized (self) { - NSMutableSet *ids = [self.awaitingAck objectForKey:pid]; + NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; if (ids == nil) { - ids = [NSMutableSet set]; + ids = [NSMutableDictionary dictionary]; [self.awaitingAck setObject:ids forKey:pid]; } - [ids addObject:[NSNumber numberWithInt:(int)payloadId]]; + // Counted, not deduplicated. The same immutable Payload sent to one + // peer twice before either answer arrives is two accepted sends under + // one portable id -- and a set kept one, so the first acknowledgement + // emitted the only terminal status and the second send never got one. + NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; + NSNumber *outstanding = [ids objectForKey:key]; + [ids setObject:[NSNumber numberWithInt: + (outstanding == nil ? 1 : [outstanding intValue] + 1)] + forKey:key]; } } @@ -893,12 +901,18 @@ - (void)awaitAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { /// outstanding -- so a duplicate or unknown ack reports nothing. - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { @synchronized (self) { - NSMutableSet *ids = [self.awaitingAck objectForKey:pid]; + NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; - if (![ids containsObject:key]) { + NSNumber *outstanding = [ids objectForKey:key]; + if (outstanding == nil) { return NO; } - [ids removeObject:key]; + int left = [outstanding intValue] - 1; + if (left > 0) { + [ids setObject:[NSNumber numberWithInt:left] forKey:key]; + } else { + [ids removeObjectForKey:key]; + } if ([ids count] == 0) { [self.awaitingAck removeObjectForKey:pid]; } @@ -909,9 +923,18 @@ - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { /// Takes every payload still waiting on a peer, for a disconnect. - (NSArray *)takeAllAcksFromPeer:(NSString *)pid { @synchronized (self) { - NSArray *ids = [[[self.awaitingAck objectForKey:pid] allObjects] copy]; + NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; + NSMutableArray *out = [NSMutableArray array]; + // One entry per outstanding SEND, so two sends of one payload get two + // terminal updates -- the same count they would have got as acks. + for (NSNumber *key in [ids allKeys]) { + int outstanding = [[ids objectForKey:key] intValue]; + for (int i = 0; i < outstanding; i++) { + [out addObject:key]; + } + } [self.awaitingAck removeObjectForKey:pid]; - return [ids autorelease]; + return out; } } @@ -2565,11 +2588,14 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i joinedEndpointIds); NSMutableArray *peers = [NSMutableArray array]; NSMutableArray *peerIds = [NSMutableArray array]; + NSMutableArray *unknown = [NSMutableArray array]; for (NSString *pid in cn1nbSplitLines(joined)) { MCPeerID *peer = [cn1nbTransport peerForId:pid]; if (peer != nil) { [peers addObject:peer]; [peerIds addObject:pid]; + } else { + [unknown addObject:pid]; } } if ([peers count] == 0) { @@ -2577,6 +2603,19 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i @"none of those endpoints is connected"); return; } + if ([unknown count] > 0) { + // A requested endpoint this transport no longer knows is a FAILED + // handoff, not a silent omission. Sending to the rest and + // answering successfully left the omitted recipient with neither + // the data nor a progress event of any kind -- while the same + // send reports a per-recipient failure for a peer that is merely + // unreachable, which is the lesser problem of the two. + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + [NSString stringWithFormat: + @"these endpoints are no longer connected: %@", + [unknown componentsJoinedByString:@", "]]); + return; + } if (payloadType == CN1_NEARBY_PAYLOAD_FILE) { NSString *p = toNSString(CN1_THREAD_STATE_PASS_ARG path); if ([p hasPrefix:@"file://"]) { diff --git a/docs/developer-guide/Nearby-Devices.asciidoc b/docs/developer-guide/Nearby-Devices.asciidoc index 774c83acd05..0005c6fc78d 100644 --- a/docs/developer-guide/Nearby-Devices.asciidoc +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -183,6 +183,14 @@ point. An event that arrives before any listener is registered is held and replayed to the first one that registers, so a wake-up isn't lost, but only the 64 most recent are kept. +Presence doesn't run your code the moment a device comes into range. Android +can start the process for the companion service alone, and Codename One +doesn't run an application's `init()` there -- a form has nowhere to live in a +service. Your listener hears about the sighting, in order, when the app next +initializes. Treat presence as a record of what happened while the app was away rather than +as a background execution mechanism; for work that must happen without the +app, use the background features in the notifications chapter. + === Transport: Sending Something [source,java] From 0a337dd6cb05b5cd728629eb05edbfff36b6d6cf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:00:37 +0300 Subject: [PATCH 50/94] Address the thirty-seventh nearby review round - A deliberate close fails its outstanding acknowledgements. Clearing the delegate is what stops didChangeState from reaching takeAllAcksFromPeer, so disconnect() or stop() left an accepted byte send with no terminal status at all and its bookkeeping alive until a full stop swept it -- the hole the acknowledgement mechanism exists to close, reopened by the explicit-disconnect path added earlier on this branch. They are drained and failed beside the disconnection event. - A failed advertising-only peer no longer leaks its metadata. A peer that arrives through advertising was never discovered, so a rejected or failed handshake gets neither onEndpointLost, which only fires for something discovery saw, nor onDisconnected, which needs a connection: each failed request kept its name and service id for the life of the process, and the containsKey guard in onConnectionInitiated then preserved that stale service id when the same endpoint id came back under another advertised service. Telling those apart needs discovery visibility tracked separately from connection state, which nothing did -- discoveredEndpoints does now. --- .../nearby/AndroidNearbyTransport.java | 26 +++++++++++++++++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 15 +++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 21bb566e8eb..a921ba80ee9 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -95,6 +95,15 @@ public class AndroidNearbyTransport implements NearbyBridge { /// discovering service A had the later call overwrite it, and endpoints /// found under A were then encoded as B -- which Endpoint.getServiceId() /// documents as the service they were found under. + /// Endpoints discovery can currently see. + /// + /// A peer that arrived through ADVERTISING was never discovered, so a + /// rejected or failed handshake leaves it with no callback coming at all + /// -- no onEndpointLost, no onDisconnected. Only this tells such a peer + /// from one discovery is still showing, whose metadata has to stay. + private final java.util.Set discoveredEndpoints = + java.util.Collections.synchronizedSet( + new java.util.HashSet()); /// Endpoints currently connected. /// /// stop() has to clear the metadata of endpoints nothing will call back @@ -456,6 +465,7 @@ public void stopAllTransport() { for (String id : discoveredOnly) { endpointNames.remove(id); endpointServices.remove(id); + discoveredEndpoints.remove(id); } } payloadIds.clear(); @@ -473,6 +483,7 @@ private EndpointDiscoveryCallback discoveryCallback( @Override public void onEndpointFound(String endpointId, DiscoveredEndpointInfo info) { + discoveredEndpoints.add(endpointId); endpointNames.put(endpointId, info.getEndpointName()); endpointServices.put(endpointId, serviceId); NearbyTransport.deliverEndpointFound( @@ -483,6 +494,7 @@ public void onEndpointFound(String endpointId, public void onEndpointLost(String endpointId) { NearbyTransport.deliverEndpointFound( encode(endpointId, nameOf(endpointId)), false); + discoveredEndpoints.remove(endpointId); endpointNames.remove(endpointId); // The service mapping goes with it. Nearby reuses endpoint // ids, so a peer lost under the discovery service could come @@ -521,6 +533,20 @@ public void onConnectionResult(String endpointId, connectedEndpoints.add(endpointId); } else { connectedEndpoints.remove(endpointId); + if (!discoveredEndpoints.contains(endpointId)) { + // Arrived through advertising and never connected, so + // nothing else will ever call back about it: neither + // onEndpointLost, which only fires for something + // discovery saw, nor onDisconnected, which needs a + // connection. Left behind, each failed request kept + // its name and service id for the life of the + // process -- and the containsKey guard in + // onConnectionInitiated then preserved that stale + // service id if the same endpoint id came back under + // another advertised service. + endpointNames.remove(endpointId); + endpointServices.remove(endpointId); + } } NearbyTransport.deliverConnectionResult( encode(endpointId, nameOf(endpointId)), ok, diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index d02e865b16f..54c3d16553e 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -762,9 +762,20 @@ - (void)closeSessionFor:(NSString *)endpointId { if ([self takeEverConnected:endpointId]) { MCPeerID *peer = [self peerForId:endpointId]; if (peer != nil) { + NSString *encoded = [self encodePeer:peer]; + // Anything queued for this peer and still unacknowledged has to + // be failed HERE. Clearing the delegate above is what stops + // didChangeState from reaching takeAllAcksFromPeer, so a + // deliberate close left an accepted send with no terminal status + // at all -- and its bookkeeping alive until a full stop swept it. + for (NSNumber *stranded in [self takeAllAcksFromPeer:endpointId]) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), + (JAVA_INT)[stranded intValue], 0, -1, + CN1_NEARBY_PAYLOAD_FAILURE); + } com_codename1_impl_ios_IOSNearbyCallbacks_disconnected___java_lang_String( - getThreadLocalData(), - cn1nbJString([self encodePeer:peer])); + getThreadLocalData(), cn1nbJString(encoded)); } } } From f2eb4866dbdff559cf8637e214adfce12390b64e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:09:18 +0300 Subject: [PATCH 51/94] Address the thirty-eighth nearby review round - Android accessory ranging answers the map its caller waits on. startAccessory registers its resource in PENDING_ACCESSORY, and delegating to startRanging settled through deliverSessionStarted, which only looks in PENDING_SESSIONS -- so the radio started and the caller waited for an answer that had been handed to nobody. The start is marked and settled through deliverAccessoryConfiguration with no bytes, which is what the SPI documents for a platform that needs no handshake back. deliverRequestFailed already consults both maps, so only the success path was wrong; the flag is cleared on failure so a retry through the ordinary start does not inherit it. Not failed with NOT_SUPPORTED, which was the suggestion: accessory ranging does work on Android -- it is joining a session the accessory published out of band, which is what the token carries -- so refusing it would remove a working capability to fix a plumbing bug. - A connected endpoint keeps its metadata when discovery loses sight of it. onEndpointLost cleared the name and service immediately, while payload callbacks and the eventual onDisconnected still encode through those maps, so a listener saw empty metadata for the rest of a live connection. onDisconnected already does the final cleanup. This is the Android twin of the forgetPeer guard added to the iOS transport earlier on this branch. --- .../nearby/AndroidNearbyTransport.java | 8 ++++ .../android/nearby/AndroidUwbRanging.java | 42 +++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index a921ba80ee9..b87cb9ae3ce 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -495,6 +495,14 @@ public void onEndpointLost(String endpointId) { NearbyTransport.deliverEndpointFound( encode(endpointId, nameOf(endpointId)), false); discoveredEndpoints.remove(endpointId); + if (connectedEndpoints.contains(endpointId)) { + // Still connected, so the metadata has to stay: payload + // callbacks and the eventual onDisconnected encode this + // endpoint through nameOf(), and dropping it here handed + // the listener an empty name for the rest of a live + // connection. onDisconnected does the final cleanup. + return; + } endpointNames.remove(endpointId); // The service mapping goes with it. Nearby reuses endpoint // ids, so a peer lost under the discovery service could come diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index f4cb4af9a81..ca81057203b 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -378,8 +378,22 @@ public void startAccessoryRanging(int requestId, int sessionHandle, byte[] accessoryData) { // An accessory on Android is not a protocol, it is a set of session // parameters the accessory published out of band -- which is exactly - // what a token is. So the two paths are the same one, and the public - // API documents building the token with RangingToken.forUwbAddress. + // what a token is. So the radio work is the same as an ordinary + // start, and the public API documents building the token with + // RangingToken.forUwbAddress. + // + // The ANSWER is not the same, though, and delegating to startRanging + // sent it to the wrong place: startAccessory registers its resource + // in PENDING_ACCESSORY and deliverSessionStarted only looks in + // PENDING_SESSIONS, so the radio started and the caller waited for an + // answer that had already been given to nobody. Marked so the start + // is settled through deliverAccessoryConfiguration instead -- with no + // bytes, which is what the SPI documents for a platform that needs no + // handshake back. + Session session = sessions.get(Integer.valueOf(sessionHandle)); + if (session != null) { + session.accessoryStart = true; + } startRanging(requestId, sessionHandle, accessoryData); } @@ -444,6 +458,13 @@ public void accept(Throwable error) { // session isClosed() still reported as open. // The scope is still valid; only this // subscription failed. + // + // deliverRequestFailed looks in both pending + // maps, so an accessory start is answered + // here too -- but the flag is cleared so a + // retry through the ordinary start does not + // inherit it. + session.accessoryStart = false; fail(pending, NearbyError.SESSION_FAILED, message(error)); return; @@ -464,9 +485,19 @@ public void accept(Throwable error) { /// Answers the start request if it is still waiting. private static void settleStarted(Session session) { int pending = session.startRequest.getAndSet(0); - if (pending != 0) { - Ranging.deliverSessionStarted(pending, session.handle); + if (pending == 0) { + return; + } + if (session.accessoryStart) { + session.accessoryStart = false; + // No bytes: Android's accessory ranging is joining a session the + // accessory already published, so there is nothing to hand back + // to it. The SPI documents an empty array for exactly this. + Ranging.deliverAccessoryConfiguration(pending, session.handle, + new byte[0]); + return; } + Ranging.deliverSessionStarted(pending, session.handle); } /// How long a start is given to fail before it is called a success. @@ -631,6 +662,9 @@ private static final class Session { /// measurement, the first error, or the grace timer gets there. private final java.util.concurrent.atomic.AtomicInteger startRequest = new java.util.concurrent.atomic.AtomicInteger(); + /// Whether the pending start came from startAccessory, whose caller + /// waits on a different pending map. + private boolean accessoryStart; private Session(int handle, boolean controller) { this.handle = handle; From 975daedaaa8e96ca5445838001197751ec99e307 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:18:00 +0300 Subject: [PATCH 52/94] Address the thirty-ninth nearby review round Three more of one mistake: cleaning up before the callback that needed the data. Emit the event first, clean up after. - The connectionFailed event is encoded before the advertising-only peer's metadata is removed. The cleanup added last round ran first, so the listener got an empty name for exactly the inbound failures that cleanup exists for. - stop() no longer clears the transfer maps. stopAllEndpoints produces terminal payload callbacks asynchronously, and those callbacks are what map a platform id back to the portable one and what turn an incoming file into a delivered payload -- so clearing immediately made an outgoing terminal update fall back to Google's id, and an incoming file report SUCCESS with its entry already discarded so payloadReceived never followed. Each entry is removed by its own terminal update; what a vanished endpoint strands is bounded by the transfers in flight at the stop, and a later send overwrites by platform id. - The simulator rejects a send naming any unavailable recipient, as the iOS transport now does. Skipping it and answering successfully left that recipient with neither delivery nor failure, and let a desktop test pass for a send the real ports refuse -- which is the specific way a simulator stops earning its keep. --- .../impl/nearby/LocalNearbyBridge.java | 23 +++++---- .../nearby/AndroidNearbyTransport.java | 47 ++++++++++++------- .../com/codename1/nearby/LocalNearbyTest.java | 17 +++++++ 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 966f4fb4f94..d2f1d013021 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -737,26 +737,29 @@ public void run() { pendingPayloads.remove(id); cancelledPayloads.remove(id); } - boolean any = false; + // EVERY requested endpoint has to be available, not just + // one. Skipping the unavailable ones and answering + // successfully left the omitted recipient with neither + // delivery nor failure -- and let a desktop test pass for a + // send the real ports refuse. The iOS transport rejects the + // same case. + List unavailable = new ArrayList(); for (String endpointId : endpointIds) { - if (findEndpoint(endpointId) != null - && connected.contains(endpointId)) { - any = true; - break; + if (findEndpoint(endpointId) == null + || !connected.contains(endpointId)) { + unavailable.add(endpointId); } } - if (!any) { + if (!unavailable.isEmpty()) { NearbyTransport.deliverRequestFailed(requestId, NearbyError.PEER_UNAVAILABLE.ordinal(), - "none of those endpoints is connected"); + "these endpoints are not connected: " + + unavailable); return; } NearbyTransport.deliverRequestOk(requestId); for (String endpointId : endpointIds) { final SimEndpoint e = findEndpoint(endpointId); - if (e == null || !connected.contains(endpointId)) { - continue; - } long total = payloadType == PAYLOAD_BYTES && bytes != null ? bytes.length : -1; if (cancelled) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index b87cb9ae3ce..b7d17dae25a 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -468,9 +468,19 @@ public void stopAllTransport() { discoveredEndpoints.remove(id); } } - payloadIds.clear(); - payloadRecipients.clear(); - incomingFiles.clear(); + // The transfer maps are NOT cleared here either. stopAllEndpoints + // produces terminal payload callbacks asynchronously, and those + // callbacks are what map a platform id back to the portable one and + // what turn an incoming file into a delivered payload -- so clearing + // now made an outgoing terminal update fall back to Google's id, and + // an incoming file report SUCCESS with its entry already discarded so + // payloadReceived never followed. + // + // Each entry is removed by its own terminal update. What a vanished + // endpoint strands is bounded by the transfers in flight at the stop, + // and a later send overwrites by platform id, so the residue is a + // handful of Long-to-Integer entries rather than a leak worth racing + // the callbacks to clear. } // ------------------------------------------------------------------ @@ -541,25 +551,28 @@ public void onConnectionResult(String endpointId, connectedEndpoints.add(endpointId); } else { connectedEndpoints.remove(endpointId); - if (!discoveredEndpoints.contains(endpointId)) { - // Arrived through advertising and never connected, so - // nothing else will ever call back about it: neither - // onEndpointLost, which only fires for something - // discovery saw, nor onDisconnected, which needs a - // connection. Left behind, each failed request kept - // its name and service id for the life of the - // process -- and the containsKey guard in - // onConnectionInitiated then preserved that stale - // service id if the same endpoint id came back under - // another advertised service. - endpointNames.remove(endpointId); - endpointServices.remove(endpointId); - } } + // Encoded BEFORE anything is removed: this is the event that + // names the endpoint, and clearing first handed the listener + // an empty name for exactly the inbound failures the cleanup + // below exists for. NearbyTransport.deliverConnectionResult( encode(endpointId, nameOf(endpointId)), ok, ok ? 0 : NearbyError.SESSION_FAILED.ordinal(), ok ? null : resolution.getStatus().getStatusMessage()); + if (!ok && !discoveredEndpoints.contains(endpointId)) { + // Arrived through advertising and never connected, so + // nothing else will ever call back about it: neither + // onEndpointLost, which only fires for something + // discovery saw, nor onDisconnected, which needs a + // connection. Left behind, each failed request kept its + // name and service id for the life of the process -- and + // the containsKey guard in onConnectionInitiated then + // preserved that stale service id if the same endpoint id + // came back under another advertised service. + endpointNames.remove(endpointId); + endpointServices.remove(endpointId); + } } @Override diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 5515d4c1027..e883ea58bca 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -578,6 +578,23 @@ void observingSomethingThatIsNotAssociatedIsRefused() { // transport // ------------------------------------------------------------------ + @Test + void sendingToAMixOfConnectedAndUnavailableFailsRatherThanPartlySending() { + // Skipping the unavailable one and answering successfully left that + // recipient with neither delivery nor failure, and let a desktop test + // pass for a send the real ports refuse. + List found = discoverAll(TransportStrategy.CLUSTER); + assertTrue(found.size() >= 2, "need two synthetic peers to test this"); + Endpoint connected = found.get(0); + Endpoint neverConnected = found.get(1); + assertTrue(value(NearbyTransport.requestConnection(connected, "me")) + .booleanValue()); + + assertFailedWith(NearbyError.PEER_UNAVAILABLE, + NearbyTransport.send(new Endpoint[] {connected, neverConnected}, + Payload.fromBytes(new byte[] {1}))); + } + @Test void sendingToNobodyFailsRatherThanResolvingWithNothingInIt() { // Answering ok and then skipping every recipient left the caller From 715f9718ec87a3daa4f4c8be11f83a9ed07bdec3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:26:10 +0300 Subject: [PATCH 53/94] Propagate an AccessorySetupKit activation failure The wait added last round answered whether the session became active and the answer was thrown away, so an inactive session was handed back anyway -- and the failure surfaced as a spuriously failing picker, an empty association list, or "no such association" on disassociate. All three blame the accessory for something that was the session's fault. cn1nbCompanionInit returns nil when activation did not happen, and each call site answers on its own terms: associate and disassociate fail with RADIO_UNAVAILABLE naming AccessorySetupKit, so it is distinguishable from no such association; getAssociations returns empty, because it is synchronous in the public API and has no error channel at all. That is noted where it happens rather than left looking like a clean outcome. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 29 +++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 54c3d16553e..8df82bf2509 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1743,8 +1743,13 @@ - (void)activate { CN1NearbyCompanion *companion = (CN1NearbyCompanion *)cn1nbCompanion; [companion activate]; // Waited for here rather than at each call site, so nothing reaches - // showPicker or session.accessories before the session is usable. - [companion awaitActive]; + // showPicker or session.accessories before the session is usable -- and + // nil when it never became usable, so a caller cannot proceed on an + // inactive session and blame the result on the accessory. Activation can + // fail outright, not only run late. + if (![companion awaitActive]) { + return nil; + } return companion; } @@ -2193,6 +2198,12 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan return; } CN1NearbyCompanion *companion = cn1nbCompanionInit(); + if (companion == nil) { + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_RADIO_UNAVAILABLE, + @"AccessorySetupKit did not become active"); + return; + } // Taken BEFORE the picker opens, so the accessory it adds can be // told apart from the ones this app already had. NSMutableSet *before = [NSMutableSet set]; @@ -2258,6 +2269,12 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan if (@available(iOS 18.0, *)) { @autoreleasepool { CN1NearbyCompanion *companion = cn1nbCompanionInit(); + if (companion == nil) { + // getAssociations is synchronous and has no error channel, so + // an inactive session can only answer with nothing. The + // operations that CAN report a failure do. + return cn1nbJString(@""); + } NSMutableArray *lines = [NSMutableArray array]; for (ASAccessory *a in companion.session.accessories) { [lines addObject:[companion encode:a present:NO]]; @@ -2276,6 +2293,14 @@ void com_codename1_impl_ios_IOSNative_nearbyDisassociate___int_java_lang_String( if (@available(iOS 18.0, *)) { @autoreleasepool { CN1NearbyCompanion *companion = cn1nbCompanionInit(); + if (companion == nil) { + // Distinguished from "no such association", which is what an + // inactive session used to look like. + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_RADIO_UNAVAILABLE, + @"AccessorySetupKit did not become active"); + return; + } NSString *aid = toNSString(CN1_THREAD_STATE_PASS_ARG associationId); ASAccessory *accessory = [companion accessoryForId:aid]; if (accessory == nil) { From 389b0e3711782376664b0346a5f45eb6139acc1d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:34:27 +0300 Subject: [PATCH 54/94] Address the fortieth nearby review round - The accessory marker is cleared on the synchronous failures that never reach the subscription. startAccessoryRanging sets it before startRanging, whose early exits -- a token that will not decode, a throw before subscribe -- left it set: an ordinary start() retried on the same still-open session was then mistaken for an accessory start and answered into PENDING_ACCESSORY, so the real resource never settled. Both paths clear it, and the pending request id with it. - The nearby-Wi-Fi and location grants are scoped to the operations that scan or broadcast. Requesting only CONNECT prompted for NEARBY_WIFI_DEVICES and then failed outright if the user declined something that operation never needed. Scoped to discovery OR advertise rather than to discovery alone, which was the suggestion: Nearby Connections advertises over BLE and brings up Wi-Fi to carry the payload, so an advertise that cannot use them does not start. That fixes the case reported without breaking advertising for an app that follows the permission contract literally. The reasoning is recorded beside the gate. --- .../impl/android/nearby/AndroidUwbRanging.java | 11 +++++++++++ .../impl/android/nearby/NearbyPermissions.java | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index ca81057203b..f9ae0b04528 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -368,6 +368,13 @@ public void startRanging(final int requestId, final int sessionHandle, try { peer = Peer.decode(peerToken); } catch (IllegalArgumentException e) { + // The marker goes with the failure. It is set before this point + // by startAccessoryRanging, and a token that will not decode + // never reaches the subscription that would clear it -- so an + // ordinary start() retried on the same still-open session was + // mistaken for an accessory start and answered into + // PENDING_ACCESSORY, leaving the real resource unsettled. + session.accessoryStart = false; fail(requestId, NearbyError.INVALID_TOKEN, e.getMessage()); return; } @@ -478,6 +485,10 @@ public void accept(Throwable error) { }); scheduleStartGrace(session); } catch (Throwable t) { + // Cleared for the reason the token failure above clears it: this + // start never reached the subscription. + session.accessoryStart = false; + session.startRequest.set(0); fail(requestId, NearbyError.SESSION_FAILED, message(t)); } } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java index 151bc842d74..b1042ac37bb 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java @@ -96,6 +96,22 @@ static List transportPermissions(Context context, out.add("android.permission.BLUETOOTH_CONNECT"); } } + // The nearby-Wi-Fi and location grants belong to the operations that + // SCAN or BROADCAST, so an app asking only to CONNECT to an endpoint + // it has already discovered is not made to answer for them -- and no + // longer fails because it declined something it never needed. + // + // It was suggested these belong to DISCOVERY alone. Advertising needs + // them too: Nearby Connections advertises over BLE and brings up + // Wi-Fi to carry the payload, which is the same radio use discovery + // asks about, and an advertise that cannot use them does not start. + // So the gate is discovery OR advertise, not discovery alone. + boolean scansOrBroadcasts = (permissionBits + & (NearbyBridge.PERMISSION_DISCOVERY + | NearbyBridge.PERMISSION_ADVERTISE)) != 0; + if (!scansOrBroadcasts) { + return out; + } if (sdk >= 33) { out.add("android.permission.NEARBY_WIFI_DEVICES"); } else { From 8b804fc334f91ec9fbdbd790de969189b65bb7e9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:42:28 +0300 Subject: [PATCH 55/94] Address the forty-first nearby review round - Closing a session releases its invitation slot. Clearing the delegate is what stops the state callback that normally releases it, so closing a session whose invitation had not been answered left the slot held and every later STAR or POINT_TO_POINT request answered BUSY until the whole transport was stopped. That is the third thing the delegate-clearing turned out to hide, after the disconnection event and the outstanding acknowledgements. Each was found separately; the deliberate-close path now does all three. - A partially scheduled file handoff fails, as the byte path already did. The two disagreed on identical input: one recipient's transfer never started and the file send still resolved successfully, telling the caller the payload was with the platform. The transfers that DID start are cancelled with it. Failing the aggregate while they ran on would have delivered the file to some recipients after the app was told the send failed -- and unlike queued bytes, a file transfer can still be recalled. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 32 ++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 8df82bf2509..45559b817c2 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -750,6 +750,12 @@ - (void)closeSessionFor:(NSString *)endpointId { // delegate queue that is trying to take the same monitor. session.delegate = nil; [session disconnect]; + // The reservation goes too. Clearing the delegate above is what stops the + // state callback that normally releases it, so closing a session whose + // invitation had not been answered left the slot held -- and every later + // STAR or POINT_TO_POINT request answered BUSY until the whole transport + // was stopped. + [self clearInviting:endpointId]; // Reported here, because clearing the delegate above is what stops // didChangeState:NotConnected from reporting it. A deliberate close is // still a disconnection as far as the app is concerned, and suppressing @@ -2724,13 +2730,27 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i payloadId, 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); } } - if (started == 0) { - // Nothing was handed to the platform, so answering the - // request successfully promised a transfer that will never - // report anything at all. + if (started != [peers count]) { + // EVERY requested transfer has to have started, not just one. + // A partial handoff answered successfully told the caller the + // payload was with the platform while one recipient's + // transfer had never begun -- and the byte path fails the + // same case, so the two differed on identical input. + // + // The ones that DID start are cancelled, because a send the + // app has been told failed must not go on to deliver the + // file to some of its recipients. Bytes cannot be recalled + // once queued; a file can. + for (NSProgress *partial in + [cn1nbTransport takeProgressesForPayload:payloadId]) { + [partial cancel]; + } cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, - @"the file could not be handed to any of those" - @" endpoints"); + started == 0 + ? @"the file could not be handed to any of those" + @" endpoints" + : @"the file could not be handed to every one of" + @" those endpoints"); return; } cn1nbTransportOk(requestId); From 8099690069fe6fd12c3579ba2d7b60a8c35cf7c6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:55:56 +0300 Subject: [PATCH 56/94] Address the forty-second nearby review round - A synchronous refusal from associate() settles the request. The platform can throw instead of calling onFailure -- a profile whose companion permission the manifest never declared -- and that escaped the method with the pending slot taken and the activity-result listener installed, so the caller's AsyncResource never settled and every later association answered BUSY. It now unwinds exactly what the asynchronous failure path unwinds. - A byte send stops waiting forever for an acknowledgement that is not coming. The acknowledgement is itself an ordinary send on the far side: it can fail to leave, or leave and be lost, with the session staying up the whole time -- and disconnection was the only thing that drained the bookkeeping. Thirty seconds without one now fails that send rather than leaving it with no terminal status at all. - Invitations and sightings from a replaced advertiser or browser are no longer attributed to its replacement. Clearing the delegate does not recall a callback already in flight, which the didNotStart guards already knew; the same is true of the callbacks that carry a peer, and labelling those with the CURRENT service id offered the app a peer that answered a different advertisement. A stale invitation is DECLINED rather than dropped, because the remote side is waiting on that handler. - Companion operations resume from the activation handler instead of blocking the EDT on it. AccessorySetupKit is not usable until it says so, and the thread that waited is the thread that draws: the first companion call of a process froze input and rendering for up to two seconds. getAssociations keeps the bounded wait, and a comment says why -- the portable API returns the associations from the call, so there is nowhere to resume to. --- .../android/nearby/AndroidNearbyBackend.java | 26 +- Ports/iOSPort/nativeSources/CN1Nearby.m | 313 +++++++++++++----- quality-report.md | 26 +- 3 files changed, 261 insertions(+), 104 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 1770d9ed219..dca17428079 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -485,7 +485,31 @@ public void associate(final int requestId, int profile, + " it has finished"); return; } - cdm.associate(request.build(), new CompanionDeviceManager.Callback() { + // associate() can refuse SYNCHRONOUSLY -- a SecurityException for a + // profile whose REQUEST_COMPANION_PROFILE_* permission the manifest + // does not declare, which happens when the matching + // android.nearby.*Profile hint was not set. Unguarded, that escaped + // past this method with pendingAssociateRequest still set and the + // result listener still installed: the AsyncResource never settled + // and every later association answered BUSY. + try { + associateNow(cdm, request.build(), requestId); + } catch (Throwable refused) { + pendingAssociateRequest = 0; + releaseResultListener(); + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.NOT_SUPPORTED.ordinal(), + "the platform refused this association request: " + + refused.getMessage()); + } + } + + /// The associate call itself, split out so the caller can catch a + /// synchronous refusal without wrapping the callback wiring too. + @SuppressLint("MissingPermission") + private void associateNow(CompanionDeviceManager cdm, + AssociationRequest request, final int requestId) { + cdm.associate(request, new CompanionDeviceManager.Callback() { @Override public void onDeviceFound(IntentSender chooserLauncher) { launch(chooserLauncher, requestId); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 45559b817c2..eccb2478203 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -423,6 +423,20 @@ static void cn1nbSettleRangingStart(CN1NearbyRangingSession *entry) #ifdef CN1_NEARBY_HAS_MPC +/// How long a sent byte payload waits for the receiver's acknowledgement. +/// +/// The acknowledgement is what turns a queued send into SUCCESS, and it is +/// itself an ordinary reliable send on the far side -- it can fail to leave +/// the receiver without the session going down, and the frame can be lost +/// with the peer still connected. Either way nothing would arrive here and +/// the send would never reach a terminal status at all, which is worse than +/// reporting it late: an app waiting on that update waits forever. +/// +/// Thirty seconds is far longer than the round trip for a payload small +/// enough to travel in one frame, so a timeout means something really is +/// wrong rather than that the link is slow. +#define CN1_NEARBY_ACK_TIMEOUT_NS (30ull * NSEC_PER_SEC) + @interface CN1NearbyTransport : NSObject @property (nonatomic, retain) MCPeerID *localPeer; @@ -876,8 +890,10 @@ - (NSUInteger)heldPeerCount { /// Sends the one-frame acknowledgement for a received payload. /// -/// Best effort: if it cannot be sent the sender falls back to its disconnect -/// handling, which is the same outcome as the peer going away. +/// Best effort: a failure here cannot be reported to the sender, which is +/// precisely why the sender does not wait on it forever. If the frame never +/// leaves, or leaves and is lost, the sender's own acknowledgement timeout +/// fails that send rather than leaving it outstanding. - (void)sendAck:(JAVA_INT)payloadId toPeer:(MCPeerID *)peer inSession:(MCSession *)session { unsigned char frame[CN1_NEARBY_FRAME_HEADER]; @@ -894,6 +910,28 @@ - (void)sendAck:(JAVA_INT)payloadId toPeer:(MCPeerID *)peer error:&ignored]; } +/// Fails a send whose acknowledgement has not arrived in time. +/// +/// Scheduled for every recorded send. It is a no-op in the normal case -- +/// by then the acknowledgement, or the peer's disconnection, has already +/// taken the entry, and taking it is what decides who reports the terminal +/// status. +- (void)scheduleAckTimeout:(JAVA_INT)payloadId fromPeer:(NSString *)pid + encoded:(NSString *)encoded { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)CN1_NEARBY_ACK_TIMEOUT_NS), + dispatch_get_main_queue(), ^{ + @autoreleasepool { + if (![self takeAck:payloadId fromPeer:pid]) { + return; + } + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), payloadId, + 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); + } + }); +} + /// Records a payload sent to a peer and waiting for its acknowledgement. - (void)awaitAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { @synchronized (self) { @@ -1345,6 +1383,18 @@ - (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser withContext:(NSData *)context invitationHandler:(void (^)(BOOL, MCSession *))invitationHandler { @autoreleasepool { + if (advertiser != self.advertiser) { + // From an advertiser that has since been replaced. Labelling it + // with the CURRENT advertiseServiceId would have handed the app + // an invitation attributed to a service it was never advertised + // on, and accepting it would have joined a peer that answered a + // different advertisement. Declined rather than dropped: the + // handler is what the remote side is waiting on, and dropping it + // leaves that peer hanging until MultipeerConnectivity times the + // invitation out. + invitationHandler(NO, nil); + return; + } NSString *pid = cn1nbIdForPeer(peerID); NSString *encoded = [self encodePeer:peerID service:self.advertiseServiceId]; @@ -1389,6 +1439,13 @@ - (void)browser:(MCNearbyServiceBrowser *)browser foundPeer:(MCPeerID *)peerID withDiscoveryInfo:(NSDictionary *)info { @autoreleasepool { + if (browser != self.browser) { + // A sighting from a browser that has since been replaced, + // reported under the service id of its replacement. The app + // would then hold an endpoint the live browser never found and + // will never report lost. + return; + } NSString *encoded = [self encodePeer:peerID service:self.discoverServiceId]; com_codename1_impl_ios_IOSNearbyCallbacks_endpointFound___java_lang_String_boolean( @@ -1399,6 +1456,12 @@ - (void)browser:(MCNearbyServiceBrowser *)browser - (void)browser:(MCNearbyServiceBrowser *)browser lostPeer:(MCPeerID *)peerID { @autoreleasepool { + if (browser != self.browser) { + // The other half of the same mislabelling, and the peer is NOT + // forgotten here either: the mapping is shared with the live + // browser, which may have found this peer under its own service. + return; + } NSString *encoded = [self encodePeer:peerID service:self.discoverServiceId]; com_codename1_impl_ios_IOSNearbyCallbacks_endpointFound___java_lang_String_boolean( @@ -1607,7 +1670,10 @@ @interface CN1NearbyCompanion : NSObject /// associations. @property (nonatomic, assign) dispatch_semaphore_t activeSignal; @property (nonatomic, assign) BOOL active; +/// Blocks queued by whenActive: while the session is still coming up. +@property (nonatomic, retain) NSMutableArray *activationWaiters; - (BOOL)awaitActive; +- (void)whenActive:(void (^)(BOOL active))handler; - (void)activate; @end @@ -1620,6 +1686,7 @@ @implementation CN1NearbyCompanion - (void)dealloc { [_session release]; + [_activationWaiters release]; if (_activeSignal != NULL) { dispatch_release(_activeSignal); } @@ -1631,6 +1698,14 @@ - (void)dealloc { /// Bounded, and never called on the main thread: the activation event is /// delivered on the main queue, so waiting there would deadlock. Codename One /// natives run on the EDT, which on iOS is a thread of its own. +/// +/// This is the LAST resort and only getAssociations still uses it. Everything +/// that can be resumed later goes through whenActive: instead, because the +/// EDT is the thread that draws: waiting on it stalls input and rendering for +/// as long as activation takes. getAssociations cannot follow, because the +/// portable API returns the associations from the call -- there is nowhere to +/// resume to, and answering empty on the way past is the very bug the wait +/// was added for, an app with associations told it had none. - (BOOL)awaitActive { if (self.active) { return YES; @@ -1712,6 +1787,48 @@ - (ASAccessory *)accessoryForId:(NSString *)associationId { return match; } +/// Runs `handler` once the session is active, WITHOUT blocking the caller. +/// +/// Called with NO when activation does not arrive in time, so a queued +/// operation always settles rather than being forgotten. The handler runs on +/// the caller's thread when the session is already active and on the main +/// queue otherwise, which is where the activation event and the timeout both +/// land. +- (void)whenActive:(void (^)(BOOL active))handler { + [self activate]; + if (self.active) { + handler(YES); + return; + } + @synchronized (self) { + if (self.activationWaiters == nil) { + self.activationWaiters = [NSMutableArray array]; + } + [self.activationWaiters addObject:[[handler copy] autorelease]]; + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(2ull * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + @autoreleasepool { + // Drains whatever is still queued, which is nothing at all in the + // ordinary case: activation got there first and took them. + [self drainWaiters:self.active]; + } + }); +} + +/// Hands every queued block the activation outcome, exactly once each. +- (void)drainWaiters:(BOOL)activeNow { + NSArray *waiting; + @synchronized (self) { + waiting = [[self.activationWaiters copy] autorelease]; + [self.activationWaiters removeAllObjects]; + } + for (void (^handler)(BOOL) in waiting) { + handler(activeNow); + } +} + - (void)activate { if (self.activated) { return; @@ -1736,12 +1853,29 @@ - (void)activate { if (event.eventType == ASAccessoryEventTypeActivated) { weakSelf.active = YES; dispatch_semaphore_signal(weakSelf.activeSignal); + [weakSelf drainWaiters:YES]; } }]; } @end +/// Hands the activated session to `handler`, or nil when it never activated. +/// +/// The deferring counterpart of cn1nbCompanionInit, for the operations that +/// have somewhere to resume to -- everything with a requestId, which is every +/// companion operation except the synchronous read. +static void cn1nbCompanionWhenActive(void (^handler)(CN1NearbyCompanion *)) + API_AVAILABLE(ios(18.0)) { + if (cn1nbCompanion == nil) { + cn1nbCompanion = [[CN1NearbyCompanion alloc] init]; + } + CN1NearbyCompanion *companion = (CN1NearbyCompanion *)cn1nbCompanion; + [companion whenActive:^(BOOL active) { + handler(active ? companion : nil); + }]; +} + static CN1NearbyCompanion *cn1nbCompanionInit(void) API_AVAILABLE(ios(18.0)) { if (cn1nbCompanion == nil) { cn1nbCompanion = [[CN1NearbyCompanion alloc] init]; @@ -2203,63 +2337,70 @@ void com_codename1_impl_ios_IOSNative_nearbyAssociate___int_int_boolean_java_lan @" ios.nearby.accessoryServices build hint"); return; } - CN1NearbyCompanion *companion = cn1nbCompanionInit(); - if (companion == nil) { - cn1nbFailCompanion(requestId, - CN1_NEARBY_ERR_RADIO_UNAVAILABLE, - @"AccessorySetupKit did not become active"); - return; - } - // Taken BEFORE the picker opens, so the accessory it adds can be - // told apart from the ones this app already had. - NSMutableSet *before = [NSMutableSet set]; - for (ASAccessory *a in companion.session.accessories) { - [before addObject:cn1nbAccessoryId(a)]; - } - [companion.session showPickerForDisplayItems:items - completionHandler:^(NSError *error) { - @autoreleasepool { - if (error != nil) { - cn1nbFailCompanion(requestId, - CN1_NEARBY_ERR_USER_CANCELED, - [error localizedDescription]); - return; - } - // The one that is NEW, not the last in the array. The - // accessories array documents no order, so an app that - // already held associations could be handed one the user - // did not pick -- and then persist or disassociate the - // wrong device. - ASAccessory *picked = nil; - for (ASAccessory *a in companion.session.accessories) { - if (![before containsObject:cn1nbAccessoryId(a)]) { - if (picked != nil) { - // Two arrived while the picker was open; - // neither can be claimed as the user's pick. - picked = nil; - break; + // Resumed from the activation handler rather than waited for. + // The session is not usable until AccessorySetupKit says it is, + // and the thread that reached here is the EDT -- the thread that + // draws. Blocking it for as long as activation takes froze input + // and rendering on the first companion call of a process, which + // is exactly when an app is likely to make one. + cn1nbCompanionWhenActive(^(CN1NearbyCompanion *companion) { + if (companion == nil) { + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_RADIO_UNAVAILABLE, + @"AccessorySetupKit did not become active"); + return; + } + // Taken BEFORE the picker opens, so the accessory it adds can be + // told apart from the ones this app already had. + NSMutableSet *before = [NSMutableSet set]; + for (ASAccessory *a in companion.session.accessories) { + [before addObject:cn1nbAccessoryId(a)]; + } + [companion.session showPickerForDisplayItems:items + completionHandler:^(NSError *error) { + @autoreleasepool { + if (error != nil) { + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_USER_CANCELED, + [error localizedDescription]); + return; + } + // The one that is NEW, not the last in the array. The + // accessories array documents no order, so an app that + // already held associations could be handed one the user + // did not pick -- and then persist or disassociate the + // wrong device. + ASAccessory *picked = nil; + for (ASAccessory *a in companion.session.accessories) { + if (![before containsObject:cn1nbAccessoryId(a)]) { + if (picked != nil) { + // Two arrived while the picker was open; + // neither can be claimed as the user's pick. + picked = nil; + break; + } + picked = a; } - picked = a; } + if (picked == nil) { + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_USER_CANCELED, + @"the picker added no accessory this app" + @" did not already have"); + return; + } + // present:NO. Associating an accessory says the user + // chose it, not that it is in range -- and this port + // reports no presence at all, so claiming YES here was + // the one place a CompanionDevice arrived on iOS + // asserting something nothing would ever correct. + com_codename1_impl_ios_IOSNearbyCallbacks_associated___int_java_lang_String( + getThreadLocalData(), requestId, + cn1nbJString([companion encode:picked + present:NO])); } - if (picked == nil) { - cn1nbFailCompanion(requestId, - CN1_NEARBY_ERR_USER_CANCELED, - @"the picker added no accessory this app" - @" did not already have"); - return; - } - // present:NO. Associating an accessory says the user - // chose it, not that it is in range -- and this port - // reports no presence at all, so claiming YES here was - // the one place a CompanionDevice arrived on iOS - // asserting something nothing would ever correct. - com_codename1_impl_ios_IOSNearbyCallbacks_associated___int_java_lang_String( - getThreadLocalData(), requestId, - cn1nbJString([companion encode:picked - present:NO])); - } - }]; + }]; + }); return; } } @@ -2298,34 +2439,39 @@ void com_codename1_impl_ios_IOSNative_nearbyDisassociate___int_java_lang_String( #ifdef CN1_NEARBY_HAS_ASK if (@available(iOS 18.0, *)) { @autoreleasepool { - CN1NearbyCompanion *companion = cn1nbCompanionInit(); - if (companion == nil) { - // Distinguished from "no such association", which is what an - // inactive session used to look like. - cn1nbFailCompanion(requestId, - CN1_NEARBY_ERR_RADIO_UNAVAILABLE, - @"AccessorySetupKit did not become active"); - return; - } + // Resolved on this thread, because toNSString needs the thread + // state the native was entered with and the block below does not + // run on that thread. NSString *aid = toNSString(CN1_THREAD_STATE_PASS_ARG associationId); - ASAccessory *accessory = [companion accessoryForId:aid]; - if (accessory == nil) { - cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, - @"no such association"); - return; - } - [companion.session removeAccessory:accessory - completionHandler:^(NSError *error) { - @autoreleasepool { - if (error != nil) { - cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_UNKNOWN, - [error localizedDescription]); - } else { - com_codename1_impl_ios_IOSNearbyCallbacks_disassociated___int( - getThreadLocalData(), requestId); - } + // Deferred for the reason associate is. + cn1nbCompanionWhenActive(^(CN1NearbyCompanion *companion) { + if (companion == nil) { + // Distinguished from "no such association", which is what an + // inactive session used to look like. + cn1nbFailCompanion(requestId, + CN1_NEARBY_ERR_RADIO_UNAVAILABLE, + @"AccessorySetupKit did not become active"); + return; + } + ASAccessory *accessory = [companion accessoryForId:aid]; + if (accessory == nil) { + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"no such association"); + return; } - }]; + [companion.session removeAccessory:accessory + completionHandler:^(NSError *error) { + @autoreleasepool { + if (error != nil) { + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_UNKNOWN, + [error localizedDescription]); + } else { + com_codename1_impl_ios_IOSNearbyCallbacks_disassociated___int( + getThreadLocalData(), requestId); + } + } + }]; + }); return; } } @@ -2820,6 +2966,9 @@ CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), // back, or FAILURE if the peer disconnects first. [cn1nbTransport awaitAck:payloadId fromPeer:[peerIds objectAtIndex:i]]; + [cn1nbTransport scheduleAckTimeout:payloadId + fromPeer:[peerIds objectAtIndex:i] + encoded:encoded]; com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, (JAVA_LONG)[data length], (JAVA_LONG)[data length], diff --git a/quality-report.md b/quality-report.md index 76233a09819..a89d1a21ff4 100644 --- a/quality-report.md +++ b/quality-report.md @@ -1,28 +1,12 @@ ## ✅ Continuous Quality Report ### Test & Coverage -- ✅ **Tests:** 6194 total, 0 failed, 1 skipped -- 📊 **Line coverage:** 59.64% - - **Lowest covered classes** - - `com.codename1.gaming.level.GameSceneView` – 0.00% - - `com.codename1.crash.CrashProtection` – 0.00% - - `com.codename1.payment.CommerceManager` – 0.00% - - `com.codename1.nearby.companion.CompanionDevices` – 0.00% - - `com.codename1.wearable.WearableMessage` – 0.00% - - `com.codename1.vr.VRView` – 0.00% - - `com.codename1.appreview.RatingDialog` – 0.00% - - `com.codename1.db.ManagedKeys` – 0.00% - - `com.codename1.calendar.DefaultCalendarHttpTransport` – 0.00% - - `com.codename1.security.Secrets` – 0.00% +- ⚠️ No test results were found. +- ⚠️ Coverage report not generated. ### Static Analysis -- **SpotBugs** - - ✅ **ByteCodeTranslator:** 0 findings (no issues) - - ✅ **android:** 0 findings (no issues) - - ✅ **codenameone-maven-plugin:** 0 findings (no issues) - - ✅ **core-unittests:** 0 findings (no issues) - - ✅ **ios:** 0 findings (no issues) -- ✅ **PMD:** 0 findings (no issues) -- ✅ **Checkstyle:** 0 findings (no issues) +- ✅ SpotBugs: no findings (report was not generated by the build). +- ⚠️ PMD report not generated. +- ⚠️ Checkstyle report not generated. _Generated automatically by the PR CI workflow._ From c71328e045dc5b091b42e50aedc08398467bc0f5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:12:38 +0300 Subject: [PATCH 57/94] Address the forty-third nearby review round - The Android backend no longer pins an activity for the life of the process. It is built once and cached, while Android destroys and recreates the activity freely, so the strong field held the very first activity, its context and its whole view hierarchy until the process died. The reference is weak now, the result listener's activity is weak too and is dropped the moment its result settles rather than when some later association happens to replace it, and the thing that is genuinely process-lived -- the application context -- is what the optional backends get. - Reserving a ranging session for a start is one operation. The check and the flag were two, so start and startAccessory racing from different threads both passed the check before either set it, and both issued a native start for one session: on iOS the second replaced the first pendingStartRequest and left that AsyncResource pending for good, and on Android the second subscription replaced the first, leaving the session measuring with nothing answering the call that asked for it. --- .../nearby/ranging/RangingSession.java | 56 ++++++++++++------- .../android/nearby/AndroidNearbyBackend.java | 52 ++++++++++++----- 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java index 29e8e9b2b46..67837337f6b 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java @@ -109,7 +109,7 @@ public AsyncResource start(RangingToken peerToken) { return failedSession(NearbyError.INVALID_TOKEN, "a peer token is required"); } - NearbyException busy = checkStartable(); + NearbyException busy = reserveStart(); if (busy != null) { EdtResult out = new EdtResult(); out.error(busy); @@ -118,7 +118,6 @@ public AsyncResource start(RangingToken peerToken) { NearbyBridge b = NearbyRequests.bridge(); int id = NearbyRequests.nextId(); EdtResult out = Ranging.pendingSessions().open(id); - starting = true; Ranging.trackStarting(id, this); b.startRanging(id, handle, peerToken.toByteArray()); return out; @@ -154,7 +153,7 @@ public AsyncResource startAccessory( "accessory configuration data is required")); return out; } - NearbyException busy = checkStartable(); + NearbyException busy = reserveStart(); if (busy != null) { EdtResult out = new EdtResult(); out.error(busy); @@ -163,7 +162,6 @@ public AsyncResource startAccessory( NearbyBridge b = NearbyRequests.bridge(); int id = NearbyRequests.nextId(); EdtResult out = Ranging.pendingAccessory().open(id); - starting = true; Ranging.trackStarting(id, this); b.startAccessoryRanging(id, handle, accessoryConfigurationData); return out; @@ -438,8 +436,8 @@ static RangingSession lookup(int handle) { void markRunning() { synchronized (SESSIONS) { running = true; + starting = false; } - starting = false; } /// True once [#stop] or an invalidation has finished this session. @@ -462,7 +460,9 @@ boolean isClosed() { /// The obvious retry after a bad token exchange is exactly the case that /// hit it. void markStartFailed() { - starting = false; + synchronized (SESSIONS) { + starting = false; + } } private RangingListener[] snapshot() { @@ -471,20 +471,38 @@ private RangingListener[] snapshot() { } } - private NearbyException checkStartable() { - if (isClosed()) { - return new NearbyException(NearbyError.SESSION_INVALIDATED, - "this session has been stopped; prepare another"); - } - if (running || starting) { - return new NearbyException(NearbyError.BUSY, - "this session is already ranging"); - } - if (NearbyRequests.bridge() == null) { - return new NearbyException(NearbyError.NOT_SUPPORTED, - "this platform does not support precision ranging"); + /// Claims this session for a start, or says why it cannot be claimed. + /// + /// The check and the reservation are ONE operation, under the monitor + /// that guards these flags. As two, `start` and `startAccessory` racing + /// from different threads both passed the check before either set the + /// flag, and both went on to issue a native start for the same session: + /// on iOS the second replaced the first pendingStartRequest and left + /// that AsyncResource pending for good, and on Android the second + /// subscription replaced the first, so the session measured but nothing + /// answered the call that asked for it. + /// + /// #### Returns + /// + /// null when the caller now owns the start, otherwise the reason it + /// does not -- and in that case nothing was reserved + private NearbyException reserveStart() { + synchronized (SESSIONS) { + if (closed) { + return new NearbyException(NearbyError.SESSION_INVALIDATED, + "this session has been stopped; prepare another"); + } + if (running || starting) { + return new NearbyException(NearbyError.BUSY, + "this session is already ranging"); + } + if (NearbyRequests.bridge() == null) { + return new NearbyException(NearbyError.NOT_SUPPORTED, + "this platform does not support precision ranging"); + } + starting = true; + return null; } - return null; } private AsyncResource failedSession(NearbyError error, diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index dca17428079..c2c40d0797a 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -50,6 +50,7 @@ import com.codename1.nearby.spi.NearbyBridge; import com.codename1.ui.Display; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -72,16 +73,30 @@ public class AndroidNearbyBackend implements NearbyBridge { /// the port's own IntentResultListener constants. private static final int ASSOCIATE_REQUEST = 0x4E42; - /// The activity this backend was built with, used only when the port has - /// no current one. - private final Activity initialActivity; + /// The activity this backend was built with, used only when the port + /// has no current one. + /// + /// WEAK. This backend is built once and cached for the life of the + /// process, while Android destroys and recreates the activity freely -- + /// so a strong field here pinned the very first activity, its context + /// and its whole view hierarchy in memory until the process died, for an + /// app that associated one accessory at startup and never came back. + /// Everything with a lifetime of its own uses appContext instead. + private final WeakReference initialActivity; + + /// The application context, which outlives every activity and leaks + /// nothing by being held. + private final Context appContext; private final NearbyBridge ranging; private final NearbyBridge transport; private int pendingAssociateRequest; public AndroidNearbyBackend(Activity activity) { - this.initialActivity = activity; + this.initialActivity = new WeakReference(activity); + Context app = activity == null ? null : activity + .getApplicationContext(); + this.appContext = app != null ? app : activity; this.ranging = load("com.codename1.impl.android.nearby." + "AndroidUwbRanging"); this.transport = load("com.codename1.impl.android.nearby." @@ -90,7 +105,17 @@ public AndroidNearbyBackend(Activity activity) { /// The activity the association's result listener is installed on, or /// null when none is. - private Activity listeningOn; + /// + /// Weak for the reason initialActivity is, and cleared as soon as the + /// result settles: an association that completed normally used to leave + /// its host activity referenced here until the next association replaced + /// it, which for most apps is never. + private WeakReference listeningOn; + + /// The activity listeningOn refers to, or null once it is gone. + private Activity listeningActivity() { + return listeningOn == null ? null : listeningOn.get(); + } /// Re-installs the association result listener on the activity that /// replaced the one it was on. @@ -107,7 +132,7 @@ public void onActivityChanged() { return; } Activity current = currentActivity(); - if (current == null || current == listeningOn) { + if (current == null || current == listeningActivity()) { return; } CompanionDeviceManager cdm = manager(); @@ -136,7 +161,7 @@ public void onActivityChanged() { /// a host nothing would ever deliver to. private Activity currentActivity() { Activity current = AndroidImplementation.getActivity(); - return current != null ? current : initialActivity; + return current != null ? current : initialActivity.get(); } /// The context the optional backends hold. @@ -145,12 +170,8 @@ private Activity currentActivity() { /// bridge does and use it only for package manager, permission and /// content-resolver lookups, so holding a destroyed activity would be a /// leak with no upside. - private android.content.Context contextForBackends() { - if (initialActivity == null) { - return null; - } - android.content.Context app = initialActivity.getApplicationContext(); - return app != null ? app : initialActivity; + private Context contextForBackends() { + return appContext; } private NearbyBridge load(String className) { @@ -583,7 +604,7 @@ private boolean listenForResult(final int requestId, // told apart from the ones this app already had. final Set before = associationKeys(cdm); final CodenameOneActivity host = (CodenameOneActivity) current; - listeningOn = current; + listeningOn = new WeakReference(current); host.setIntentResultListener(new IntentResultListener() { public void onActivityResult(int requestCode, int resultCode, Intent data) { @@ -591,6 +612,9 @@ public void onActivityResult(int requestCode, int resultCode, return; } host.restoreIntentResultListener(); + // Dropped here, not only when the next association replaces + // it: the flow this listener belongs to is over. + listeningOn = null; pendingAssociateRequest = 0; if (resultCode != Activity.RESULT_OK) { CompanionDevices.deliverRequestFailed(requestId, From 98f5420bd1b5e211007b89c73345dae21079f9c0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:28:56 +0300 Subject: [PATCH 58/94] Address the forty-fourth nearby review round - The advertised payload ceiling is the same number everywhere. Android spends four of Nearby Connections' 32K on the payload-id header it frames in and reports 32764; the simulator and iOS reported and accepted 32768, so an app sized against either passed there and was refused on the first Android device it ran on. The portable API promises one number an app can rely on everywhere, which can only be the tightest of the real backends. - A permission request with no current activity is answered rather than thrown. This bridge is cached for the life of the process and legitimately has no activity at times -- during a recreation, or reached from a service after the weak reference was collected -- and dereferencing one anyway threw out of a method the facade had already registered an EdtResult for, so the exception escaped synchronously and left that request pending for good. The checks now read the application context, which every one of them only ever needed, and the one step that does need an activity fails the request when there is none. The chooser launch, which runs a main-looper hop after its own check, gets the same treatment. - Presence observation is recognised from the START call alone. stopObservingPresence is a cleanup call -- an app version that dropped observation still makes it, to undo an observation a previous version persisted -- and a substring match kept the exported companion service and the background companion permissions in that app's manifest. Both scanners, and a test in each tree, because these two drifting is the failure that family of tests exists to catch. - The companion block says why it emits no entitlement. There is no com.apple.developer.accessory-setup-kit; the one AccessorySetupKit entitlement that exists is for a discovery EXTENSION, and emitting an entitlement the App ID does not grant would fail signing for every app that touches the package. --- .../impl/nearby/LocalNearbyBridge.java | 12 ++- .../android/nearby/AndroidNearbyBackend.java | 52 +++++++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 8 +- .../builders/AndroidGradleBuilder.java | 15 +++- .../com/codename1/builders/IPhoneBuilder.java | 19 ++++ .../builders/NearbyPresenceScanTest.java | 88 +++++++++++++++++++ 6 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index d2f1d013021..260ef3b4398 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -516,10 +516,14 @@ public void setPresent(String associationId, boolean present) { @Override public int getMaxPayloadSize() { - // What Nearby Connections allows for a BYTES payload. Matching the - // tighter of the two real limits means an app that fits here fits - // everywhere. - return 32 * 1024; + // Nearby Connections allows 32K for a BYTES payload, and the Android + // transport spends four of those bytes on the payload-id header it + // frames in -- so 32764 is what an app may actually send there, and + // the tightest of the real backends is the only honest number for a + // simulator to advertise. Reporting the raw 32768 let an app size + // itself against the simulator, pass, and then be refused on the + // first device it ran on. + return 32 * 1024 - 4; } @Override diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index c2c40d0797a..91a1b0b25a3 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -242,10 +242,25 @@ public void requestPermissions(int requestId, int permissionBits) { // permission strings and AndroidImplementation.checkForPermission is // in the always-compiled half of the port. So one list, one pass, one // answer. + // The APPLICATION context, and checked for null before anything is + // read off it. Everything below only needs package-manager and + // permission lookups, which every Context answers, and a bridge + // cached for the life of the process legitimately has no activity at + // times -- during a recreation, or when reached from a service after + // the weak initial activity was collected. Dereferencing one anyway + // threw out of a method the facade had already registered an + // EdtResult for, so the exception escaped synchronously and left + // that permission request pending for good. + Context ctx = contextForBackends(); + if (ctx == null) { + com.codename1.nearby.ranging.Ranging + .deliverPermissionResult(requestId, false); + return; + } final ArrayList perms = new ArrayList(); if ((permissionBits & NearbyBridge.PERMISSION_RANGING) != 0 && Build.VERSION.SDK_INT >= 31) { - add(perms, "android.permission.UWB_RANGING"); + add(perms, "android.permission.UWB_RANGING", ctx); } boolean transportBits = (permissionBits & (NearbyBridge.PERMISSION_DISCOVERY @@ -260,9 +275,9 @@ public void requestPermissions(int requestId, int permissionBits) { // 12 uses the legacy permissions and location, and asking it for // BLUETOOTH_SCAN left the grant it needed unrequested. List transport = NearbyPermissions.transportPermissions( - currentActivity(), permissionBits); + ctx, permissionBits); for (int i = 0; i < transport.size(); i++) { - add(perms, transport.get(i)); + add(perms, transport.get(i), ctx); } } if (perms.isEmpty()) { @@ -273,10 +288,19 @@ public void requestPermissions(int requestId, int permissionBits) { .deliverPermissionResult(requestId, true); return; } + // Asking for a grant DOES need an activity, and there may be none. + // Answered false rather than thrown: the caller is waiting on a + // result, and "not granted" is both true and something it can act on. + Activity host = currentActivity(); + if (host == null) { + com.codename1.nearby.ranging.Ranging + .deliverPermissionResult(requestId, false); + return; + } // checkForPermission blocks through invokeAndBlock and must run on the // EDT. Display.getInstance().callSerially( - permissionRunnable(requestId, perms, currentActivity())); + permissionRunnable(requestId, perms, host)); } /// Adds a permission the app has not already been granted. @@ -286,11 +310,12 @@ public void requestPermissions(int requestId, int permissionBits) { /// calling it threw NoSuchMethodError rather than answering, which a /// transport app on Android 5.0 or 5.1 can reach, since the transport's /// minimum is 21. - private void add(ArrayList perms, String permission) { + private void add(ArrayList perms, String permission, + Context ctx) { if (Build.VERSION.SDK_INT < 23) { return; } - if (currentActivity().checkSelfPermission(permission) + if (ctx.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { perms.add(permission); } @@ -555,8 +580,21 @@ public void onFailure(CharSequence error) { } private void launch(IntentSender chooserLauncher, int requestId) { + // This runs from the platform's callback, which is a main-looper hop + // after the activity was checked -- long enough for it to have gone. + // Failed rather than thrown, for the reason requestPermissions is. + Activity host = currentActivity(); + if (host == null) { + pendingAssociateRequest = 0; + releaseResultListener(); + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.USER_CANCELED.ordinal(), + "the screen went away before the device chooser could" + + " open; associate again"); + return; + } try { - currentActivity().startIntentSenderForResult(chooserLauncher, + host.startIntentSenderForResult(chooserLauncher, ASSOCIATE_REQUEST, null, 0, 0, 0); } catch (IntentSender.SendIntentException e) { pendingAssociateRequest = 0; diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index eccb2478203..d6b94108459 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -2505,9 +2505,11 @@ JAVA_INT com_codename1_impl_ios_IOSNative_nearbyMaxPayloadSize___R_int( #ifdef CN1_NEARBY_HAS_MPC // MultipeerConnectivity has no published limit for sendData, but it // degrades badly past a few tens of kilobytes and the portable API - // promises one number an app can rely on everywhere. Matching the tighter - // of the two real backends means a payload that fits here fits on Android. - return 32 * 1024; + // promises one number an app can rely on everywhere. So this is Android's + // number: its 32K Nearby Connections ceiling less the four bytes its + // payload-id header takes, which is the tightest of the real backends. + // Anything else and "fits here fits everywhere" is false by four bytes. + return 32 * 1024 - 4; #else return 0; #endif 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 87298f99bf9..eb2dfd39040 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 @@ -2163,10 +2163,19 @@ public void usesClassMethod(String cls, String method) { // same classes. Only the call tells them apart, and only // the second should carry the background permissions. if ("com/codename1/nearby/companion/CompanionDevices" - .equals(cls) - && method.contains("ObservingPresence")) { + .equals(cls)) { usesNearbyCompanion = true; - usesNearbyPresence = true; + // START, specifically. stopObservingPresence is a + // cleanup call -- an app version that dropped + // observation still makes it, to undo an observation + // a previous version persisted -- and counting it as + // observing kept the exported companion service and + // the background companion permissions in the + // manifest of an app that no longer observes + // anything, which is the opposite of what the + // per-operation gating above is for. + usesNearbyPresence |= + "startObservingPresence".equals(method); } if (cls.indexOf("com/codename1/health/HealthStore") == 0) { usesHealth = true; 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 9462b1c29ef..06fcdb0285f 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 @@ -4648,6 +4648,25 @@ public void usesClassMethod(String cls, String method) { usesBonjour); } if (usesNearbyCompanion) { + // Info.plist keys and NO entitlement, deliberately. + // + // AccessorySetupKit is gated on these declarations, not + // on a capability: there is no + // com.apple.developer.accessory-setup-kit -- Xcode's own + // portal capability list has no such entitlement, and the + // one AccessorySetupKit entitlement that does exist, + // com.apple.developer.accessory-setup-discovery-extension, + // is for an app EXTENSION that offers a third-party + // accessory to the SYSTEM picker. An app presenting its + // own ASAccessorySession picker, which is all this port + // does, is not that. + // + // Adding one anyway would not be harmless. An entitlement + // the App ID does not grant fails signing, so every app + // that merely touches com.codename1.nearby.companion + // would stop building -- which is exactly why the + // nearby-interaction entitlement below is behind an + // explicit hint rather than switched on by use. enableNearbyDefine(buildinRes, "CN1_NEARBY_COMPANION"); declareNearbyPlistArray(request, "NSAccessorySetupKitSupports", diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java new file mode 100644 index 00000000000..4bde5e3b69c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java @@ -0,0 +1,88 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Presence observation is recognised from the START call alone. + * + *

The manifest an observing app gets is bigger than the one an + * associating app gets: an exported companion service and the background + * companion permissions. Which one an app receives turns entirely on this + * one classification, and the scanner rule lives in an anonymous visitor + * callback with no seam to call -- so this pins the rule by source text, + * the way {@code HealthScannerParityTest} does.

+ * + *

The rule it pins: {@code stopObservingPresence} is a cleanup call. An + * app version that dropped observation still makes it, to undo an + * observation a previous version persisted, and a substring match on + * {@code ObservingPresence} classified that app as observing -- keeping + * the service and the permissions in the manifest of an app that starts + * no observation at all.

+ */ +public class NearbyPresenceScanTest { + + private static String scanner() throws Exception { + File f = new File("src/main/java/com/codename1/builders/" + + "AndroidGradleBuilder.java"); + assertTrue(f.exists(), "scanner source must be readable: " + + f.getAbsolutePath()); + return new String(Files.readAllBytes(f.toPath()), + StandardCharsets.UTF_8); + } + + @Test + public void presenceIsMatchedOnTheStartCallExactly() throws Exception { + String src = scanner(); + assertTrue(src.contains("\"startObservingPresence\".equals(method)"), + "presence observation must be recognised from" + + " startObservingPresence by exact name"); + assertFalse(src.contains("method.contains(\"ObservingPresence\")"), + "a substring match also classifies stopObservingPresence" + + " as observing"); + } + + /** + * Touching the facade at all is still companion use. Only the presence + * half is gated on the start call; an app that merely associates must + * keep its companion feature and its association permissions. + */ + @Test + public void anyCompanionCallStillCountsAsCompanionUse() throws Exception { + String src = scanner(); + int at = src.indexOf("\"startObservingPresence\".equals(method)"); + assertTrue(at > 0, "the presence rule must be present"); + String before = src.substring(Math.max(0, at - 1200), at); + assertTrue(before.contains("usesNearbyCompanion = true;"), + "companion use must be set for any CompanionDevices call," + + " not only for the observing one"); + } +} From 57dd9c24dfe5759c6430c6c669ee812877ed8ee1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:50:18 +0300 Subject: [PATCH 59/94] Address the forty-fifth nearby review round - A byte send is registered before it is sent, not after. The acknowledgement comes back on the session queue, and one recipient of a multi-peer send can answer before a later recipient has even been sent to -- so registering afterwards let the ack arrive for a send nothing knew about yet, be dropped as unknown, and then leave behind an entry that could only time out and fail a payload the peer already had. The progress update moved ahead of the send with it, so SUCCESS can only follow the IN_PROGRESS it belongs to. - The acknowledged terminal update carries the byte count. It reported nothing transferred and no total, contradicting the IN_PROGRESS just before it, so a listener that persists or displays the terminal update regressed a finished transfer back to zero. The ack frame has no length in it, so the length is recorded with the send and handed back when the ack takes it -- and a send stranded by a disconnect or a timeout now reports the same total its progress did. - Submitted libraries are scanned for nearby usage. The class scanner reads loose .class files and never opens a jar, so a library that is the only code touching these APIs left every flag false: Android deleted the implementation package out of the generated sources and iOS left the defines and frameworks off, and the library called into classes the build had removed. The database scan already reads both trees for this exact reason. Kept to the feature whose implementation gets deleted rather than turned on for every flag the scanner carries -- making the general scanner archive-aware would change what permissions every existing feature asks for, which is not a change to make in passing. The flags alone were not enough either: the accumulator is what supplies the dependencies, the frameworks and the minimum SDK, so library-only usage feeds that too. A test pins the three catalog prefixes in both trees, since the scan has no resolved class name to offer and nothing else would notice them drifting. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 137 ++++++---- .../builders/AndroidGradleBuilder.java | 47 ++++ .../com/codename1/builders/IPhoneBuilder.java | 45 ++++ .../builders/NearbyManifestFragments.java | 242 ++++++++++++++++++ .../builders/NearbyLibraryScanTest.java | 187 ++++++++++++++ 5 files changed, 607 insertions(+), 51 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index d6b94108459..a391d4825ed 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -922,50 +922,66 @@ - (void)scheduleAckTimeout:(JAVA_INT)payloadId fromPeer:(NSString *)pid (int64_t)CN1_NEARBY_ACK_TIMEOUT_NS), dispatch_get_main_queue(), ^{ @autoreleasepool { - if (![self takeAck:payloadId fromPeer:pid]) { + JAVA_LONG length = -1; + if (![self takeAck:payloadId fromPeer:pid length:&length]) { return; } com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), payloadId, - 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); + 0, length, CN1_NEARBY_PAYLOAD_FAILURE); } }); } /// Records a payload sent to a peer and waiting for its acknowledgement. -- (void)awaitAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { +/// +/// The LENGTH is recorded with it, because the acknowledgement frame does +/// not carry one and the terminal update has to. Reporting SUCCESS with +/// nothing transferred contradicted the IN_PROGRESS update just before it, +/// which had already reported the whole payload -- so a listener that +/// persists or displays the terminal update regressed a finished transfer +/// back to zero bytes. +- (void)awaitAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid + length:(JAVA_LONG)length { @synchronized (self) { NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; if (ids == nil) { ids = [NSMutableDictionary dictionary]; [self.awaitingAck setObject:ids forKey:pid]; } - // Counted, not deduplicated. The same immutable Payload sent to one - // peer twice before either answer arrives is two accepted sends under - // one portable id -- and a set kept one, so the first acknowledgement - // emitted the only terminal status and the second send never got one. + // One entry per send, not deduplicated. The same immutable Payload + // sent to one peer twice before either answer arrives is two accepted + // sends under one portable id -- and a set kept one, so the first + // acknowledgement emitted the only terminal status and the second + // send never got one. NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; - NSNumber *outstanding = [ids objectForKey:key]; - [ids setObject:[NSNumber numberWithInt: - (outstanding == nil ? 1 : [outstanding intValue] + 1)] - forKey:key]; + NSMutableArray *outstanding = [ids objectForKey:key]; + if (outstanding == nil) { + outstanding = [NSMutableArray array]; + [ids setObject:outstanding forKey:key]; + } + [outstanding addObject:[NSNumber numberWithLongLong: + (long long)length]]; } } /// Takes the acknowledgement for one payload, answering whether it was -/// outstanding -- so a duplicate or unknown ack reports nothing. -- (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid { +/// outstanding -- so a duplicate or unknown ack reports nothing -- and +/// handing back the length that send was recorded with. +- (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid + length:(JAVA_LONG *)outLength { @synchronized (self) { NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; - NSNumber *outstanding = [ids objectForKey:key]; - if (outstanding == nil) { + NSMutableArray *outstanding = [ids objectForKey:key]; + if (outstanding == nil || [outstanding count] == 0) { return NO; } - int left = [outstanding intValue] - 1; - if (left > 0) { - [ids setObject:[NSNumber numberWithInt:left] forKey:key]; - } else { + if (outLength != NULL) { + *outLength = (JAVA_LONG)[[outstanding lastObject] longLongValue]; + } + [outstanding removeLastObject]; + if ([outstanding count] == 0) { [ids removeObjectForKey:key]; } if ([ids count] == 0) { @@ -982,10 +998,11 @@ - (NSArray *)takeAllAcksFromPeer:(NSString *)pid { NSMutableArray *out = [NSMutableArray array]; // One entry per outstanding SEND, so two sends of one payload get two // terminal updates -- the same count they would have got as acks. + // Each is a pair of the payload id and the length it was sent with, + // so a stranded send reports the same total its progress did. for (NSNumber *key in [ids allKeys]) { - int outstanding = [[ids objectForKey:key] intValue]; - for (int i = 0; i < outstanding; i++) { - [out addObject:key]; + for (NSNumber *length in [ids objectForKey:key]) { + [out addObject:[NSArray arrayWithObjects:key, length, nil]]; } } [self.awaitingAck removeObjectForKey:pid]; @@ -1184,10 +1201,11 @@ - (void)session:(MCSession *)session peer:(MCPeerID *)peerID if ([self takeEverConnected:pid]) { // Anything still waiting on this peer will never be // acknowledged, so it is failed rather than left pending. - for (NSNumber *stranded in [self takeAllAcksFromPeer:pid]) { + for (NSArray *stranded in [self takeAllAcksFromPeer:pid]) { com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), - (JAVA_INT)[stranded intValue], 0, -1, + (JAVA_INT)[[stranded objectAtIndex:0] intValue], 0, + (JAVA_LONG)[[stranded objectAtIndex:1] longLongValue], CN1_NEARBY_PAYLOAD_FAILURE); } if (lostWhileUp) { @@ -1233,10 +1251,14 @@ - (void)session:(MCSession *)session didReceiveData:(NSData *)data // The far side has the bytes. Reported once: a duplicate or // unknown ack is dropped rather than emitting a second terminal // status for a payload already finished. - if ([self takeAck:payloadId fromPeer:pid]) { + JAVA_LONG length = -1; + if ([self takeAck:payloadId fromPeer:pid length:&length]) { + // The length this send was recorded with, not zero. SUCCESS + // means every byte arrived, so the terminal update has to + // carry the same count the IN_PROGRESS before it did. com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), payloadId, - 0, -1, CN1_NEARBY_PAYLOAD_SUCCESS); + length, length, CN1_NEARBY_PAYLOAD_SUCCESS); } return; } @@ -2931,50 +2953,63 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i // at all. NSError *err = nil; BOOL sent = [peers count] > 0; - NSMutableArray *outcomes = [NSMutableArray array]; for (NSUInteger i = 0; i < [peers count]; i++) { MCSession *session = [cn1nbTransport sessionFor:[peerIds objectAtIndex:i]]; + NSString *encoded = [cn1nbTransport + encodePeer:[peers objectAtIndex:i]]; + // Registered BEFORE the send, and the progress reported before + // it too. The acknowledgement can come back on the session queue + // while this thread is still between the two, and one recipient + // of a multi-peer send can answer before a later recipient has + // even been sent to -- so registering afterwards let takeAck see + // an ack for a send it did not know about, drop it as unknown, + // and then create an entry that could only ever time out and + // fail a payload the peer already had. Reporting progress first + // keeps the order right as well: SUCCESS can now only follow the + // IN_PROGRESS it belongs to, never precede it. + [cn1nbTransport awaitAck:payloadId + fromPeer:[peerIds objectAtIndex:i] + length:(JAVA_LONG)[data length]]; + // Queued, not delivered. sendData returning YES says the message + // was accepted for sending, and PayloadStatus.SUCCESS documents + // that every byte ARRIVED -- which is what Android reports, + // because Nearby tells it so. So this reports progress now and + // the terminal status when the receiver's acknowledgement comes + // back, or FAILURE if the peer disconnects first. + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, + (JAVA_LONG)[data length], (JAVA_LONG)[data length], + CN1_NEARBY_PAYLOAD_IN_PROGRESS); NSError *one = nil; BOOL ok = [session sendData:framed toPeers:[NSArray arrayWithObject: [peers objectAtIndex:i]] withMode:MCSessionSendDataReliable error:&one]; - [outcomes addObject:[NSNumber numberWithBool:ok]]; if (!ok) { sent = NO; if (err == nil) { err = one; } - } - } - for (NSUInteger i = 0; i < [peers count]; i++) { - BOOL ok = [[outcomes objectAtIndex:i] boolValue]; - NSString *encoded = [cn1nbTransport - encodePeer:[peers objectAtIndex:i]]; - if (!ok) { - com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( - CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), - payloadId, 0, (JAVA_LONG)[data length], - CN1_NEARBY_PAYLOAD_FAILURE); + // Taken back, so nothing is left for the timeout to fail a + // second time. If the entry has already gone the send did + // reach the peer after all and its ack has been reported -- + // in which case this reports nothing. + JAVA_LONG unused = -1; + if ([cn1nbTransport takeAck:payloadId + fromPeer:[peerIds objectAtIndex:i] + length:&unused]) { + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), + payloadId, 0, (JAVA_LONG)[data length], + CN1_NEARBY_PAYLOAD_FAILURE); + } continue; } - // Queued, not delivered. sendData returning YES says the message - // was accepted for sending, and PayloadStatus.SUCCESS documents - // that every byte ARRIVED -- which is what Android reports, - // because Nearby tells it so. So this reports progress now and - // the terminal status when the receiver's acknowledgement comes - // back, or FAILURE if the peer disconnects first. - [cn1nbTransport awaitAck:payloadId - fromPeer:[peerIds objectAtIndex:i]]; [cn1nbTransport scheduleAckTimeout:payloadId fromPeer:[peerIds objectAtIndex:i] encoded:encoded]; - com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( - CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, - (JAVA_LONG)[data length], (JAVA_LONG)[data length], - CN1_NEARBY_PAYLOAD_IN_PROGRESS); } if (!sent) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, 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 eb2dfd39040..20156ae00e6 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 @@ -2406,6 +2406,53 @@ public void usesClassMethod(String cls, String method) { throw new BuildException("An error occurred while trying to scan the classes for API usage.", ex); } + // The libraries as well as the loose class tree. + // + // scanClassesForPermissions reads .class files and never opens a jar, + // so a library that is the only code touching these APIs -- the + // application calls the library and never names a nearby class -- + // left every flag false. This build then DELETED + // com/codename1/impl/android/nearby out of the sources and omitted + // the dependencies and manifest entries, so the library called into + // classes the build had removed. The database scan above reads both + // trees for exactly that reason; this is the same fix for the same + // hazard, kept to the feature whose implementation gets deleted + // rather than turned on for every flag the scanner carries. + NearbyManifestFragments.NearbyUsage libraryNearby = + NearbyManifestFragments.scanForNearbyUsage(libsDir); + if (!libraryNearby.isEmpty()) { + debug("Nearby usage found inside a submitted library" + + (libraryNearby.usesRanging() ? " ranging" : "") + + (libraryNearby.usesTransport() ? " transport" : "") + + (libraryNearby.usesCompanion() ? " companion" : "") + + (libraryNearby.usesPresence() ? " presence" : "")); + } + usesNearbyRanging |= libraryNearby.usesRanging(); + usesNearbyTransport |= libraryNearby.usesTransport(); + usesNearbyCompanion |= libraryNearby.usesCompanion(); + usesNearbyPresence |= libraryNearby.usesPresence(); + + // Fed to the CATALOG as well as to the flags. The flags decide which + // sources survive and which manifest fragments are written; the + // accumulator is what supplies the dependencies, the frameworks, the + // privacy strings and the minimum SDK. Setting only the flags kept + // AndroidUwbRanging.java in the generated sources without + // androidx.core.uwb to compile it against, and enabled the iOS + // defines without NearbyInteraction to link -- a build that fails + // late for a reason nothing in it names. + // + // The entry prefix IS the key: the catalog matches a consumed class + // by startsWith, and a prefix starts with itself. + if (libraryNearby.usesRanging()) { + aiAcc.consume("com/codename1/nearby/ranging/"); + } + if (libraryNearby.usesTransport()) { + aiAcc.consume("com/codename1/nearby/transport/"); + } + if (libraryNearby.usesCompanion()) { + aiAcc.consume("com/codename1/nearby/companion/"); + } + // Apply AI/ML dependency table hits accumulated during the // scan. Permissions / features go to xPermissions right // away (so they're visible to all the downstream manifest 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 06fcdb0285f..432f51042e2 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 @@ -2238,6 +2238,51 @@ public void usesClassMethod(String cls, String method) { } catch (Exception ex) { throw new BuildException("Failed to scan project classes for permissions.", ex); } + + // The libraries as well as the loose class tree, and read HERE -- + // before the port's own jars are unzipped into btres further down, + // which is what keeps the framework's own use of these packages from + // answering for the application's. + // + // scanClassesForPermissions reads .class files and never opens a jar, + // so a library that is the only code touching these APIs -- the + // application calls the library and never names a nearby class -- + // left every flag false, and this build then left CN1_INCLUDE_NEARBY + // undefined and the frameworks unlinked. The feature was simply + // absent from a build that looked clean. The database scan above + // reads both trees for the same reason. + NearbyManifestFragments.NearbyUsage libraryNearby = + NearbyManifestFragments.scanForNearbyUsage(buildinRes); + if (!libraryNearby.isEmpty()) { + debug("Nearby usage found inside a submitted library" + + (libraryNearby.usesRanging() ? " ranging" : "") + + (libraryNearby.usesTransport() ? " transport" : "") + + (libraryNearby.usesCompanion() ? " companion" : "")); + } + usesNearbyRanging |= libraryNearby.usesRanging(); + usesNearbyTransport |= libraryNearby.usesTransport(); + usesNearbyCompanion |= libraryNearby.usesCompanion(); + + // Fed to the CATALOG as well as to the flags. The flags decide which + // sources survive and which manifest fragments are written; the + // accumulator is what supplies the dependencies, the frameworks, the + // privacy strings and the minimum SDK. Setting only the flags kept + // AndroidUwbRanging.java in the generated sources without + // androidx.core.uwb to compile it against, and enabled the iOS + // defines without NearbyInteraction to link -- a build that fails + // late for a reason nothing in it names. + // + // The entry prefix IS the key: the catalog matches a consumed class + // by startsWith, and a prefix starts with itself. + if (libraryNearby.usesRanging()) { + aiAcc.consume("com/codename1/nearby/ranging/"); + } + if (libraryNearby.usesTransport()) { + aiAcc.consume("com/codename1/nearby/transport/"); + } + if (libraryNearby.usesCompanion()) { + aiAcc.consume("com/codename1/nearby/companion/"); + } stopwatch.split("Scan Classes"); if (usesCalendarApi) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 5a3f320730b..1b1077c3532 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -341,4 +341,246 @@ private static String addFeature(String xPermissions, String name, + "\" android:required=\"" + required + "\" />\n" + xPermissions; } + + // ------------------------------------------------------------------ + // Library bytecode + // ------------------------------------------------------------------ + + /// What a tree of bytecode was found to use. + public static final class NearbyUsage { + + private boolean ranging; + private boolean transport; + private boolean companion; + private boolean presence; + + public boolean usesRanging() { + return ranging; + } + + public boolean usesTransport() { + return transport; + } + + public boolean usesCompanion() { + return companion; + } + + public boolean usesPresence() { + return presence; + } + + /// True when nothing at all was found, which is the ordinary case. + public boolean isEmpty() { + return !ranging && !transport && !companion && !presence; + } + } + + /// The package a reference to it is stored under, in every constant pool + /// that names one of its classes. + private static final String RANGING_MARKER = + "com/codename1/nearby/ranging/"; + private static final String TRANSPORT_MARKER = + "com/codename1/nearby/transport/"; + private static final String COMPANION_MARKER = + "com/codename1/nearby/companion/"; + /// The method name, because presence is a call rather than a class. + private static final String PRESENCE_MARKER = "startObservingPresence"; + + /// Classes whose own mention of these packages says nothing about the + /// application: the API, the simulator bridge and the ports implement + /// them, so a framework jar staged beside the libraries would otherwise + /// report every application as using all of it. + private static final String[] FRAMEWORK_PREFIXES = { + "com/codename1/nearby/", + "com/codename1/impl/nearby/", + "com/codename1/impl/android/nearby/", + "com/codename1/impl/ios/", + }; + + /// What the bytecode under `root` uses of the nearby packages. + /// + /// Loose class files, jars and Android archives alike, because a library + /// can be the only thing that touches these APIs -- the application calls + /// the library and never names a nearby class itself. Reading only the + /// loose tree reported no use at all, and the Android build then DELETED + /// the implementation package out from under the library that calls it + /// while iOS left the natives and frameworks out, so the feature was + /// missing from a build that looked clean. The database scan is extended + /// over the same trees for the same reason. + /// + /// The test is a search of the whole class file for the package name, + /// which is how every reference to a class in it is stored. A class that + /// mentions the string for some other reason counts too, which errs + /// towards keeping the implementation -- the safe direction, since the + /// cost of a false positive is bytes and the cost of a false negative is + /// an app that crashes on a class the build removed. + /// + /// #### Parameters + /// + /// - `root`: a directory of staged classes and libraries, or null + /// + /// #### Returns + /// + /// what it uses, never null and empty when `root` is not a directory + public static NearbyUsage scanForNearbyUsage(java.io.File root) { + NearbyUsage found = new NearbyUsage(); + if (root != null && root.isDirectory()) { + scanTree(root, "", found); + } + return found; + } + + private static void scanTree(java.io.File dir, String relativePath, + NearbyUsage found) { + java.io.File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (int iter = 0; iter < children.length; iter++) { + java.io.File child = children[iter]; + String childPath = relativePath.length() == 0 + ? child.getName() : relativePath + "/" + child.getName(); + String name = child.getName().toLowerCase(java.util.Locale.ROOT); + if (child.isDirectory()) { + scanTree(child, childPath, found); + } else if (name.endsWith(".jar") || name.endsWith(".aar") + || name.endsWith(".zip")) { + scanArchive(child, found); + } else if (name.endsWith(".class") + && !isFrameworkClass(childPath)) { + inspect(readAll(child), found); + } + } + } + + private static void scanArchive(java.io.File archive, NearbyUsage found) { + java.util.zip.ZipFile zip = null; + try { + zip = new java.util.zip.ZipFile(archive); + java.util.Enumeration entries = + zip.entries(); + while (entries.hasMoreElements()) { + java.util.zip.ZipEntry entry = entries.nextElement(); + String entryName = entry.getName(); + if (entry.isDirectory()) { + continue; + } + String lower = entryName.toLowerCase(java.util.Locale.ROOT); + if (lower.endsWith(".jar")) { + // An Android archive keeps its bytecode in a nested + // classes.jar, so the entries that matter are one level + // further in. Caught per entry: one unreadable entry says + // nothing about the entries after it. + try { + inspectNested(readAll(zip.getInputStream(entry)), + found); + } catch (Throwable unreadable) { + continue; + } + } else if (lower.endsWith(".class") + && !isFrameworkClass(entryName)) { + try { + inspect(readAll(zip.getInputStream(entry)), found); + } catch (Throwable unreadable) { + continue; + } + } + } + } catch (Throwable unreadable) { + // Not an archive, or a broken one. Nothing can be read out of it, + // and guessing that it uses everything would charge the whole + // apparatus to every application that ships a stray file. + return; + } finally { + if (zip != null) { + try { + zip.close(); + } catch (java.io.IOException ignored) { + // Nothing useful to do with a failure to close. + } + } + } + } + + private static void inspectNested(byte[] archiveBytes, NearbyUsage found) { + java.util.zip.ZipInputStream in = new java.util.zip.ZipInputStream( + new java.io.ByteArrayInputStream(archiveBytes)); + try { + java.util.zip.ZipEntry entry = in.getNextEntry(); + while (entry != null) { + String entryName = entry.getName(); + if (!entry.isDirectory() + && entryName.toLowerCase(java.util.Locale.ROOT) + .endsWith(".class") + && !isFrameworkClass(entryName)) { + inspect(readAll(in), found); + } + entry = in.getNextEntry(); + } + } catch (Throwable unreadable) { + return; + } finally { + try { + in.close(); + } catch (java.io.IOException ignored) { + // Nothing useful to do with a failure to close. + } + } + } + + private static boolean isFrameworkClass(String path) { + String normalized = path.replace('\\', '/'); + for (int iter = 0; iter < FRAMEWORK_PREFIXES.length; iter++) { + if (normalized.indexOf(FRAMEWORK_PREFIXES[iter]) >= 0) { + return true; + } + } + return false; + } + + private static void inspect(byte[] bytes, NearbyUsage found) { + if (bytes == null || bytes.length == 0) { + return; + } + String text; + try { + text = new String(bytes, "ISO-8859-1"); + } catch (java.io.UnsupportedEncodingException never) { + return; + } + found.ranging |= text.indexOf(RANGING_MARKER) >= 0; + found.transport |= text.indexOf(TRANSPORT_MARKER) >= 0; + found.companion |= text.indexOf(COMPANION_MARKER) >= 0; + found.presence |= text.indexOf(PRESENCE_MARKER) >= 0; + } + + private static byte[] readAll(java.io.File file) { + try { + java.io.InputStream in = new java.io.FileInputStream(file); + try { + return readAll(in); + } finally { + in.close(); + } + } catch (Throwable unreadable) { + return null; + } + } + + private static byte[] readAll(java.io.InputStream in) { + java.io.ByteArrayOutputStream out = + new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + try { + int read = in.read(buffer); + while (read > 0) { + out.write(buffer, 0, read); + read = in.read(buffer); + } + } catch (java.io.IOException unreadable) { + return out.toByteArray(); + } + return out.toByteArray(); + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java new file mode 100644 index 00000000000..78bbff3ab8f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java @@ -0,0 +1,187 @@ +/* + * 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.builders; + +import com.codename1.build.shared.PlatformFeatureCatalog; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A library can be the only thing that uses the nearby packages. + * + *

The class scanner behind the feature flags reads loose {@code .class} + * files and never opens a jar, so an application that calls a library which + * calls {@code NearbyTransport} names no nearby class itself and left every + * flag false. Android then deleted the implementation package out of the + * generated sources and iOS left the native defines off, so the library + * called into classes the build had removed.

+ */ +public class NearbyLibraryScanTest { + + /** + * A stand-in class file. + * + *

Not real bytecode, and it does not need to be: the scan is a + * search of the whole file for the package name, which is how every + * constant pool stores a reference to a class in it.

+ */ + private static byte[] classBytes(String reference) { + return reference.getBytes(StandardCharsets.ISO_8859_1); + } + + private static void writeJar(File jar, String entry, byte[] body) + throws Exception { + OutputStream raw = new FileOutputStream(jar); + ZipOutputStream out = new ZipOutputStream(raw); + try { + out.putNextEntry(new ZipEntry(entry)); + out.write(body); + out.closeEntry(); + } finally { + out.close(); + } + } + + @Test + public void aTransportReferenceInsideAJarCounts(@TempDir File dir) + throws Exception { + writeJar(new File(dir, "mylib.jar"), "com/acme/Wrapper.class", + classBytes("com/codename1/nearby/transport/NearbyTransport")); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertTrue(usage.usesTransport(), "a jar entry naming the transport" + + " package must count as transport use"); + assertFalse(usage.usesRanging(), + "nothing named the ranging package"); + assertFalse(usage.isEmpty(), "the scan found something"); + } + + @Test + public void aNestedClassesJarInsideAnAarCounts(@TempDir File dir) + throws Exception { + File inner = new File(dir, "inner.jar"); + writeJar(inner, "com/acme/Ranger.class", + classBytes("com/codename1/nearby/ranging/Ranging")); + byte[] innerBytes = Files.readAllBytes(inner.toPath()); + assertTrue(inner.delete(), "the staging jar is removed so only the" + + " archive under test is scanned"); + writeJar(new File(dir, "mylib.aar"), "classes.jar", innerBytes); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertTrue(usage.usesRanging(), "an Android archive keeps its" + + " bytecode one level further in"); + } + + /** + * Presence is a call, so the marker is the method name -- and the + * cleanup call must not match it, for the reason + * {@code NearbyPresenceScanTest} gives. + */ + @Test + public void onlyTheStartCallCountsAsPresence(@TempDir File dir) + throws Exception { + writeJar(new File(dir, "stopper.jar"), "com/acme/Stopper.class", + classBytes("com/codename1/nearby/companion/CompanionDevices" + + "stopObservingPresence")); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertTrue(usage.usesCompanion(), "it does associate"); + assertFalse(usage.usesPresence(), + "stopObservingPresence is cleanup, not observation"); + } + + /** + * The framework's own classes are not evidence about the application. + * A staged framework jar naming these packages would otherwise report + * every application as using all of them. + */ + @Test + public void theFrameworksOwnClassesDoNotCount(@TempDir File dir) + throws Exception { + writeJar(new File(dir, "cn1.jar"), + "com/codename1/nearby/transport/NearbyTransport.class", + classBytes("com/codename1/nearby/transport/Endpoint")); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertTrue(usage.isEmpty(), + "the API's own classes say nothing about the application"); + } + + @Test + public void anUnreadableArchiveIsNotUsage(@TempDir File dir) + throws Exception { + Files.write(new File(dir, "broken.jar").toPath(), + "not an archive".getBytes(StandardCharsets.ISO_8859_1)); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertTrue(usage.isEmpty(), "a file that cannot be read must not" + + " charge the whole apparatus to the application"); + } + + @Test + public void nothingIsFoundInAnEmptyTree(@TempDir File dir) { + assertTrue(NearbyManifestFragments.scanForNearbyUsage(dir).isEmpty()); + assertTrue(NearbyManifestFragments.scanForNearbyUsage(null).isEmpty(), + "a null root is answered rather than thrown at"); + } + + /** + * The catalog answers to the prefixes the builders feed it. + * + *

The library scan has no class names to consume -- it works from a + * search of the whole file, not a resolved reference -- so it feeds the + * catalog its entry prefix. Nothing else checks that the two agree, and + * a renamed package would silently stop supplying the dependency, the + * framework and the minimum SDK while the feature flags stayed on: a + * build that keeps AndroidUwbRanging.java with nothing to compile it + * against, and enables the iOS defines with nothing to link.

+ */ + @Test + public void theCatalogAnswersToTheBuildersPrefixes() { + String[] prefixes = { + "com/codename1/nearby/ranging/", + "com/codename1/nearby/transport/", + "com/codename1/nearby/companion/", + }; + for (int i = 0; i < prefixes.length; i++) { + PlatformFeatureCatalog.Accumulator acc = + new PlatformFeatureCatalog.Accumulator(); + acc.consume(prefixes[i]); + assertFalse(acc.hits().isEmpty(), + "the catalog must have an entry for " + prefixes[i] + + "; the library scan consumes exactly this string"); + } + } +} From 30a7c4134f0c43952ea995de7f792ea24c581ce6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:56:46 +0300 Subject: [PATCH 60/94] Decode both stranded-acknowledgement callers takeAllAcksFromPeer started returning [payloadId, length] pairs so a send stranded by a disconnect could report the total its progress had. One of its two callers was updated; the other, the deliberate-close path, went on sending intValue to what was now an NSArray. That is an unrecognized selector, so closing a session with a send still in flight crashed before either the failure or the disconnection reached the app -- the opposite of what that loop is there to guarantee. The return type spells the pair out now, and the loop variables with it. An untyped NSArray * could not catch this; the typed one does, verified by putting the mistake back on a copy and watching clang name it. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index a391d4825ed..ec92d14e3ee 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -788,10 +788,12 @@ - (void)closeSessionFor:(NSString *)endpointId { // didChangeState from reaching takeAllAcksFromPeer, so a // deliberate close left an accepted send with no terminal status // at all -- and its bookkeeping alive until a full stop swept it. - for (NSNumber *stranded in [self takeAllAcksFromPeer:endpointId]) { + for (NSArray *stranded in + [self takeAllAcksFromPeer:endpointId]) { com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), - (JAVA_INT)[stranded intValue], 0, -1, + (JAVA_INT)[[stranded objectAtIndex:0] intValue], 0, + (JAVA_LONG)[[stranded objectAtIndex:1] longLongValue], CN1_NEARBY_PAYLOAD_FAILURE); } com_codename1_impl_ios_IOSNearbyCallbacks_disconnected___java_lang_String( @@ -992,7 +994,7 @@ - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid } /// Takes every payload still waiting on a peer, for a disconnect. -- (NSArray *)takeAllAcksFromPeer:(NSString *)pid { +- (NSArray *> *)takeAllAcksFromPeer:(NSString *)pid { @synchronized (self) { NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; NSMutableArray *out = [NSMutableArray array]; @@ -1000,6 +1002,13 @@ - (NSArray *)takeAllAcksFromPeer:(NSString *)pid { // terminal updates -- the same count they would have got as acks. // Each is a pair of the payload id and the length it was sent with, // so a stranded send reports the same total its progress did. + // + // The return type spells the pair out. It used to be a flat array of + // ids, and when it became pairs one of the two callers went on + // sending intValue to what was now an NSArray -- an unrecognized + // selector, so a deliberate close of a session with a send in flight + // crashed before either the failure or the disconnection was + // delivered. An untyped NSArray * cannot catch that; this can. for (NSNumber *key in [ids allKeys]) { for (NSNumber *length in [ids objectForKey:key]) { [out addObject:[NSArray arrayWithObjects:key, length, nil]]; @@ -1201,7 +1210,8 @@ - (void)session:(MCSession *)session peer:(MCPeerID *)peerID if ([self takeEverConnected:pid]) { // Anything still waiting on this peer will never be // acknowledged, so it is failed rather than left pending. - for (NSArray *stranded in [self takeAllAcksFromPeer:pid]) { + for (NSArray *stranded in + [self takeAllAcksFromPeer:pid]) { com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), (JAVA_INT)[[stranded objectAtIndex:0] intValue], 0, From 12e096a1da40967b458c97bbb33556ecb75285d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:10:24 +0300 Subject: [PATCH 61/94] Address the forty-sixth nearby review round - A start whose answer lands after its stop no longer reports success. Google answers startAdvertising and startDiscovery asynchronously, and a stop can land in front of that answer -- which told the caller the radio was active AFTER it had stopped it, and left the platform start running behind a stop that had already returned. Both now carry a generation, the stale answer fails SESSION_INVALIDATED, and the stop is re-issued so the start does not outlive it. The simulated bridge has modelled this race from the start; this is the same answer, down to the wording. - Cancelling a byte payload cancels it. The loop only cancelled file transfers, which are the ones carrying an NSProgress, so cancelling an accepted byte send did nothing at all and the send went on to report SUCCESS. Its acknowledgement bookkeeping is taken now and answered CANCELED, which also makes the ack that was already coming a no-op. What cancel() promises is corrected with it. It said "on both sides", and no backend can keep that promise for bytes: the payload is handed to the platform whole and none of the three offers a handle to take it back -- Nearby cannot recall queued bytes either. Cancelling one stops this side reporting it as delivered; the peer may still receive it, and the documentation now says so rather than implying otherwise. --- .../nearby/transport/NearbyTransport.java | 14 ++++- .../nearby/AndroidNearbyTransport.java | 42 ++++++++++++++ Ports/iOSPort/nativeSources/CN1Nearby.m | 56 +++++++++++++++++-- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java index e67f97cadaf..d1b7df31aff 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -351,7 +351,19 @@ public static AsyncResource send(Endpoint[] endpoints, return out; } - /// Cancels an in-flight payload on both sides. Idempotent. + /// Cancels an in-flight payload. Idempotent. + /// + /// The send reaches + /// [PayloadStatus#CANCELED] on this side, and a transfer + /// the platform can still recall is recalled -- which for a file is + /// every byte not yet sent, on all three implementations. + /// + /// A BYTE payload is a different matter, and the same on every one of + /// them: it is handed to the platform whole, and no platform offers a + /// handle to take it back. Cancelling one that has already been accepted + /// stops this side reporting it as delivered, but the peer may receive + /// it anyway. Cancel a byte payload to stop waiting on it, not to + /// prevent its arrival. /// /// #### Parameters /// diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index b7d17dae25a..81831d32b5e 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -114,6 +114,16 @@ public class AndroidNearbyTransport implements NearbyBridge { new java.util.HashSet()); private final Map endpointServices = Collections.synchronizedMap(new HashMap()); + /// Which start each asynchronous answer belongs to. + /// + /// A stop can land between a start and the platform's answer to it, and + /// so can a second start. Without these the late answer resolved the + /// caller's request as though the state it describes were still current. + /// Read and written on the main thread, which is where Google delivers + /// these listeners and where the portable API is called from. + private int advertiseGeneration; + private int discoverGeneration; + private String advertisingServiceId = ""; private String discoveryServiceId = ""; private String localName = ""; @@ -234,6 +244,13 @@ public void startAdvertising(final int requestId, String serviceId, final String started = serviceId == null ? "" : serviceId; this.advertisingServiceId = started; this.localName = localName == null ? "" : localName; + // The generation this start belongs to. Google answers the start + // asynchronously, and a stopAdvertising can land in front of that + // answer -- which then told the caller advertising was active AFTER + // it had stopped it, and left the platform start running behind a + // stop that had already returned. The simulated bridge has modelled + // this race from the beginning; this is the same answer. + final int generation = ++advertiseGeneration; AdvertisingOptions options = new AdvertisingOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); @@ -241,6 +258,18 @@ public void startAdvertising(final int requestId, String serviceId, connectionCallback(started), options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { + if (generation != advertiseGeneration) { + // Stopped, so the platform is advertising for a + // caller that no longer wants it. Undone here, + // because the stop that ran before this had + // nothing to stop. + client().stopAdvertising(); + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "advertising was stopped before it" + + " started"); + return; + } NearbyTransport.deliverRequestOk(requestId); } }) @@ -254,6 +283,7 @@ public void onFailure(Exception e) { } public void stopAdvertising() { + advertiseGeneration++; client().stopAdvertising(); } @@ -267,12 +297,23 @@ public void startDiscovery(final int requestId, String serviceId, // discovering. The field remains for the state a later call needs. final String started = serviceId == null ? "" : serviceId; this.discoveryServiceId = started; + // The generation this start belongs to, for the reason + // startAdvertising keeps one. + final int generation = ++discoverGeneration; DiscoveryOptions options = new DiscoveryOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); client().startDiscovery(started, discoveryCallback(started), options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { + if (generation != discoverGeneration) { + client().stopDiscovery(); + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "discovery was stopped before it" + + " started"); + return; + } NearbyTransport.deliverRequestOk(requestId); } }) @@ -286,6 +327,7 @@ public void onFailure(Exception e) { } public void stopDiscovery() { + discoverGeneration++; client().stopDiscovery(); } diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index ec92d14e3ee..54fcda2c167 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -993,6 +993,33 @@ - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid } } +/// Takes every peer's outstanding sends of ONE payload, for a cancel. +/// +/// #### Returns +/// +/// pairs of the peer id and the length each send was recorded with +- (NSArray *)takeAcksForPayload:(JAVA_INT)payloadId { + @synchronized (self) { + NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; + NSMutableArray *out = [NSMutableArray array]; + for (NSString *pid in [self.awaitingAck allKeys]) { + NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; + NSMutableArray *outstanding = [ids objectForKey:key]; + if (outstanding == nil) { + continue; + } + for (NSNumber *length in outstanding) { + [out addObject:[NSArray arrayWithObjects:pid, length, nil]]; + } + [ids removeObjectForKey:key]; + if ([ids count] == 0) { + [self.awaitingAck removeObjectForKey:pid]; + } + } + return out; + } +} + /// Takes every payload still waiting on a peer, for a disconnect. - (NSArray *> *)takeAllAcksFromPeer:(NSString *)pid { @synchronized (self) { @@ -3041,15 +3068,36 @@ void com_codename1_impl_ios_IOSNative_nearbyCancelPayload___int( if (cn1nbTransport == nil) { return; } - // A file transfer CAN be cancelled -- sendResourceAtURL hands back an + // A file transfer CAN be recalled -- sendResourceAtURL hands back an // NSProgress for exactly that -- so it is, and the completion handler - // reports the failure. A byte payload cannot: sendData has left by the - // time anything could ask, which is the same outcome an app gets from - // cancelling one anywhere. + // reports the cancellation. NSArray *all = [cn1nbTransport takeProgressesForPayload:payloadId]; for (NSProgress *progress in all) { [progress cancel]; } + // A byte payload cannot be recalled: sendData has left by the time + // anything could ask, and MultipeerConnectivity offers no handle on + // it. The SEND is still cancelled, which is what the portable API + // promises and what Android and the simulator do -- Nearby cannot + // recall queued bytes either. Its acknowledgement bookkeeping is + // taken here and answered CANCELED, so the send reaches the terminal + // status the caller asked for instead of reporting SUCCESS when the + // acknowledgement it was already going to get comes back. Dropping + // the entry is also what makes that later ack a no-op. + for (NSArray *cancelled in + [cn1nbTransport takeAcksForPayload:payloadId]) { + NSString *pid = [cancelled objectAtIndex:0]; + MCPeerID *peer = [cn1nbTransport peerForId:pid]; + if (peer == nil) { + continue; + } + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + CN1_THREAD_STATE_PASS_ARG + cn1nbJString([cn1nbTransport encodePeer:peer]), + payloadId, 0, + (JAVA_LONG)[[cancelled objectAtIndex:1] longLongValue], + CN1_NEARBY_PAYLOAD_CANCELED); + } } #endif } From cea2363350d2f97280d61b9938a3f2bc0dad16d8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:20:16 +0300 Subject: [PATCH 62/94] Address the forty-seventh nearby review round - Waiting on a permission result gives up when its host goes away. The flag the loop polls is cleared by the activity the result is delivered TO, and Android delivers it to whichever activity is alive when the dialog closes -- so an activity recreated while the dialog was open left the flag set for the life of the process. The invokeAndBlock worker spun on it and the permission request never resolved. The final grant check reads the current context with it, since a grant belongs to the application rather than to the activity that asked. - Endpoint metadata survives a disconnection while discovery still sees the peer. A connection can close with the peer still advertised and still in the discovered set, and clearing its name and service there meant the later onEndpointLost encoded it out of empty maps: the app was told an endpoint it knew by name had been lost, with no name and no service on it. The two paths are symmetric now -- whichever of them is the last event to mention the endpoint is the one that clears it. --- .../android/nearby/AndroidNearbyBackend.java | 37 ++++++++++++++++++- .../nearby/AndroidNearbyTransport.java | 18 +++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 91a1b0b25a3..53fb9b3e17d 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -384,6 +384,16 @@ static boolean requestTogether(Activity activity, List perms) { @Override public void run() { while (host.isRequestForPermission()) { + // The flag is cleared by the activity the result is + // delivered TO, and Android delivers it to whichever + // activity is alive when the dialog closes. If this one + // was recreated while the dialog was open its flag is + // never cleared again -- so this loop spun for the life + // of the process, holding the invokeAndBlock worker and + // leaving the permission request unresolved for good. + if (isGone(host)) { + return; + } try { Thread.sleep(50); } catch (InterruptedException interrupted) { @@ -393,8 +403,17 @@ public void run() { } } }); + // Asked of the CURRENT context, not the activity captured above, + // which may be the one that just went away. A grant belongs to the + // application, so any live context answers for it. + Activity current = AndroidImplementation.getActivity(); + Context ctx = current != null ? (Context) current + : activity.getApplicationContext(); + if (ctx == null) { + return false; + } for (int i = 0; i < perms.size(); i++) { - if (activity.checkSelfPermission(perms.get(i)) + if (ctx.checkSelfPermission(perms.get(i)) != PackageManager.PERMISSION_GRANTED) { return false; } @@ -402,6 +421,22 @@ public void run() { return true; } + /// True when waiting on this activity can no longer end. + /// + /// Either it has been destroyed, or the port has moved on to another one + /// -- in both cases the permission result is going somewhere else and + /// this activity's flag stays set for good. + private static boolean isGone(Activity host) { + if (host.isFinishing()) { + return true; + } + if (Build.VERSION.SDK_INT >= 17 && host.isDestroyed()) { + return true; + } + Activity current = AndroidImplementation.getActivity(); + return current != null && current != host; + } + // ------------------------------------------------------------------ // Ranging // ------------------------------------------------------------------ diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 81831d32b5e..758e2c9c950 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -622,9 +622,21 @@ public void onDisconnected(String endpointId) { NearbyTransport.deliverDisconnected( encode(endpointId, nameOf(endpointId))); connectedEndpoints.remove(endpointId); - endpointNames.remove(endpointId); - // Cleared with the name, for the reason onEndpointLost does. - endpointServices.remove(endpointId); + // Only when discovery has ALSO lost sight of it. A connection + // can close while the peer is still being advertised and + // still in the discovered set -- the app disconnects, or the + // link drops -- and dropping the name and service there meant + // the later onEndpointLost for that same peer encoded it out + // of empty maps, so the app was told an endpoint it knew by + // name had been lost, with no name and no service on it. + // + // When discovery is not watching it, this is the last event + // that will ever mention the endpoint, so the entries go now + // or they never do. + if (!discoveredEndpoints.contains(endpointId)) { + endpointNames.remove(endpointId); + endpointServices.remove(endpointId); + } } }; } From 759b0a2c7f9bf9dc6b2ff66680133dcf6d7b63d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:26:20 +0300 Subject: [PATCH 63/94] Address the forty-eighth nearby review round - Stopping the whole transport invalidates pending starts. stopAdvertising and stopDiscovery bump the generations so a start still in flight cannot come back and report success into a stop that already returned, and stopAllTransport -- which is what the public stop() calls -- went straight to the client and left them alone. - It clears discovery visibility for every endpoint, not only the ones it can forget. Discovery has stopped, so no onEndpointLost is coming for any of them, and onDisconnected only drops an endpoint's metadata when discovery is no longer watching it. A connected endpoint left in that set made the check answer "still discovered" forever, so its name and service outlived the stop and a later reuse of the endpoint id inherited them. - A permission wait is handed to the replacement activity rather than abandoned. The flag it polls is instance state cleared by whichever activity receives the result, so a recreation mid-dialog stranded it -- but giving up the moment a new activity appeared answered "not granted" while the dialog was still on screen and untouched, telling an app that rotated at the wrong moment that the user had refused. The replacement is marked so its own result callback ends the wait, the actual grant state is polled alongside it for the case where the answer arrived before the swap was noticed, and only that case -- a denial nothing can observe -- falls back to a deadline. --- .../android/nearby/AndroidNearbyBackend.java | 77 +++++++++++++------ .../nearby/AndroidNearbyTransport.java | 18 ++++- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 53fb9b3e17d..eabf0c31cb3 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -380,18 +380,56 @@ static boolean requestTogether(Activity activity, List perms) { // this indistinguishable from the port's other permission requests. activity.requestPermissions( missing.toArray(new String[missing.size()]), 1); + final List requested = missing; + final Context checkAgainst = activity.getApplicationContext() != null + ? activity.getApplicationContext() : (Context) activity; Display.getInstance().invokeAndBlock(new Runnable() { @Override public void run() { - while (host.isRequestForPermission()) { - // The flag is cleared by the activity the result is - // delivered TO, and Android delivers it to whichever - // activity is alive when the dialog closes. If this one - // was recreated while the dialog was open its flag is - // never cleared again -- so this loop spun for the life - // of the process, holding the invokeAndBlock worker and - // leaving the permission request unresolved for good. - if (isGone(host)) { + // The flag is instance state, cleared by the activity the + // result is delivered TO -- and Android delivers it to + // whichever activity is alive when the dialog closes. So a + // recreation while the dialog is open leaves THIS instance's + // flag set for good, and waiting on it alone spun for the + // life of the process, holding the invokeAndBlock worker and + // leaving the request unresolved. + // + // The wait is handed to the replacement rather than + // abandoned. Abandoning it answered "not granted" the moment + // the new activity appeared -- while the dialog was still on + // screen and the user had not touched it yet, so an app that + // rotated its screen at the wrong moment was told the user + // had refused. + CodenameOneActivity waiting = host; + long deadline = 0; + while (waiting.isRequestForPermission()) { + // The grant itself, which any context can answer and + // which no recreation can hide. This is what ends the + // wait when the result reached the replacement before + // the swap was noticed and its flag could be set. + if (allGranted(checkAgainst, requested)) { + return; + } + Activity current = AndroidImplementation.getActivity(); + if (current != waiting) { + if (!(current instanceof CodenameOneActivity)) { + // No CodenameOne activity at all: nothing will + // receive the result, so nothing will end this. + return; + } + waiting = (CodenameOneActivity) current; + waiting.setRequestForPermission(true); + waiting.setWaitingForPermissionResult(true); + // Bounded from the swap onwards. If the answer was + // delivered before the flag above was set, nothing + // will ever clear it -- and a denial is invisible to + // the grant check, so only a deadline ends that. It + // is generous because the person is being asked a + // question; the caller can ask again. + deadline = System.currentTimeMillis() + 120000L; + } + if (deadline != 0 + && System.currentTimeMillis() > deadline) { return; } try { @@ -421,20 +459,15 @@ public void run() { return true; } - /// True when waiting on this activity can no longer end. - /// - /// Either it has been destroyed, or the port has moved on to another one - /// -- in both cases the permission result is going somewhere else and - /// this activity's flag stays set for good. - private static boolean isGone(Activity host) { - if (host.isFinishing()) { - return true; - } - if (Build.VERSION.SDK_INT >= 17 && host.isDestroyed()) { - return true; + /// Whether every one of these permissions is granted right now. + private static boolean allGranted(Context ctx, List perms) { + for (int i = 0; i < perms.size(); i++) { + if (ctx.checkSelfPermission(perms.get(i)) + != PackageManager.PERMISSION_GRANTED) { + return false; + } } - Activity current = AndroidImplementation.getActivity(); - return current != null && current != host; + return true; } // ------------------------------------------------------------------ diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 758e2c9c950..7ac10a07f69 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -490,6 +490,15 @@ public void stopAllTransport() { // broadcasting and scanning -- burning the radio and still taking // endpoint and connection callbacks -- while the public stop() // documents exactly the opposite. + // The generations go up here too. stopAdvertising() and + // stopDiscovery() bump them so a start still in flight cannot come + // back and report success into a stop that already returned -- and + // this method, which is what the public stop() calls, went straight + // to the client and left them alone. A start pending across it + // therefore passed its check and resolved as though it had survived + // the stop. + advertiseGeneration++; + discoverGeneration++; client().stopAdvertising(); client().stopDiscovery(); client().stopAllEndpoints(); @@ -507,8 +516,15 @@ public void stopAllTransport() { for (String id : discoveredOnly) { endpointNames.remove(id); endpointServices.remove(id); - discoveredEndpoints.remove(id); } + // Discovery visibility goes for EVERYTHING, connected or not. + // Discovery has stopped, so no onEndpointLost is coming for any + // of these -- and onDisconnected only clears an endpoint's + // metadata when discovery is no longer watching it. Leaving a + // connected endpoint in this set made that check answer "still + // discovered" forever, so its name and service survived the stop + // and a later reuse of the same endpoint id inherited them. + discoveredEndpoints.clear(); } // The transfer maps are NOT cleared here either. stopAllEndpoints // produces terminal payload callbacks asynchronously, and those From e076d0b695cadc34bd8e6da97c767220bac38c9f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:46:41 +0300 Subject: [PATCH 64/94] Address the forty-ninth nearby review round - Disconnecting a simulated endpoint before its acceptance cancels it. The acceptance is queued behind the disconnect and the request itself has already been answered, so what arrived afterwards was a connection the app had explicitly dropped -- the simulator being the one place a disconnect could be undone by the connection it was cancelling. The reservation is the claim on that acceptance, so taking it is what stops it, and the connection outcome is answered rather than dropped so nothing waiting on it waits for good. Both directions are tested. - Each activation waiter gets its own timeout. One timer drained the whole array, so an older waiter's two seconds settled a request made moments before it fired: a second association could fail RADIO_UNAVAILABLE almost immediately, and even after activation went on to succeed well inside that request's own window. - A finished file transfer reports the bytes it moved. It emitted SUCCESS with nothing transferred and no total, so a listener that finalises its display from the terminal event recorded a completed file as having moved zero bytes. MultipeerConnectivity's completion handler carries only an error, so the size is read once up front; a transfer that stopped short reports what its NSProgress reached. --- .../impl/nearby/LocalNearbyBridge.java | 28 ++++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 49 +++++++++++-- .../com/codename1/nearby/LocalNearbyTest.java | 73 +++++++++++++++++++ 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 260ef3b4398..8ae981f4a29 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -644,8 +644,16 @@ public void run() { // adding the endpoint then reported a connection on // a transport that had been stopped and never // restarted. - connecting.remove(endpointId); - if (generation != transportGeneration) { + // + // The reservation is also the claim on this + // acceptance. disconnect() takes it away, and + // without that check the acceptance went on to + // connect an endpoint the app had explicitly + // disconnected -- so the simulator was the one place + // a disconnect could be undone by the connection it + // was cancelling. + boolean reserved = connecting.remove(endpointId); + if (!reserved || generation != transportGeneration) { return; } connected.add(endpointId); @@ -808,6 +816,22 @@ public void disconnect(String endpointId) { if (e != null) { NearbyTransport.deliverDisconnected(e.encode()); } + return; + } + // Not connected YET. The acceptance is queued behind this call, and + // taking its reservation is what stops it: the request itself has + // already been answered, so what would otherwise arrive is a + // connection the app asked to drop. + if (connecting.remove(endpointId)) { + SimEndpoint e = findEndpoint(endpointId); + if (e != null) { + // Answered rather than dropped, so nothing waiting on the + // connection outcome waits for good. + NearbyTransport.deliverConnectionResult(e.encode(), false, + NearbyError.SESSION_INVALIDATED.ordinal(), + "the connection was disconnected before it" + + " completed"); + } } } diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 54fcda2c167..66c7a7a02eb 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1859,19 +1859,36 @@ - (void)whenActive:(void (^)(BOOL active))handler { handler(YES); return; } + void (^queued)(BOOL) = [[handler copy] autorelease]; @synchronized (self) { if (self.activationWaiters == nil) { self.activationWaiters = [NSMutableArray array]; } - [self.activationWaiters addObject:[[handler copy] autorelease]]; + [self.activationWaiters addObject:queued]; } dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2ull * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ @autoreleasepool { - // Drains whatever is still queued, which is nothing at all in the - // ordinary case: activation got there first and took them. - [self drainWaiters:self.active]; + // THIS waiter and no other. Draining the whole array here meant + // an older waiter's timer settled a request queued moments + // before it fired -- so a second association could fail + // RADIO_UNAVAILABLE a fraction of a second after it was made, + // and even after activation went on to succeed well inside its + // own two seconds. Ordinarily this finds nothing: activation got + // there first and took every waiter with it. + BOOL mine = NO; + @synchronized (self) { + NSUInteger at = [self.activationWaiters + indexOfObjectIdenticalTo:queued]; + if (at != NSNotFound) { + [self.activationWaiters removeObjectAtIndex:at]; + mine = YES; + } + } + if (mine) { + queued(self.active); + } } }); } @@ -2876,6 +2893,17 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i // had nothing to report but zero. NSString *sentName = [NSString stringWithFormat:@"cn1id-%d-%@", (int)payloadId, [p lastPathComponent]]; + // Read once, here, so the terminal update can report the size + // the transfer moved. MultipeerConnectivity's completion handler + // carries an error and nothing else, and reporting zero moved + // and no total contradicted every progress update before it -- + // so a listener that finalises its display from the terminal + // event recorded a finished file as having transferred nothing. + NSNumber *fileSize = [[[NSFileManager defaultManager] + attributesOfItemAtPath:p error:NULL] + objectForKey:NSFileSize]; + JAVA_LONG fileBytes = fileSize == nil + ? -1 : (JAVA_LONG)[fileSize longLongValue]; NSUInteger started = 0; for (NSUInteger i = 0; i < [peers count]; i++) { MCPeerID *peer = [peers objectAtIndex:i]; @@ -2902,9 +2930,20 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i ? CN1_NEARBY_PAYLOAD_CANCELED : CN1_NEARBY_PAYLOAD_FAILURE; } + // SUCCESS means the whole file arrived, so it reports + // the whole file. A transfer that stopped short + // reports what its progress had reached, which the + // NSProgress still holds after the fact. + NSProgress *finished = [progressHolder count] > 0 + ? [progressHolder objectAtIndex:0] : nil; + JAVA_LONG moved = status == CN1_NEARBY_PAYLOAD_SUCCESS + ? fileBytes + : (finished == nil ? 0 + : (JAVA_LONG)[finished + completedUnitCount]); com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), - payloadId, 0, -1, status); + payloadId, moved, fileBytes, status); // Only THIS recipient's transfer is finished. The // others under the same payload id are still going, // and dropping the whole entry here left them diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index e883ea58bca..5d9d89ca096 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -832,6 +832,79 @@ public void connected(Endpoint e) { assertEquals(1, connected.size()); } + @Test + void disconnectingBeforeTheAcceptanceCancelsIt() { + // The acceptance is queued behind the disconnect, and the request + // itself has already been answered -- so without the reservation + // check what arrives afterwards is a connection the app explicitly + // dropped. The simulator was the one place a disconnect could be + // undone by the connection it was cancelling. + final List connected = new ArrayList(); + final List disconnected = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void connected(Endpoint e) { + connected.add(e); + } + + @Override + public void disconnected(Endpoint e) { + disconnected.add(e); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + assertNotNull(e); + + List queue = new ArrayList(); + bridge.deferForTest(queue); + NearbyTransport.requestConnection(e, "me"); + NearbyTransport.disconnect(e); + drain(queue); + + assertTrue(connected.isEmpty(), + "a disconnected endpoint must not connect afterwards: " + + connected); + assertTrue(disconnected.isEmpty(), + "nothing was connected, so nothing was disconnected: " + + disconnected); + } + + @Test + void disconnectingAfterTheAcceptanceStillDisconnects() { + // The other side of the same guard: taking the reservation must not + // cost a connected endpoint its ordinary disconnect. + final List disconnected = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void disconnected(Endpoint e) { + disconnected.add(e); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + + List queue = new ArrayList(); + bridge.deferForTest(queue); + NearbyTransport.requestConnection(e, "me"); + drain(queue); + NearbyTransport.disconnect(e); + assertEquals(1, disconnected.size(), + "a connected endpoint still reports its disconnection"); + } + /// Runs every parked delivery, including any the deliveries themselves /// park, until nothing is left. private static void drain(List queue) { From 39532dd4fbcdc3579bc911312bab1c470e00b8df Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:54:04 +0300 Subject: [PATCH 65/94] Settle a pending start on every iOS stop path Stopping the whole transport destroyed the advertiser and the browser and left both request ids pending, so the deferred settler found one unchanged half a second later and reported that a stopped transport had started. The single stops already settled their own; this is the third path, and all three now go through one function so they cannot drift again. That function FAILS the start rather than answering it true. The old answer reasoned that advertising really had started -- true of the radio, and beside the point to a caller whose question was whether it is advertising now, which is what startAdvertising documents its resolution to mean. Android and the simulated bridge both fail this case, with the wording reused here, so an app that branched on the answer behaved differently on iOS alone. That divergence is what this family of guards exists to remove. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 56 ++++++++++++++++++------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 66c7a7a02eb..7b3f5525ee6 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1685,6 +1685,33 @@ static void cn1nbApplyLocalName(CN1NearbyTransport *t, NSString *localName) { /// - `t`: the transport /// - `advertising`: YES for advertising, NO for discovery /// - `requestId`: the request to answer +/// Fails whichever start is still pending, because a stop just cancelled it. +/// +/// SESSION_INVALIDATED, and the same wording the simulated bridge uses. This +/// used to answer OK on the grounds that the framework HAD taken the start -- +/// true of the radio, and beside the point to the caller, whose question was +/// "is it advertising now". Android and the simulator both fail it, so an app +/// that branched on the answer behaved differently on iOS alone, which is the +/// divergence this whole family of guards exists to remove. +static void cn1nbCancelPendingStart(CN1NearbyTransport *t, BOOL advertising) { + if (t == nil) { + return; + } + int pending = advertising ? t.pendingAdvertiseRequest + : t.pendingDiscoverRequest; + if (advertising) { + t.pendingAdvertiseRequest = 0; + } else { + t.pendingDiscoverRequest = 0; + } + if (pending != 0) { + cn1nbFailTransport(pending, CN1_NEARBY_ERR_SESSION_INVALIDATED, + advertising + ? @"advertising was stopped before it started" + : @"discovery was stopped before it started"); + } +} + static void cn1nbSettleTransportStart(CN1NearbyTransport *t, BOOL advertising, int requestId) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, @@ -2649,17 +2676,12 @@ void com_codename1_impl_ios_IOSNative_nearbyStopAdvertising__( [cn1nbTransport.advertiser stopAdvertisingPeer]; cn1nbTransport.advertiser.delegate = nil; cn1nbTransport.advertiser = nil; - // Answered on the way out. Advertising DID start -- the framework - // took it -- and stopping before the grace period elapsed would - // otherwise leave the start's AsyncResource unresolved for good, - // because the deferred answer only fires for a request that is - // still pending. - int pending = cn1nbTransport.pendingAdvertiseRequest; - cn1nbTransport.pendingAdvertiseRequest = 0; - if (pending != 0) { - cn1nbTransportOk(pending); - } } + // Settled on the way out, and OUTSIDE the advertiser check. Stopping + // before the grace period elapsed would otherwise leave the start's + // AsyncResource unresolved for good, because the deferred answer + // only fires for a request that is still pending. + cn1nbCancelPendingStart(cn1nbTransport, YES); } #endif } @@ -2710,13 +2732,9 @@ void com_codename1_impl_ios_IOSNative_nearbyStopDiscovery__( [cn1nbTransport.browser stopBrowsingForPeers]; cn1nbTransport.browser.delegate = nil; cn1nbTransport.browser = nil; - // Answered on the way out, for the reason stopAdvertising is. - int pending = cn1nbTransport.pendingDiscoverRequest; - cn1nbTransport.pendingDiscoverRequest = 0; - if (pending != 0) { - cn1nbTransportOk(pending); - } } + // Settled on the way out, for the reason stopAdvertising is. + cn1nbCancelPendingStart(cn1nbTransport, NO); } #endif } @@ -3176,6 +3194,12 @@ void com_codename1_impl_ios_IOSNative_nearbyStopAllTransport__( cn1nbTransport.browser.delegate = nil; cn1nbTransport.browser = nil; } + // Both of them, for the reason the single stops settle their own: a + // start inside its grace period had its advertiser destroyed here + // while its request id stayed pending, so the deferred settler found + // it unchanged and reported that a stopped transport had started. + cn1nbCancelPendingStart(cn1nbTransport, YES); + cn1nbCancelPendingStart(cn1nbTransport, NO); [cn1nbTransport closeAllSessions]; [cn1nbTransport forgetInvitations]; [cn1nbTransport forgetAllPeers]; From d8d44e6ffc2b52712e6e0f1a4fc514bfe60ab642 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:02:33 +0300 Subject: [PATCH 66/94] Address the fiftieth nearby review round - A stale Android start no longer stops the one that replaced it. Google's stopAdvertising and stopDiscovery are global -- there is one advertiser per client -- so undoing a start that landed after its stop switched off whatever had taken over in the meantime, and that newer start then reported success on a radio this call had just disabled. The undo is kept for the case it was written for, a stop with nothing asked since, and a flag tells the two apart. - A superseded iOS start fails instead of succeeding. Replacing an advertiser within its grace period tears the first one down, so answering its caller true ran that continuation against a service that is no longer being advertised. It fails SESSION_INVALIDATED now, which is what a stop does to a pending start and what the other two backends do to a superseded one. - Closing a pending iOS connection says so. requestConnection resolves when the invitation is sent, so the outcome an app waits for is the connected or connectionFailed that follows -- and clearing the delegate is what stops the framework delivering either, so a disconnect during an unanswered invitation ended the attempt in silence. It now answers with the code and the wording the simulated bridge uses for the same case. --- .../nearby/AndroidNearbyTransport.java | 32 ++++++++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 49 ++++++++++++++++--- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 7ac10a07f69..f278f04f8c2 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -124,6 +124,17 @@ public class AndroidNearbyTransport implements NearbyBridge { private int advertiseGeneration; private int discoverGeneration; + /// Whether anyone still wants the radio doing this. + /// + /// A stale start has to tell "stopped, and nobody has asked since" from + /// "superseded by a newer start". Google's stopAdvertising and + /// stopDiscovery are GLOBAL -- there is one advertiser per client -- so + /// undoing a stale start in the second case stopped the replacement that + /// had just taken over, and that replacement then reported success on a + /// radio this call had switched off. + private boolean advertisingWanted; + private boolean discoveringWanted; + private String advertisingServiceId = ""; private String discoveryServiceId = ""; private String localName = ""; @@ -251,6 +262,7 @@ public void startAdvertising(final int requestId, String serviceId, // stop that had already returned. The simulated bridge has modelled // this race from the beginning; this is the same answer. final int generation = ++advertiseGeneration; + advertisingWanted = true; AdvertisingOptions options = new AdvertisingOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); @@ -262,8 +274,13 @@ public void onSuccess(Void unused) { // Stopped, so the platform is advertising for a // caller that no longer wants it. Undone here, // because the stop that ran before this had - // nothing to stop. - client().stopAdvertising(); + // nothing to stop -- but ONLY when nobody has + // asked to advertise since. stopAdvertising is + // global, so calling it when a newer start has + // taken over stopped that one instead. + if (!advertisingWanted) { + client().stopAdvertising(); + } NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_INVALIDATED.ordinal(), "advertising was stopped before it" @@ -284,6 +301,7 @@ public void onFailure(Exception e) { public void stopAdvertising() { advertiseGeneration++; + advertisingWanted = false; client().stopAdvertising(); } @@ -300,6 +318,7 @@ public void startDiscovery(final int requestId, String serviceId, // The generation this start belongs to, for the reason // startAdvertising keeps one. final int generation = ++discoverGeneration; + discoveringWanted = true; DiscoveryOptions options = new DiscoveryOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); @@ -307,7 +326,11 @@ public void startDiscovery(final int requestId, String serviceId, .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { if (generation != discoverGeneration) { - client().stopDiscovery(); + // Only when nobody has asked since, for the + // reason the advertising branch gives. + if (!discoveringWanted) { + client().stopDiscovery(); + } NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_INVALIDATED.ordinal(), "discovery was stopped before it" @@ -328,6 +351,7 @@ public void onFailure(Exception e) { public void stopDiscovery() { discoverGeneration++; + discoveringWanted = false; client().stopDiscovery(); } @@ -499,6 +523,8 @@ public void stopAllTransport() { // the stop. advertiseGeneration++; discoverGeneration++; + advertisingWanted = false; + discoveringWanted = false; client().stopAdvertising(); client().stopDiscovery(); client().stopAllEndpoints(); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 7b3f5525ee6..3f644ded41c 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -769,7 +769,7 @@ - (void)closeSessionFor:(NSString *)endpointId { // invitation had not been answered left the slot held -- and every later // STAR or POINT_TO_POINT request answered BUSY until the whole transport // was stopped. - [self clearInviting:endpointId]; + BOOL wasInviting = [self takeInviting:endpointId]; // Reported here, because clearing the delegate above is what stops // didChangeState:NotConnected from reporting it. A deliberate close is // still a disconnection as far as the app is concerned, and suppressing @@ -799,6 +799,28 @@ - (void)closeSessionFor:(NSString *)endpointId { com_codename1_impl_ios_IOSNearbyCallbacks_disconnected___java_lang_String( getThreadLocalData(), cn1nbJString(encoded)); } + return; + } + if (!wasInviting) { + return; + } + // Never connected, but an invitation WAS outstanding. requestConnection + // resolves as soon as the invitation is sent, so the outcome an app + // waits for is the connected or connectionFailed that follows -- and + // clearing the delegate above is what stops the framework delivering + // either. Closing here therefore ended the connection attempt in + // silence, and nothing would ever have said what became of it. + // + // The same code and the same wording the simulated bridge answers this + // case with, so the two agree about what a disconnect during a pending + // connection looks like. + MCPeerID *pending = [self peerForId:endpointId]; + if (pending != nil) { + com_codename1_impl_ios_IOSNearbyCallbacks_connectionResult___java_lang_String_boolean_int_java_lang_String( + getThreadLocalData(), cn1nbJString([self encodePeer:pending]), + JAVA_FALSE, CN1_NEARBY_ERR_SESSION_INVALIDATED, + cn1nbJString(@"the connection was disconnected before it" + @" completed")); } } @@ -1055,8 +1077,17 @@ - (void)markInviting:(NSString *)pid { /// Forgets an invitation that has been answered, either way. - (void)clearInviting:(NSString *)pid { + [self takeInviting:pid]; +} + +/// Forgets an invitation, answering whether one was outstanding. +- (BOOL)takeInviting:(NSString *)pid { @synchronized (self) { + if (![self.inviting containsObject:pid]) { + return NO; + } [self.inviting removeObject:pid]; + return YES; } } @@ -2652,12 +2683,16 @@ void com_codename1_impl_ios_IOSNative_nearbyStartAdvertising___int_java_lang_Str // A start within the grace period of an earlier one replaces its // pending id, and the earlier settler would then see a mismatch and // return -- leaving that caller's AsyncResource pending for good. - // Answered on the way out: advertising did start, and the newer call - // is what changed it. + // Failed on the way out, for the reason a stop fails one: the + // advertiser that caller asked for has just been torn down and + // replaced with another service id, so its continuation would run + // against a service that is no longer being advertised. int superseded = t.pendingAdvertiseRequest; t.pendingAdvertiseRequest = requestId; if (superseded != 0 && superseded != requestId) { - cn1nbTransportOk(superseded); + cn1nbFailTransport(superseded, + CN1_NEARBY_ERR_SESSION_INVALIDATED, + @"another advertising start replaced this one"); } [t.advertiser startAdvertisingPeer]; cn1nbSettleTransportStart(t, YES, requestId); @@ -2709,11 +2744,13 @@ void com_codename1_impl_ios_IOSNative_nearbyStartDiscovery___int_java_lang_Strin serviceType:t.discoverServiceType] autorelease]; t.browser.delegate = t; t.discoverStrategy = (int)strategy; - // Answered for the reason the advertising path is. + // Failed for the reason the advertising path is. int superseded = t.pendingDiscoverRequest; t.pendingDiscoverRequest = requestId; if (superseded != 0 && superseded != requestId) { - cn1nbTransportOk(superseded); + cn1nbFailTransport(superseded, + CN1_NEARBY_ERR_SESSION_INVALIDATED, + @"another discovery start replaced this one"); } [t.browser startBrowsingForPeers]; cn1nbSettleTransportStart(t, NO, requestId); From 579823f124833cd7f22a5c8456365b59d8bbd8e2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:13:34 +0300 Subject: [PATCH 67/94] Address the fifty-first nearby review round - Presence filtering keys off what was UNregistered. Observation survives process death -- the platform keeps watching and keeps binding the service -- while the registered set starts empty and fills one call at a time, so the first re-registration turned it into a whitelist and threw away appearances for every other association still being watched, until the app happened to re-register that one too. Not knowing yet is not the same as not wanting it. The registered set is gone rather than left unread: a set that looks authoritative without being it is what caused this. - Reading capabilities never waits. The bounded 400ms wait for the probe ran on the EDT -- the thread that draws -- so the first call after the permission was granted, which is exactly when an app asks, froze input and rendering while the UWB service bound. Until the probe lands the answer is distance alone, which is the safe direction: an app that believes it has less asks again and gets more, while one that believes it has more draws an arrow the device cannot aim. - A ranging subscription created after its stop is disposed. stop() could land between the session lookup and the subscription being installed: it removed the session and disposed what it found, which at that moment was nothing, and the start then handed a live subscription to a session nobody holds -- so the facade rejected the start while the radio went on ranging for the life of the process. The stop marks the session before reading it, and a start that loses the race disposes its own. --- .../android/nearby/AndroidUwbRanging.java | 80 ++++++++++++++----- .../nearby/CN1CompanionDeviceService.java | 29 +++++-- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index f9ae0b04528..52518f8b2c1 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -166,6 +166,10 @@ private void settleProbe(int bits) { probedCapabilities = bits; } probeInFlight = false; + // Nothing waits on this monitor any more -- a capability getter + // that blocked the EDT was worse than an incomplete answer -- but + // the notify stays: it costs nothing with no waiters and removing + // it would make adding one silently deadlock-prone later. capabilityLock.notifyAll(); } } @@ -216,27 +220,20 @@ public int getRangingCapabilities() { // produce would have an app draw an arrow that never moves. int bits = NearbyBridge.CAPABILITY_DISTANCE; // The scope that carries the answer is opened by a background probe, - // not here: opening one on the calling thread blocks it on the UWB - // system service binding, and this is called from the EDT. The wait - // below is bounded and only ever happens on a call that beats the - // probe; once it lands the answer is cached and every later call is - // free. + // and this call NEVER waits for it. Opening a scope blocks on the UWB + // system service binding, and this getter is called from the EDT -- + // the thread that draws -- so waiting even a bounded 400ms for it + // froze input and rendering on the first call after the permission + // was granted, which is exactly when an app asks. + // + // Until the probe lands the answer is distance alone, which is the + // conservative direction: an app that believes it has less than it + // does asks again and gets more, while one that believes it has more + // draws an arrow the device cannot aim. Once the probe lands every + // later call has the full answer. startCapabilityProbe(); int probed; - long deadline = System.currentTimeMillis() + 400; synchronized (capabilityLock) { - while (probedCapabilities < 0 && probeInFlight) { - long left = deadline - System.currentTimeMillis(); - if (left <= 0) { - break; - } - try { - capabilityLock.wait(left); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - break; - } - } probed = probedCapabilities; } if (probed > 0) { @@ -438,7 +435,7 @@ private void run(final int requestId, final Session session, // signal of its own -- a session that starts cleanly and simply // has nothing in range to measure yet. session.startRequest.set(requestId); - session.subscription = UwbClientSessionScopeRx + Disposable started = UwbClientSessionScopeRx .rangingResultsObservable(session.scope, params) .subscribeOn(Schedulers.io()) .subscribe(new io.reactivex.rxjava3.functions.Consumer< @@ -483,6 +480,29 @@ public void accept(Throwable error) { sessions.remove(Integer.valueOf(session.handle)); } }); + // Installed only if the session is still registered. + // + // stop() can land between the lookup at the top of this method + // and this line: it removes the session and disposes whatever + // subscription it finds, which at that moment is null. The start + // then handed a live UWB subscription to a session nobody holds, + // so the facade rejected the start -- its session is closed -- + // while the radio went on ranging for the life of the process. + boolean late; + synchronized (session) { + late = session.stopped; + if (!late) { + session.subscription = started; + } + } + if (late) { + started.dispose(); + session.accessoryStart = false; + session.startRequest.set(0); + fail(requestId, NearbyError.SESSION_INVALIDATED, + "the session was stopped before ranging started"); + return; + } scheduleStartGrace(session); } catch (Throwable t) { // Cleared for the reason the token failure above clears it: this @@ -571,8 +591,21 @@ private static void deliver(int handle, RangingResult result) { public void stopRangingSession(int sessionHandle) { Session session = sessions.remove(Integer.valueOf(sessionHandle)); - if (session != null && session.subscription != null) { - session.subscription.dispose(); + if (session == null) { + return; + } + // Marked before the subscription is read, so a start still on its way + // to installing one sees the stop and disposes it itself. Reading + // alone found null for a subscription that did not exist YET and left + // the radio ranging once it did. + Disposable doomed; + synchronized (session) { + session.stopped = true; + doomed = session.subscription; + session.subscription = null; + } + if (doomed != null) { + doomed.dispose(); } } @@ -668,6 +701,11 @@ private static final class Session { private int sessionId; private byte[] sessionKey; private Disposable subscription; + /// Whether stopRangingSession has taken this session. Guarded by the + /// session itself, which is also what guards `subscription`, so a + /// stop and a start that races it cannot both decide nothing needs + /// disposing. + private boolean stopped; /// The start request still waiting for an answer, or 0 once it has /// been answered. Answered exactly once, by whichever of the first /// measurement, the first error, or the grace timer gets there. diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index 23408a09372..f9398505923 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -80,7 +80,23 @@ public class CN1CompanionDeviceService extends CompanionDeviceService { /// Whether this service is the one that started the Codename One context. private boolean startedContext; - private static final Set OBSERVED = + /// Associations the app has explicitly STOPPED watching in this process. + /// + /// The filter keys off what was UNregistered, not off what was + /// registered. Observation survives process death -- the platform keeps + /// watching and keeps binding this service -- while a set of registered + /// ids starts empty and fills one registration at a time, so treating it + /// as the authoritative list made the first re-registration turn into a + /// whitelist: an appearance for a second still-watched association was + /// dropped until the app happened to re-register that one too, which it + /// may never do. + /// + /// Not knowing yet is not the same as not wanting it. Only an explicit + /// unregister says the app is done, and that is what this records. There + /// is deliberately no matching set of registered ids: nothing would read + /// it, and one that looked authoritative without being it is what caused + /// the defect. + private static final Set UNOBSERVED = Collections.synchronizedSet(new HashSet()); /// Records that the app asked to watch an association, so an event for @@ -91,7 +107,7 @@ public class CN1CompanionDeviceService extends CompanionDeviceService { /// - `associationId`: the association being watched public static void register(String associationId) { if (associationId != null) { - OBSERVED.add(associationId); + UNOBSERVED.remove(associationId); } } @@ -102,7 +118,7 @@ public static void register(String associationId) { /// - `associationId`: the association no longer watched public static void unregister(String associationId) { if (associationId != null) { - OBSERVED.remove(associationId); + UNOBSERVED.add(associationId); } } @@ -172,7 +188,7 @@ private void deliverByAddress(String address, boolean present) { if (address == null) { return; } - if (!OBSERVED.isEmpty() && !OBSERVED.contains(address)) { + if (UNOBSERVED.contains(address)) { return; } String encoded = sanitize(address) + '\t' + sanitize(address) + '\t' @@ -187,10 +203,11 @@ private void deliver(AssociationInfo info, boolean present) { android.net.MacAddress address = info.getDeviceMacAddress(); String mac = address == null ? null : address.toString(); String id = mac != null ? mac : Integer.toString(info.getId()); - if (!OBSERVED.isEmpty() && !OBSERVED.contains(id)) { + if (UNOBSERVED.contains(id)) { // The platform keeps watching until told otherwise, and it // outlives the process. An event for an association the app has - // since stopped watching is not the app's business. + // since stopped watching is not the app's business -- but one it + // simply has not re-registered yet still is. return; } CharSequence name = info.getDisplayName(); From 2bda5432595a0b3ea1a74e2ea33366c7b4905a78 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:20:55 +0300 Subject: [PATCH 68/94] Clear the wanted flag when the current start fails A failed start left it set, so an older start whose success was still on its way read it as "a live replacement owns the radio" and declined to undo itself. It then advertised on after both the explicit stop and the replacement's reported failure -- with nothing at all having asked for it. Only for the CURRENT generation: a failure that has already been superseded says nothing about the start that superseded it. --- .../android/nearby/AndroidNearbyTransport.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index f278f04f8c2..8099e2871ca 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -292,6 +292,15 @@ public void onSuccess(Void unused) { }) .addOnFailureListener(new OnFailureListener() { public void onFailure(Exception e) { + if (generation == advertiseGeneration) { + // Nothing is advertising and nothing is trying + // to. Leaving the flag set told an older start + // whose success is still on its way that a live + // replacement owned the radio, so it declined to + // undo itself -- and went on advertising after + // both an explicit stop and this failure. + advertisingWanted = false; + } NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); @@ -342,6 +351,11 @@ public void onSuccess(Void unused) { }) .addOnFailureListener(new OnFailureListener() { public void onFailure(Exception e) { + if (generation == discoverGeneration) { + // Cleared for the reason the advertising failure + // clears its own. + discoveringWanted = false; + } NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); From 9202b2de373f284abb876ffc35badd58ae037d4d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:27:51 +0300 Subject: [PATCH 69/94] Bind each acknowledgement timeout to its own send Peer and payload id did not identify a SEND. Sending the same Payload to the same peer again inside the thirty-second window let the earlier send's timer take the newer send's record and fail it well before its own timeout -- and the acknowledgement that then arrived for it was dropped as unknown, so a delivered payload was reported as failed and nothing ever corrected it. Each recorded send carries a token now, and a timeout settles the send that scheduled it or nothing at all. An acknowledgement frame still takes any outstanding send of that payload, because the frame carries a payload id and nothing else -- and with one length under one id it does not need to distinguish them. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 83 ++++++++++++++++++++----- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 3f644ded41c..87abdcb51cf 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -478,6 +478,8 @@ @interface CN1NearbyTransport : NSObject _ackToken; + [outstanding addObject:[NSArray arrayWithObjects: + [NSNumber numberWithLongLong:(long long)token], + [NSNumber numberWithLongLong:(long long)length], nil]]; + return token; } } @@ -994,6 +1013,16 @@ - (void)awaitAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid /// handing back the length that send was recorded with. - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid length:(JAVA_LONG *)outLength { + // Any outstanding send of it. An acknowledgement frame carries a payload + // id and nothing else, so it cannot name which send it answers -- and + // with equal lengths under one id, it does not need to. + return [self takeAck:payloadId fromPeer:pid token:0 length:outLength]; +} + +/// Takes one specific send when `token` is non-zero, or any outstanding one +/// when it is zero. +- (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid + token:(JAVA_LONG)token length:(JAVA_LONG *)outLength { @synchronized (self) { NSMutableDictionary *ids = [self.awaitingAck objectForKey:pid]; NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; @@ -1001,10 +1030,29 @@ - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid if (outstanding == nil || [outstanding count] == 0) { return NO; } + NSUInteger at = [outstanding count] - 1; + if (token != 0) { + at = NSNotFound; + for (NSUInteger i = 0; i < [outstanding count]; i++) { + NSArray *entry = [outstanding objectAtIndex:i]; + if ((JAVA_LONG)[[entry objectAtIndex:0] longLongValue] + == token) { + at = i; + break; + } + } + if (at == NSNotFound) { + // Its send is already settled. Taking whatever else is here + // would fail a DIFFERENT send, which is the bug the token + // exists to prevent. + return NO; + } + } if (outLength != NULL) { - *outLength = (JAVA_LONG)[[outstanding lastObject] longLongValue]; + *outLength = (JAVA_LONG)[[[outstanding objectAtIndex:at] + objectAtIndex:1] longLongValue]; } - [outstanding removeLastObject]; + [outstanding removeObjectAtIndex:at]; if ([outstanding count] == 0) { [ids removeObjectForKey:key]; } @@ -1030,8 +1078,9 @@ - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid if (outstanding == nil) { continue; } - for (NSNumber *length in outstanding) { - [out addObject:[NSArray arrayWithObjects:pid, length, nil]]; + for (NSArray *entry in outstanding) { + [out addObject:[NSArray arrayWithObjects:pid, + [entry objectAtIndex:1], nil]]; } [ids removeObjectForKey:key]; if ([ids count] == 0) { @@ -1059,8 +1108,9 @@ - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid // crashed before either the failure or the disconnection was // delivered. An untyped NSArray * cannot catch that; this can. for (NSNumber *key in [ids allKeys]) { - for (NSNumber *length in [ids objectForKey:key]) { - [out addObject:[NSArray arrayWithObjects:key, length, nil]]; + for (NSArray *entry in [ids objectForKey:key]) { + [out addObject:[NSArray arrayWithObjects:key, + [entry objectAtIndex:1], nil]]; } } [self.awaitingAck removeObjectForKey:pid]; @@ -3099,9 +3149,10 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i // fail a payload the peer already had. Reporting progress first // keeps the order right as well: SUCCESS can now only follow the // IN_PROGRESS it belongs to, never precede it. - [cn1nbTransport awaitAck:payloadId - fromPeer:[peerIds objectAtIndex:i] - length:(JAVA_LONG)[data length]]; + JAVA_LONG ackToken = [cn1nbTransport + awaitAck:payloadId + fromPeer:[peerIds objectAtIndex:i] + length:(JAVA_LONG)[data length]]; // Queued, not delivered. sendData returning YES says the message // was accepted for sending, and PayloadStatus.SUCCESS documents // that every byte ARRIVED -- which is what Android reports, @@ -3130,6 +3181,7 @@ CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), payloadId, JAVA_LONG unused = -1; if ([cn1nbTransport takeAck:payloadId fromPeer:[peerIds objectAtIndex:i] + token:ackToken length:&unused]) { com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), @@ -3140,6 +3192,7 @@ CN1_THREAD_STATE_PASS_ARG cn1nbJString(encoded), } [cn1nbTransport scheduleAckTimeout:payloadId fromPeer:[peerIds objectAtIndex:i] + token:ackToken encoded:encoded]; } if (!sent) { From fd7de15cec0cb22c3fa37b98712d86f1f60ccd18 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:49:06 +0300 Subject: [PATCH 70/94] Address the fifty-second nearby review round - Asking for ranging permissions asks whether ranging is SUPPORTED, not merely whether a bridge exists. Every other entry point on Ranging asks both questions and NearbyTransport.requestPermissions asks its own, so this one sent PERMISSION_RANGING to a phone with no UWB radio: either a prompt for a permission the hardware cannot use, or a true answer for a capability isSupported() reports it does not have. Tested with a bridge that does the transport and not ranging, which is what an ordinary Android phone is. - Answering an incoming connection claims it atomically. The test and the assignment were two steps, so an app that verifies its peer before answering -- which is what the authentication token is FOR -- could have two threads both read "unanswered" and both answer, racing an accept against a reject for one endpoint on Android. The outcome then had nothing to do with which call the app believed won. - The companion manager is looked up on the application context. It is a system service like any other and every context answers for it, but keying the lookup off the current activity meant that during a recreation, or from a service after the weak activity reference was collected, isCompanionSupported reported false, getAssociations answered empty, and disassociation and presence failed -- all for a manager that was available the whole time. An activity is needed to LAUNCH the chooser, and that is where it is still required. --- .../com/codename1/nearby/ranging/Ranging.java | 8 ++++- .../nearby/transport/IncomingConnection.java | 33 ++++++++++++++++--- .../android/nearby/AndroidNearbyBackend.java | 15 +++++++-- .../nearby/NearbyDegradationTest.java | 26 +++++++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java index ef36a3973fc..8e2b7d008d1 100644 --- a/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -169,7 +169,13 @@ public static RangingCapabilities getCapabilities() { public static AsyncResource requestPermissions( NearbyPermission... permissions) { NearbyBridge b = NearbyRequests.bridge(); - if (b == null) { + // Supported, not merely present. Every other entry point here asks + // both questions, and NearbyTransport.requestPermissions asks its own + // -- this one asked only whether a bridge existed, so an Android + // device without UWB went on to request UWB_RANGING, or answered + // true, for a capability isSupported() reports it does not have. + // A permission prompt for a radio the phone lacks is the worst of it. + if (b == null || !b.isRangingSupported()) { return failedBoolean(); } int bits = 0; diff --git a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java index eebe43093b1..ec3709b7eab 100644 --- a/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java +++ b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java @@ -83,7 +83,32 @@ public String getAuthenticationToken() { /// Whether [#accept()] or [#reject()] has already been called. public boolean isAnswered() { - return answered; + synchronized (this) { + return answered; + } + } + + /// Claims the right to answer this request, once. + /// + /// The test and the assignment are ONE operation. As two, an app that + /// verifies its peer asynchronously -- which is what the authentication + /// token is FOR, so it is the expected shape rather than an exotic one -- + /// could have two threads both read "unanswered" before either wrote, + /// and both go on to answer. On Android that races an acceptConnection + /// against a rejectConnection for one endpoint, and the connection + /// outcome then has nothing to do with which call the app believes won. + /// + /// #### Returns + /// + /// true for the one caller that may answer; false for every other + private boolean claim() { + synchronized (this) { + if (answered) { + return false; + } + answered = true; + return true; + } } /// Accepts the connection. The result arrives as @@ -91,10 +116,9 @@ public boolean isAnswered() { /// [TransportListener#connectionFailed], because the far side has to /// accept too. Calling this twice, or after [#reject()], does nothing. public void accept() { - if (answered) { + if (!claim()) { return; } - answered = true; NearbyBridge b = NearbyRequests.bridge(); if (b != null) { // Recorded before the call, so a port that refuses the acceptance @@ -108,10 +132,9 @@ public void accept() { /// Rejects the connection. Calling this twice, or after [#accept()], /// does nothing. public void reject() { - if (answered) { + if (!claim()) { return; } - answered = true; NearbyBridge b = NearbyRequests.bridge(); if (b != null) { b.rejectConnection(endpoint.getId()); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index eabf0c31cb3..4186875ca1e 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -515,12 +515,23 @@ public void stopRangingSession(int sessionHandle) { // Companion // ------------------------------------------------------------------ + /// The system service, looked up on the APPLICATION context. + /// + /// Not on the current activity. CompanionDeviceManager is a system + /// service like any other and every context answers for it -- but keying + /// the lookup off an activity meant that during a recreation, or when + /// this process-lived bridge is reached from a service after its weak + /// activity reference was collected, isCompanionSupported reported + /// false, getAssociations answered with an empty list, and disassociation + /// and presence failed. All of it for a manager that was available the + /// whole time. An activity is needed to LAUNCH the chooser, and that is + /// where it is required. private CompanionDeviceManager manager() { - if (Build.VERSION.SDK_INT < 26 || currentActivity() == null) { + if (Build.VERSION.SDK_INT < 26 || appContext == null) { return null; } try { - return (CompanionDeviceManager) currentActivity().getSystemService( + return (CompanionDeviceManager) appContext.getSystemService( Context.COMPANION_DEVICE_SERVICE); } catch (Throwable t) { return null; diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java index f6aa0eb9e51..3549a27da84 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java @@ -22,6 +22,7 @@ */ package com.codename1.nearby; +import com.codename1.impl.nearby.LocalNearbyBridge; import com.codename1.impl.nearby.NearbyRequests; import com.codename1.nearby.companion.AssociationRequest; import com.codename1.nearby.companion.CompanionDevices; @@ -66,6 +67,31 @@ void clear() { NearbyRequests.resetForTest(null); } + /// A bridge that does the transport but not ranging, which is what an + /// ordinary Android phone without a UWB radio is. + private static final class NoUwbBridge extends LocalNearbyBridge { + @Override + public boolean isRangingSupported() { + return false; + } + } + + @Test + void rangingPermissionsAreRefusedWhereRangingIsUnsupported() { + // A bridge EXISTING is not the same as ranging working. Asking only + // whether one was present sent PERMISSION_RANGING to a device with + // no UWB radio, which either prompted for a permission the hardware + // cannot use or answered true -- for a capability isSupported() + // reports it does not have. + NearbyRequests.resetForTest(new NoUwbBridge()); + assertFalse(Ranging.isSupported()); + assertFailedWith(NearbyError.NOT_SUPPORTED, + Ranging.requestPermissions(NearbyPermission.RANGING)); + // The transport half of the same device still works, and asks for + // its own permissions through its own entry point. + assertTrue(NearbyTransport.isSupported()); + } + @Test void everyEntryPointReportsItselfUnsupported() { assertFalse(Ranging.isSupported()); From 630b608ff029d1ac32ecb5178d59cf0ba3fd5a72 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:06:54 +0300 Subject: [PATCH 71/94] Address the fifty-third nearby review round - The transport's generation state is serialized. It is written by the public API, which runs on Codename One's EDT, and read by Google's Task listeners, which run on Android's main thread -- and the public API does not promise callers only one thread either. Unsynchronized, an increment could be lost, or a callback could read a stale pair and let a stopped start report success or stop the start that replaced it. The generation and the wanted flag are also read TOGETHER now, in one classification: read separately, a stop or a start landing between them could have the answer act on a state that never existed. - A presence event survives the process it arrived in. The platform starts the companion service for a sighting and does not start the application -- that is the premise of the feature -- so the event went into an in-memory backlog that died with an idle process the user never opened, and the platform does not replay. The listener documented to hear about the sighting "when the app next initializes" heard nothing at all. The service persists each event, bounded to the same 64 the in-memory backlog keeps, and the backend puts them back when it is built. addPresenceListener now asks for the bridge before replaying, because that is what gives a port the chance to restore -- an app whose init() only registers a listener touched nothing else that would have. The two backlogs cannot double up: each event carries a pid-qualified sequence, the process that delivered it records that sequence, and the restore skips exactly those. If the process died neither the set nor the in-memory backlog exists, and everything persisted replays. --- .../nearby/companion/CompanionDevices.java | 9 ++ .../android/nearby/AndroidNearbyBackend.java | 23 +++ .../nearby/AndroidNearbyTransport.java | 129 +++++++++++++---- .../nearby/CN1CompanionDeviceService.java | 137 +++++++++++++++++- 4 files changed, 267 insertions(+), 31 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index 803de8c9e21..b13b61ef7e4 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -290,6 +290,15 @@ public static void addPresenceListener(PresenceListener l) { if (l == null) { return; } + // Asked for BEFORE the backlog is replayed, and the answer thrown + // away. Building the bridge is what gives a port the chance to put + // back events that outlived the process they arrived in -- Android + // persists them, because the platform starts a service for a + // sighting without starting the app, and an idle process reclaimed + // before the user opens it took the in-memory backlog with it. + // Nothing else on this path would have touched the bridge, so an app + // whose init() only registers a listener never restored them. + NearbyRequests.bridge(); synchronized (LISTENERS) { LISTENERS.add(l); if (PENDING_PRESENCE.isEmpty() || replayingPresence) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 4186875ca1e..569ee4d9aa0 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -101,6 +101,29 @@ public AndroidNearbyBackend(Activity activity) { + "AndroidUwbRanging"); this.transport = load("com.codename1.impl.android.nearby." + "AndroidNearbyTransport"); + restorePresence(); + } + + /// Replays presence events that outlived the process they arrived in. + /// + /// The platform starts the companion service for a sighting and does not + /// start the application, so the event lands in an in-memory backlog that + /// dies with the process if the user never opens the app -- and the + /// platform does not replay it. The service persists them; this is where + /// they come back, which is the first thing an app touches on its way to + /// registering a presence listener. + private void restorePresence() { + String[] rows = CN1CompanionDeviceService.takePersistedPresence( + appContext); + for (int i = 0; i < rows.length; i++) { + int tab = rows[i].indexOf('\t'); + if (tab <= 0) { + continue; + } + CompanionDevices.deliverPresenceChanged( + rows[i].substring(tab + 1), + "1".equals(rows[i].substring(0, tab))); + } } /// The activity the association's result listener is installed on, or diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 8099e2871ca..645232ebc48 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -121,6 +121,16 @@ public class AndroidNearbyTransport implements NearbyBridge { /// caller's request as though the state it describes were still current. /// Read and written on the main thread, which is where Google delivers /// these listeners and where the portable API is called from. + /// Guards the four fields below. + /// + /// They are written by the public API, which runs on Codename One's EDT, + /// and read by Google's Task listeners, which run on Android's main + /// thread -- two different threads, and the public API does not promise + /// callers only one of them either. Unsynchronized, an increment could + /// be lost or a callback could read a stale pair and let a stopped start + /// report success, or stop the start that replaced it. + private final Object transportLock = new Object(); + private int advertiseGeneration; private int discoverGeneration; @@ -135,6 +145,73 @@ public class AndroidNearbyTransport implements NearbyBridge { private boolean advertisingWanted; private boolean discoveringWanted; + /// This start owns the operation and should report success. + private static final int START_CURRENT = 0; + /// It was stopped and nothing has asked since: undo it. + private static final int START_ORPHANED = 1; + /// A newer start owns the operation: leave the radio alone. + private static final int START_SUPERSEDED = 2; + + /// Claims a generation for a start that is about to be issued. + private int beginStart(boolean advertising) { + synchronized (transportLock) { + if (advertising) { + advertisingWanted = true; + return ++advertiseGeneration; + } + discoveringWanted = true; + return ++discoverGeneration; + } + } + + /// What a start's answer means, decided from BOTH fields at once. + /// + /// One reading, under the lock. As two -- is it current, then does + /// anyone want it -- a stop or a start landing between them could have + /// this answer act on a state that never existed. + private int classifyStart(boolean advertising, int generation) { + synchronized (transportLock) { + if (advertising) { + if (generation == advertiseGeneration) { + return START_CURRENT; + } + return advertisingWanted ? START_SUPERSEDED : START_ORPHANED; + } + if (generation == discoverGeneration) { + return START_CURRENT; + } + return discoveringWanted ? START_SUPERSEDED : START_ORPHANED; + } + } + + /// Records that the current start failed, so nothing is wanted any more. + private void failStart(boolean advertising, int generation) { + synchronized (transportLock) { + if (advertising) { + if (generation == advertiseGeneration) { + advertisingWanted = false; + } + return; + } + if (generation == discoverGeneration) { + discoveringWanted = false; + } + } + } + + /// Ends the operation, invalidating any start still in flight. + private void endStart(boolean advertising) { + synchronized (transportLock) { + if (advertising) { + advertiseGeneration++; + advertisingWanted = false; + return; + } + discoverGeneration++; + discoveringWanted = false; + } + } + private String advertisingServiceId = ""; private String discoveryServiceId = ""; private String localName = ""; @@ -261,8 +338,7 @@ public void startAdvertising(final int requestId, String serviceId, // it had stopped it, and left the platform start running behind a // stop that had already returned. The simulated bridge has modelled // this race from the beginning; this is the same answer. - final int generation = ++advertiseGeneration; - advertisingWanted = true; + final int generation = beginStart(true); AdvertisingOptions options = new AdvertisingOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); @@ -270,7 +346,8 @@ public void startAdvertising(final int requestId, String serviceId, connectionCallback(started), options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { - if (generation != advertiseGeneration) { + int state = classifyStart(true, generation); + if (state != START_CURRENT) { // Stopped, so the platform is advertising for a // caller that no longer wants it. Undone here, // because the stop that ran before this had @@ -278,7 +355,7 @@ public void onSuccess(Void unused) { // asked to advertise since. stopAdvertising is // global, so calling it when a newer start has // taken over stopped that one instead. - if (!advertisingWanted) { + if (state == START_ORPHANED) { client().stopAdvertising(); } NearbyTransport.deliverRequestFailed(requestId, @@ -292,15 +369,13 @@ public void onSuccess(Void unused) { }) .addOnFailureListener(new OnFailureListener() { public void onFailure(Exception e) { - if (generation == advertiseGeneration) { - // Nothing is advertising and nothing is trying - // to. Leaving the flag set told an older start - // whose success is still on its way that a live - // replacement owned the radio, so it declined to - // undo itself -- and went on advertising after - // both an explicit stop and this failure. - advertisingWanted = false; - } + // Nothing is advertising and nothing is trying to. + // Leaving the flag set told an older start whose + // success is still on its way that a live replacement + // owned the radio, so it declined to undo itself -- + // and went on advertising after both an explicit stop + // and this failure. + failStart(true, generation); NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); @@ -309,8 +384,7 @@ public void onFailure(Exception e) { } public void stopAdvertising() { - advertiseGeneration++; - advertisingWanted = false; + endStart(true); client().stopAdvertising(); } @@ -326,18 +400,18 @@ public void startDiscovery(final int requestId, String serviceId, this.discoveryServiceId = started; // The generation this start belongs to, for the reason // startAdvertising keeps one. - final int generation = ++discoverGeneration; - discoveringWanted = true; + final int generation = beginStart(false); DiscoveryOptions options = new DiscoveryOptions.Builder() .setStrategy(strategyFor(strategy)) .build(); client().startDiscovery(started, discoveryCallback(started), options) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { - if (generation != discoverGeneration) { + int state = classifyStart(false, generation); + if (state != START_CURRENT) { // Only when nobody has asked since, for the // reason the advertising branch gives. - if (!discoveringWanted) { + if (state == START_ORPHANED) { client().stopDiscovery(); } NearbyTransport.deliverRequestFailed(requestId, @@ -351,11 +425,9 @@ public void onSuccess(Void unused) { }) .addOnFailureListener(new OnFailureListener() { public void onFailure(Exception e) { - if (generation == discoverGeneration) { - // Cleared for the reason the advertising failure - // clears its own. - discoveringWanted = false; - } + // Cleared for the reason the advertising failure + // clears its own. + failStart(false, generation); NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); @@ -364,8 +436,7 @@ public void onFailure(Exception e) { } public void stopDiscovery() { - discoverGeneration++; - discoveringWanted = false; + endStart(false); client().stopDiscovery(); } @@ -535,10 +606,8 @@ public void stopAllTransport() { // to the client and left them alone. A start pending across it // therefore passed its check and resolved as though it had survived // the stop. - advertiseGeneration++; - discoverGeneration++; - advertisingWanted = false; - discoveringWanted = false; + endStart(true); + endStart(false); client().stopAdvertising(); client().stopDiscovery(); client().stopAllEndpoints(); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index f9398505923..bfdf1a53784 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -25,14 +25,19 @@ import android.annotation.SuppressLint; import android.companion.AssociationInfo; import android.companion.CompanionDeviceService; +import android.content.Context; +import android.content.SharedPreferences; import android.os.Build; +import android.util.Log; import com.codename1.impl.android.AndroidImplementation; import com.codename1.nearby.companion.CompanionDevices; import com.codename1.ui.Display; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; /// The service the platform wakes when an associated device comes into or @@ -193,7 +198,7 @@ private void deliverByAddress(String address, boolean present) { } String encoded = sanitize(address) + '\t' + sanitize(address) + '\t' + sanitize(address) + "\t0\t" + (present ? '1' : '0'); - CompanionDevices.deliverPresenceChanged(encoded, present); + record(this, encoded, present); } private void deliver(AssociationInfo info, boolean present) { @@ -220,9 +225,139 @@ private void deliver(AssociationInfo info, boolean present) { + sanitize(mac == null ? "" : mac) + '\t' + AndroidNearbyBackend.profileOrdinalOf(info) + '\t' + (present ? '1' : '0'); + record(this, encoded, present); + } + + // ------------------------------------------------------------------ + // The backlog that outlives the process + // ------------------------------------------------------------------ + + /// Where a presence event waits for an app that is not running. + /// + /// The platform starts this service for the event and does NOT start the + /// application, which is the whole premise of the feature -- so the + /// event goes into CompanionDevices' in-memory backlog, and if Android + /// reclaims this idle process before the user opens the app, that + /// backlog dies with it. The platform does not replay, so the listener + /// documented to hear about the sighting "when the app next initializes" + /// heard nothing at all. A record of what happened while the app was + /// away has to survive the app not being there. + private static final String PRESENCE_PREFS = "cn1-nearby-presence"; + private static final String PRESENCE_KEY = "backlog"; + /// The same bound CompanionDevices keeps, for the same reason: a device + /// that flaps for a week must not grow this without limit. + private static final int MAX_PERSISTED = 64; + + /// Sequence numbers this process has already handed to CompanionDevices. + /// + /// Its lifetime is exactly the in-memory backlog's, which is what makes + /// the two agree: an event this process delivered is already in that + /// backlog, so the restore must skip it, and if the process died neither + /// this set nor that backlog exists and every persisted event replays. + private static final Set DELIVERED_HERE = + Collections.synchronizedSet(new HashSet()); + + private static long presenceSequence; + + /// Persists an event and hands it to the in-memory backlog. + private static void record(Context ctx, String encoded, boolean present) { + String seq; + synchronized (CN1CompanionDeviceService.class) { + // Qualified by pid, so a sequence minted by an earlier process + // cannot be mistaken for one this process delivered. A recycled + // pid is harmless: the set that would have to match it is empty + // in a process that has delivered nothing. + seq = android.os.Process.myPid() + "-" + + Long.toString(++presenceSequence); + } + persist(ctx, seq + '\t' + (present ? '1' : '0') + '\t' + encoded); + DELIVERED_HERE.add(seq); CompanionDevices.deliverPresenceChanged(encoded, present); } + private static void persist(Context ctx, String row) { + if (ctx == null) { + return; + } + try { + SharedPreferences prefs = ctx.getSharedPreferences( + PRESENCE_PREFS, Context.MODE_PRIVATE); + String existing = prefs.getString(PRESENCE_KEY, ""); + List rows = new ArrayList(); + if (existing.length() > 0) { + for (String r : existing.split("\n")) { + if (r.length() > 0) { + rows.add(r); + } + } + } + rows.add(row); + while (rows.size() > MAX_PERSISTED) { + rows.remove(0); + } + StringBuilder out = new StringBuilder(); + for (int i = 0; i < rows.size(); i++) { + if (i > 0) { + out.append('\n'); + } + out.append(rows.get(i)); + } + prefs.edit().putString(PRESENCE_KEY, out.toString()).commit(); + } catch (Throwable unavailable) { + // Nothing can be done about a store that will not take it, and + // failing the event outright would lose what the in-memory + // backlog can still carry for a process that lives long enough. + Log.w("CN1Nearby", "presence backlog not persisted", unavailable); + } + } + + /// Hands back every persisted event this process has not already + /// delivered, and clears the store. + /// + /// Called when the nearby backend is built, which is what an app does on + /// its way to registering a presence listener. + /// + /// #### Parameters + /// + /// - `ctx`: any context; the store is per-application + /// + /// #### Returns + /// + /// rows of `present-flag TAB encoded`, oldest first, never null + static String[] takePersistedPresence(Context ctx) { + if (ctx == null) { + return new String[0]; + } + String existing; + try { + SharedPreferences prefs = ctx.getSharedPreferences( + PRESENCE_PREFS, Context.MODE_PRIVATE); + existing = prefs.getString(PRESENCE_KEY, ""); + prefs.edit().remove(PRESENCE_KEY).commit(); + } catch (Throwable unavailable) { + return new String[0]; + } + if (existing.length() == 0) { + return new String[0]; + } + List out = new ArrayList(); + for (String row : existing.split("\n")) { + int tab = row.indexOf('\t'); + if (tab <= 0) { + continue; + } + String seq = row.substring(0, tab); + if (DELIVERED_HERE.remove(seq)) { + // This process already gave it to CompanionDevices, so it is + // in the in-memory backlog and replaying it would deliver the + // same sighting twice. + continue; + } + out.add(row.substring(tab + 1)); + } + return out.toArray(new String[out.size()]); + } + private static String sanitize(String s) { if (s == null) { return ""; From 3a8eaa7a27e8208f2df24d793dd12d7f1b8f1ced Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:14:12 +0300 Subject: [PATCH 72/94] Address the fifty-fourth nearby review round - A failed replacement stops an advertiser left running for nobody. A start that succeeds after being superseded leaves exactly that: the platform advertising, and the caller who asked failed, because a newer start had taken the operation. If that newer start then failed too -- which it does when the platform refuses it as already advertising -- both resources had failed and the radio stayed on indefinitely with no caller owning it. The failure path knows about that case now, and only that case: a start that resolved successfully still owns its radio and is left alone. - Reserving the association chooser is one operation. The slot was tested in one place and taken forty lines later, so two callers both read it free and both took it -- the second overwriting the first, and a refusal then clearing the slot while the first chooser was still open, which let a third request replace its result listener and leave the original resource unresolved. It is taken where it is tested now, given back by every path between there and the chooser opening, and only ever by the request that still owns it. --- .../android/nearby/AndroidNearbyBackend.java | 64 +++++++++++++---- .../nearby/AndroidNearbyTransport.java | 72 ++++++++++++++++--- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 569ee4d9aa0..98e3bcdb695 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -90,8 +90,44 @@ public class AndroidNearbyBackend implements NearbyBridge { private final NearbyBridge ranging; private final NearbyBridge transport; + /// Guards pendingAssociateRequest. + /// + /// The chooser slot is a reservation, and a reservation tested in one + /// step and taken in another is not one: two callers both read it free + /// and both took it, the second overwriting the first, and a refusal + /// then cleared the slot while the first chooser was still open. The + /// public API does not promise associate() is called from one thread. + private final Object associateLock = new Object(); + private int pendingAssociateRequest; + /// Takes the chooser slot for this request, if it is free. + private boolean reserveAssociate(int requestId) { + synchronized (associateLock) { + if (pendingAssociateRequest != 0) { + return false; + } + pendingAssociateRequest = requestId; + return true; + } + } + + /// Gives the slot back, but only if this request still owns it. + private void releaseAssociate(int requestId) { + synchronized (associateLock) { + if (pendingAssociateRequest == requestId) { + pendingAssociateRequest = 0; + } + } + } + + /// The request holding the slot, or 0. + private int pendingAssociate() { + synchronized (associateLock) { + return pendingAssociateRequest; + } + } + public AndroidNearbyBackend(Activity activity) { this.initialActivity = new WeakReference(activity); Context app = activity == null ? null : activity @@ -151,7 +187,8 @@ private Activity listeningActivity() { /// BUSY. Called from AndroidImplementation.init through /// AndroidNearbyBridge, the one place that knows the activity changed. public void onActivityChanged() { - if (pendingAssociateRequest == 0) { + int outstanding = pendingAssociate(); + if (outstanding == 0) { return; } Activity current = currentActivity(); @@ -159,13 +196,13 @@ public void onActivityChanged() { return; } CompanionDeviceManager cdm = manager(); - if (cdm == null || !listenForResult(pendingAssociateRequest, cdm)) { + if (cdm == null || !listenForResult(outstanding, cdm)) { // Nothing can answer it now, so it is failed rather than left to // hang -- and the pending slot is released so the next // association is not refused as BUSY for a chooser nobody is // waiting on any more. - int requestId = pendingAssociateRequest; - pendingAssociateRequest = 0; + int requestId = outstanding; + releaseAssociate(requestId); listeningOn = null; CompanionDevices.deliverRequestFailed(requestId, NearbyError.USER_CANCELED.ordinal(), @@ -571,7 +608,9 @@ public void associate(final int requestId, int profile, "companion association needs Android 8 or later"); return; } - if (pendingAssociateRequest != 0) { + // Reserved HERE, where it is tested. Everything between this and the + // chooser opening gives it back on the way out. + if (!reserveAssociate(requestId)) { CompanionDevices.deliverRequestFailed(requestId, NearbyError.BUSY.ordinal(), "an association chooser is already open"); @@ -592,6 +631,7 @@ public void associate(final int requestId, int profile, String deviceProfile = Build.VERSION.SDK_INT >= 31 ? profileFor(profile) : null; if (deviceProfile == null) { + releaseAssociate(requestId); CompanionDevices.deliverRequestFailed(requestId, NearbyError.NOT_SUPPORTED.ordinal(), "this Android version has no companion profile " @@ -609,6 +649,7 @@ public void associate(final int requestId, int profile, // worth reporting, not one worth widening. for (int i = 0; filters != null && i < filters.length; i++) { if (!addFilter(request, filters[i])) { + releaseAssociate(requestId); CompanionDevices.deliverRequestFailed(requestId, NearbyError.INVALID_TOKEN.ordinal(), "this device filter could not be used: " + filters[i]); @@ -622,11 +663,10 @@ public void associate(final int requestId, int profile, // to see everything. A request with no filters at all is what makes // the platform scan all three transports, which is what the portable // API promises for an empty filter list. - pendingAssociateRequest = requestId; if (!listenForResult(requestId, cdm)) { // Nothing would ever answer this request, so it is refused now // rather than left pending while another flow takes its result. - pendingAssociateRequest = 0; + releaseAssociate(requestId); CompanionDevices.deliverRequestFailed(requestId, NearbyError.BUSY.ordinal(), "another activity result is outstanding; try again when" @@ -643,7 +683,7 @@ public void associate(final int requestId, int profile, try { associateNow(cdm, request.build(), requestId); } catch (Throwable refused) { - pendingAssociateRequest = 0; + releaseAssociate(requestId); releaseResultListener(); CompanionDevices.deliverRequestFailed(requestId, NearbyError.NOT_SUPPORTED.ordinal(), @@ -665,7 +705,7 @@ public void onDeviceFound(IntentSender chooserLauncher) { @Override public void onFailure(CharSequence error) { - pendingAssociateRequest = 0; + releaseAssociate(requestId); // The listener was installed before associate() was called, // and installing one marks CodenameOneActivity as waiting for // a result. Leaving it there when no chooser is ever launched @@ -687,7 +727,7 @@ private void launch(IntentSender chooserLauncher, int requestId) { // Failed rather than thrown, for the reason requestPermissions is. Activity host = currentActivity(); if (host == null) { - pendingAssociateRequest = 0; + releaseAssociate(requestId); releaseResultListener(); CompanionDevices.deliverRequestFailed(requestId, NearbyError.USER_CANCELED.ordinal(), @@ -699,7 +739,7 @@ private void launch(IntentSender chooserLauncher, int requestId) { host.startIntentSenderForResult(chooserLauncher, ASSOCIATE_REQUEST, null, 0, 0, 0); } catch (IntentSender.SendIntentException e) { - pendingAssociateRequest = 0; + releaseAssociate(requestId); // Same as the onFailure path: nothing will come back through the // listener, so it must not stay installed. releaseResultListener(); @@ -755,7 +795,7 @@ public void onActivityResult(int requestCode, int resultCode, // Dropped here, not only when the next association replaces // it: the flow this listener belongs to is over. listeningOn = null; - pendingAssociateRequest = 0; + releaseAssociate(requestId); if (resultCode != Activity.RESULT_OK) { CompanionDevices.deliverRequestFailed(requestId, NearbyError.USER_CANCELED.ordinal(), diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 645232ebc48..3c5adcae7d5 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -145,6 +145,16 @@ public class AndroidNearbyTransport implements NearbyBridge { private boolean advertisingWanted; private boolean discoveringWanted; + /// Whether the radio is on with NO resolved caller owning it. + /// + /// A start that succeeded after being superseded leaves exactly that: the + /// platform is advertising, and the caller who asked was failed, because + /// a newer start had taken the operation. If that newer start then fails + /// too, both resources have failed and the radio is still on for nobody + /// -- which is what this lets the failure notice and undo. + private boolean unownedAdvertising; + private boolean unownedDiscovering; + /// This start owns the operation and should report success. private static final int START_CURRENT = 0; /// It was stopped and nothing has asked since: undo it. @@ -184,18 +194,46 @@ private int classifyStart(boolean advertising, int generation) { } } + /// Records what a start's answer did, so a later failure knows whether + /// the radio is still on for nobody. + private void noteStartOutcome(boolean advertising, int state) { + synchronized (transportLock) { + // SUPERSEDED is the one outcome that leaves the platform running + // with its caller failed. CURRENT has an owner, and ORPHANED was + // just stopped. + boolean unowned = state == START_SUPERSEDED; + if (advertising) { + unownedAdvertising = unowned; + return; + } + unownedDiscovering = unowned; + } + } + /// Records that the current start failed, so nothing is wanted any more. - private void failStart(boolean advertising, int generation) { + /// + /// #### Returns + /// + /// true when the radio has to be stopped as well, because a superseded + /// start had left it running for a caller that was already failed + private boolean failStart(boolean advertising, int generation) { synchronized (transportLock) { if (advertising) { - if (generation == advertiseGeneration) { - advertisingWanted = false; + if (generation != advertiseGeneration) { + return false; } - return; + advertisingWanted = false; + boolean orphaned = unownedAdvertising; + unownedAdvertising = false; + return orphaned; } - if (generation == discoverGeneration) { - discoveringWanted = false; + if (generation != discoverGeneration) { + return false; } + discoveringWanted = false; + boolean orphaned = unownedDiscovering; + unownedDiscovering = false; + return orphaned; } } @@ -205,10 +243,12 @@ private void endStart(boolean advertising) { if (advertising) { advertiseGeneration++; advertisingWanted = false; + unownedAdvertising = false; return; } discoverGeneration++; discoveringWanted = false; + unownedDiscovering = false; } } @@ -347,6 +387,7 @@ public void startAdvertising(final int requestId, String serviceId, .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { int state = classifyStart(true, generation); + noteStartOutcome(true, state); if (state != START_CURRENT) { // Stopped, so the platform is advertising for a // caller that no longer wants it. Undone here, @@ -375,7 +416,14 @@ public void onFailure(Exception e) { // owned the radio, so it declined to undo itself -- // and went on advertising after both an explicit stop // and this failure. - failStart(true, generation); + // + // And an EARLIER start that already succeeded after + // being superseded left the platform advertising with + // its caller failed. Both resources have failed by + // now, so nobody is left to stop it but this. + if (failStart(true, generation)) { + client().stopAdvertising(); + } NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); @@ -408,6 +456,7 @@ public void startDiscovery(final int requestId, String serviceId, .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { int state = classifyStart(false, generation); + noteStartOutcome(false, state); if (state != START_CURRENT) { // Only when nobody has asked since, for the // reason the advertising branch gives. @@ -425,9 +474,12 @@ public void onSuccess(Void unused) { }) .addOnFailureListener(new OnFailureListener() { public void onFailure(Exception e) { - // Cleared for the reason the advertising failure - // clears its own. - failStart(false, generation); + // Cleared, and the radio stopped if it was left + // running for nobody, for the reasons the advertising + // failure gives. + if (failStart(false, generation)) { + client().stopDiscovery(); + } NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); From ee23c0cfc0f1c36312ed493a8806e244024101b1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:24:47 +0300 Subject: [PATCH 73/94] Address the fifty-fifth nearby review round - The orphaned-radio cleanup is issued under the lock. Deciding to stop and then releasing it left a window for another thread to begin a start, and the global stop that followed switched off THAT operation instead -- without touching its generation, so its callback went on to report success for a radio this had just disabled. The lock is held across the platform call for as long as it takes to issue it, and Google answers on the main looper rather than reentrantly. - A chooser is not launched for a request that no longer owns the slot. The platform keeps searching after associate() returns and answers on a later main-looper turn; an activity recreation in between fails the request and gives the slot back, and launching anyway put a chooser on screen for a resource that had already failed -- then sent its result to whatever flow the replacement activity had installed by then. - A received file reports the bytes it received. The terminal SUCCESS said nothing transferred and no total, contradicting every progress update before it, so a receiver that finalises its display from the terminal event recorded a finished file as empty. The size is read off the file now that it is in place, which is the one moment it is knowable. --- .../android/nearby/AndroidNearbyBackend.java | 9 ++++++ .../nearby/AndroidNearbyTransport.java | 30 +++++++++++++++---- Ports/iOSPort/nativeSources/CN1Nearby.m | 12 +++++++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 98e3bcdb695..2bf80bbc6c3 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -722,6 +722,15 @@ public void onFailure(CharSequence error) { } private void launch(IntentSender chooserLauncher, int requestId) { + // Still ours? The platform keeps searching after associate() returns + // and answers on a later main-looper turn, and an activity + // recreation in between can fail this request and give the slot + // back. Launching anyway put a chooser on screen for a resource that + // had already failed, and sent its result to whatever result flow the + // replacement activity had installed by then. + if (pendingAssociate() != requestId) { + return; + } // This runs from the platform's callback, which is a main-looper hop // after the activity was checked -- long enough for it to have gone. // Failed rather than thrown, for the reason requestPermissions is. diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 3c5adcae7d5..bbd04601ce7 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -210,6 +210,28 @@ private void noteStartOutcome(boolean advertising, int state) { } } + /// Fails the current start and cleans up after it, under the lock. + /// + /// The stop is issued from INSIDE the critical section. Deciding to stop + /// and then releasing the lock left a window for another thread to begin + /// a start, and the global stop that followed switched off that new + /// operation instead -- without touching its generation, so its callback + /// went on to report success for a radio this had just disabled. The + /// lock is held across the platform call for exactly as long as it takes + /// to issue it; Google answers it on the main looper, not here. + private void failAndCleanUp(boolean advertising, int generation) { + synchronized (transportLock) { + if (!failStart(advertising, generation)) { + return; + } + if (advertising) { + client().stopAdvertising(); + } else { + client().stopDiscovery(); + } + } + } + /// Records that the current start failed, so nothing is wanted any more. /// /// #### Returns @@ -421,9 +443,7 @@ public void onFailure(Exception e) { // being superseded left the platform advertising with // its caller failed. Both resources have failed by // now, so nobody is left to stop it but this. - if (failStart(true, generation)) { - client().stopAdvertising(); - } + failAndCleanUp(true, generation); NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); @@ -477,9 +497,7 @@ public void onFailure(Exception e) { // Cleared, and the radio stopped if it was left // running for nobody, for the reasons the advertising // failure gives. - if (failStart(false, generation)) { - client().stopDiscovery(); - } + failAndCleanUp(false, generation); NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_FAILED.ordinal(), e.getMessage()); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 87abdcb51cf..d3a08d70597 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1500,9 +1500,19 @@ - (void)session:(MCSession *)session // per-payload state on the documented terminal status waited forever // on every file that actually arrived -- the one case that always // works on Android. + // + // With the size it actually received, read off the file now that it + // is in place. Reporting nothing transferred and no total contradicted + // every progress update before it, so a receiver that finalises its + // display from the terminal event recorded a finished file as empty. + NSNumber *receivedSize = [[[NSFileManager defaultManager] + attributesOfItemAtPath:target error:NULL] + objectForKey:NSFileSize]; + JAVA_LONG receivedBytes = receivedSize == nil + ? -1 : (JAVA_LONG)[receivedSize longLongValue]; com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( getThreadLocalData(), cn1nbJString(encoded), filePayloadId, - 0, -1, CN1_NEARBY_PAYLOAD_SUCCESS); + receivedBytes, receivedBytes, CN1_NEARBY_PAYLOAD_SUCCESS); com_codename1_impl_ios_IOSNearbyCallbacks_payloadReceived___java_lang_String_int_int_byte_1ARRAY_java_lang_String( getThreadLocalData(), cn1nbJString(encoded), filePayloadId, CN1_NEARBY_PAYLOAD_FILE, JAVA_NULL, From 49778ad0a87687ed2ca090b185e3e4d54394eda4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:39:45 +0300 Subject: [PATCH 74/94] Address the fifty-sixth nearby review round - Stopping the simulated transport fails its pending connections. The request was already answered by the first hop, so the outcome an app waits for is the connected or connectionFailed that follows -- and clearing the reservation is what stops the queued acceptance delivering either, so a stop during a pending connection ended it in silence and the listener waited for good. - Presence usage is matched to the facade that owns the call. The library scan treated the bare bytes "startObservingPresence" anywhere in any class as observation, so a library with its own method of that name -- or a string literal spelling it -- handed an app that only associates the exported companion service and the background companion permissions. That is a store-review conversation rather than a few kilobytes of unused implementation. The constant pool is read properly now and a Methodref has to name CompanionDevices as its owner. Hand-read rather than taken from ASM, because this file is mirrored into a daemon pinned to an ASM that stops at Java 8 bytecode, and a scan the two copies disagree about is worse than no scan. An unknown tag abandons the pool rather than guessing its length. The package answers still fall back to a raw search when a class cannot be read: being wrong there costs bytes. Presence does not fall back, because being wrong there costs permissions. --- .../impl/nearby/LocalNearbyBridge.java | 15 ++ .../builders/NearbyManifestFragments.java | 180 +++++++++++++++++- .../builders/NearbyLibraryScanTest.java | 85 +++++++++ .../com/codename1/nearby/LocalNearbyTest.java | 41 ++++ 4 files changed, 313 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 8ae981f4a29..bf62e93500e 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -844,7 +844,22 @@ public void stopAllTransport() { transportGeneration++; discoverGeneration++; advertiseGeneration++; + // Failed, not merely forgotten. The request itself has already been + // answered by the first hop, so the outcome the app waits for is the + // connected or connectionFailed that follows -- and clearing the + // reservation is what stops the queued acceptance delivering either, + // so a stop during a pending connection ended it in silence. + List pending = new ArrayList(connecting); connecting.clear(); + for (String id : pending) { + SimEndpoint p = findEndpoint(id); + if (p != null) { + NearbyTransport.deliverConnectionResult(p.encode(), false, + NearbyError.SESSION_INVALIDATED.ordinal(), + "the transport was stopped before the connection" + + " completed"); + } + } cancelledPayloads.clear(); pendingPayloads.clear(); List doomed = new ArrayList(connected); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 1b1077c3532..16a1b8b9b7e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -543,16 +543,180 @@ private static void inspect(byte[] bytes, NearbyUsage found) { if (bytes == null || bytes.length == 0) { return; } - String text; - try { - text = new String(bytes, "ISO-8859-1"); - } catch (java.io.UnsupportedEncodingException never) { + ConstantPool pool = ConstantPool.read(bytes); + if (pool == null) { + // Not readable as a class file -- truncated, obfuscated past + // recognition, or simply not one. The PACKAGE answers fall back + // to a search of the raw bytes, which errs towards keeping an + // implementation that might be needed; the cost of being wrong + // is bytes. + // + // Presence does NOT fall back. Its cost is an exported service + // and the background companion permissions in the manifest of an + // app that never observes anything, which is a store-review + // conversation rather than a few kilobytes -- so an unreadable + // class says nothing about it. + String text; + try { + text = new String(bytes, "ISO-8859-1"); + } catch (java.io.UnsupportedEncodingException never) { + return; + } + found.ranging |= text.indexOf(RANGING_MARKER) >= 0; + found.transport |= text.indexOf(TRANSPORT_MARKER) >= 0; + found.companion |= text.indexOf(COMPANION_MARKER) >= 0; return; } - found.ranging |= text.indexOf(RANGING_MARKER) >= 0; - found.transport |= text.indexOf(TRANSPORT_MARKER) >= 0; - found.companion |= text.indexOf(COMPANION_MARKER) >= 0; - found.presence |= text.indexOf(PRESENCE_MARKER) >= 0; + // The UTF8 entries alone, not the whole file: every reference to a + // class is stored as its name in one of them, and a byte that + // happens to spell a package name inside the code array is not a + // reference to anything. + for (int iter = 0; iter < pool.size(); iter++) { + String utf8 = pool.utf8At(iter); + if (utf8 == null) { + continue; + } + found.ranging |= utf8.indexOf(RANGING_MARKER) >= 0; + found.transport |= utf8.indexOf(TRANSPORT_MARKER) >= 0; + found.companion |= utf8.indexOf(COMPANION_MARKER) >= 0; + } + // Presence is a CALL, and the owner is what makes it one. A library + // with its own startObservingPresence, or a string literal spelling + // it, is not this API being used -- and treating it as one gave an + // app that only associates the exported service and the background + // permissions of an app that observes. + found.presence |= pool.callsMethod(PRESENCE_OWNER, PRESENCE_MARKER); + } + + /// The class whose startObservingPresence means presence observation. + private static final String PRESENCE_OWNER = + "com/codename1/nearby/companion/CompanionDevices"; + + /// The constant pool of one class file, and nothing else from it. + /// + /// Hand-read rather than taken from ASM: this file is mirrored into the + /// BuildDaemon, which is pinned to an ASM that stops at Java 8 bytecode, + /// and a scan the two copies disagree about is worse than no scan. The + /// constant pool format has not changed since Java 1.0 and new tags are + /// skippable by length, so this reads every class file either tree will + /// ever be handed. + private static final class ConstantPool { + + private final int[] tags; + private final int[] first; + private final int[] second; + private final String[] strings; + + private ConstantPool(int count) { + tags = new int[count]; + first = new int[count]; + second = new int[count]; + strings = new String[count]; + } + + private int size() { + return tags.length; + } + + private String utf8At(int index) { + if (index < 0 || index >= strings.length) { + return null; + } + return strings[index]; + } + + /// Whether some Methodref names this owner and this method. + private boolean callsMethod(String owner, String method) { + for (int iter = 0; iter < tags.length; iter++) { + // 10 Methodref, 11 InterfaceMethodref. A static call on a + // final class is the first; the second is here so a facade + // reached through an interface counts too. + if (tags[iter] != 10 && tags[iter] != 11) { + continue; + } + if (!owner.equals(classNameAt(first[iter]))) { + continue; + } + int nameAndType = second[iter]; + if (nameAndType < 0 || nameAndType >= tags.length + || tags[nameAndType] != 12) { + continue; + } + if (method.equals(utf8At(first[nameAndType]))) { + return true; + } + } + return false; + } + + private String classNameAt(int index) { + if (index < 0 || index >= tags.length || tags[index] != 7) { + return null; + } + return utf8At(first[index]); + } + + /// Reads the pool, or null when this is not a class file it can read. + private static ConstantPool read(byte[] b) { + if (b == null || b.length < 10) { + return null; + } + if ((b[0] & 0xff) != 0xCA || (b[1] & 0xff) != 0xFE + || (b[2] & 0xff) != 0xBA || (b[3] & 0xff) != 0xBE) { + return null; + } + int count = u2(b, 8); + if (count < 1) { + return null; + } + ConstantPool pool = new ConstantPool(count); + int at = 10; + try { + for (int iter = 1; iter < count; iter++) { + int tag = b[at++] & 0xff; + pool.tags[iter] = tag; + if (tag == 1) { + int length = u2(b, at); + at += 2; + pool.strings[iter] = new String(b, at, length, + "UTF-8"); + at += length; + } else if (tag == 7 || tag == 8 || tag == 16 + || tag == 19 || tag == 20) { + pool.first[iter] = u2(b, at); + at += 2; + } else if (tag == 15) { + pool.first[iter] = b[at + 1] & 0xff; + at += 3; + } else if (tag == 3 || tag == 4) { + at += 4; + } else if (tag == 5 || tag == 6) { + at += 8; + // A long or a double takes TWO pool entries, and the + // second is unusable. Skipping the increment here is + // the classic way to misread every entry after one. + iter++; + } else if (tag == 9 || tag == 10 || tag == 11 + || tag == 12 || tag == 17 || tag == 18) { + pool.first[iter] = u2(b, at); + pool.second[iter] = u2(b, at + 2); + at += 4; + } else { + // A tag from a class file newer than this code knows. + // Its length is unknown, so nothing after it can be + // read: the pool is abandoned rather than guessed at. + return null; + } + } + } catch (Throwable truncated) { + return null; + } + return pool; + } + + private static int u2(byte[] b, int at) { + return ((b[at] & 0xff) << 8) | (b[at + 1] & 0xff); + } } private static byte[] readAll(java.io.File file) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java index 78bbff3ab8f..4d71215ead9 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java @@ -184,4 +184,89 @@ public void theCatalogAnswersToTheBuildersPrefixes() { + "; the library scan consumes exactly this string"); } } + + /** + * A class file carrying one Methodref: {@code owner.method()}. + * + *

Only the constant pool is read, so only the constant pool is + * built. Hand-assembled because the point of the test is that the + * owner and the method name are tied together, which is exactly what a + * flat byte search cannot see.

+ */ + private static byte[] callingClass(String owner, String method) { + java.io.ByteArrayOutputStream out = + new java.io.ByteArrayOutputStream(); + java.io.DataOutputStream d = new java.io.DataOutputStream(out); + try { + d.writeInt(0xCAFEBABE); + d.writeShort(0); + d.writeShort(52); + // 1 owner utf8, 2 method utf8, 3 descriptor utf8, 4 Class, + // 5 NameAndType, 6 Methodref -- so a count of 7. + d.writeShort(7); + d.writeByte(1); + d.writeUTF(owner); + d.writeByte(1); + d.writeUTF(method); + d.writeByte(1); + d.writeUTF("(Ljava/lang/String;)Z"); + d.writeByte(7); + d.writeShort(1); + d.writeByte(12); + d.writeShort(2); + d.writeShort(3); + d.writeByte(10); + d.writeShort(4); + d.writeShort(5); + d.flush(); + } catch (java.io.IOException never) { + throw new IllegalStateException(never); + } + return out.toByteArray(); + } + + @Test + public void presenceNeedsTheCallToBeOnTheFacade(@TempDir File dir) + throws Exception { + writeJar(new File(dir, "lib.jar"), "com/acme/Watcher.class", + callingClass("com/codename1/nearby/companion/CompanionDevices", + "startObservingPresence")); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertTrue(usage.usesPresence(), + "a call to the facade's startObservingPresence is presence"); + assertTrue(usage.usesCompanion(), + "and naming the class is companion use"); + } + + @Test + public void someoneElsesMethodOfThatNameIsNotPresence(@TempDir File dir) + throws Exception { + writeJar(new File(dir, "lib.jar"), "com/acme/Watcher.class", + callingClass("com/acme/OwnPresence", + "startObservingPresence")); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertFalse(usage.usesPresence(), "the name alone is not the API:" + + " charging an app the exported service and the background" + + " permissions for a library's own method is a" + + " store-review conversation"); + assertTrue(usage.isEmpty(), "and nothing else was named either"); + } + + @Test + public void anUnreadableClassNeverClaimsPresence(@TempDir File dir) + throws Exception { + // The package fallback still applies -- keeping an implementation + // that might be needed costs bytes -- but presence does not fall + // back, because being wrong there costs permissions. + writeJar(new File(dir, "lib.jar"), "com/acme/Odd.class", + classBytes("com/codename1/nearby/companion/CompanionDevices" + + "startObservingPresence")); + NearbyManifestFragments.NearbyUsage usage = + NearbyManifestFragments.scanForNearbyUsage(dir); + assertTrue(usage.usesCompanion(), "the package fallback still reads"); + assertFalse(usage.usesPresence(), + "an unreadable class says nothing about presence"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java index 5d9d89ca096..8ba98b17e04 100644 --- a/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -905,6 +905,47 @@ public void disconnected(Endpoint e) { "a connected endpoint still reports its disconnection"); } + @Test + void stoppingDuringAPendingConnectionFailsIt() { + // The request was already answered by the first hop, so the outcome + // the app waits for is what follows -- and clearing the reservation + // is what stops the queued acceptance delivering it. Ending the + // attempt in silence left a listener waiting for good. + final List connected = new ArrayList(); + final List failures = new ArrayList(); + final AtomicReference found = new AtomicReference(); + NearbyTransport.addTransportListener(new TransportAdapter() { + @Override + public void endpointFound(Endpoint e) { + found.compareAndSet(null, e); + } + + @Override + public void connected(Endpoint e) { + connected.add(e); + } + + @Override + public void connectionFailed(Endpoint e, NearbyException error) { + failures.add(Integer.valueOf(1)); + } + }); + value(NearbyTransport.startDiscovery("chat", TransportStrategy.STAR)); + Endpoint e = found.get(); + assertNotNull(e); + + List queue = new ArrayList(); + bridge.deferForTest(queue); + NearbyTransport.requestConnection(e, "me"); + NearbyTransport.stop(); + drain(queue); + + assertTrue(connected.isEmpty(), + "a stopped transport must not connect: " + connected); + assertEquals(1, failures.size(), + "the pending connection has to be answered, not dropped"); + } + /// Runs every parked delivery, including any the deliveries themselves /// park, until nothing is left. private static void drain(List queue) { From 8e94cae104fc6c7b5ac285f14767e7dc9141d21e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:44:45 +0300 Subject: [PATCH 75/94] Refuse the nearby transport when AndroidX is turned off play-services-nearby is AndroidX the whole way down its transitive closure. The preflight that catches this for every other feature reads the CATALOG's gradle dependencies, and the transport has none -- its artifact is turned on through the builder's own Play-services flag so it keeps the version this build resolved rather than one pinned in a table that cannot know. So the check that would have caught it could not see it, and AGP rejected the generated project instead, well after the build had committed to it and with a message naming androidx rather than anything the developer wrote. Refused beside the legacy-monolith check, which exists for the same shape of problem, and pinned by a test in both trees. --- .../builders/AndroidGradleBuilder.java | 18 ++++++++++++++++++ .../builders/NearbyPresenceScanTest.java | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) 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 20156ae00e6..e45795ac5ea 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 @@ -3211,6 +3211,24 @@ public void usesClassMethod(String cls, String method) { + " added for you.", new RuntimeException()); return false; } + + // And AndroidX, because the modular artifact's transitive closure is + // AndroidX the whole way down. The preflight that catches this for + // every other feature reads the CATALOG's gradle dependencies, and + // the transport has none -- its artifact is turned on through the + // Play-services flag above so it keeps the version this build's own + // table resolved. So the check that would have caught it cannot see + // it, and AGP rejected the generated project instead, well after the + // build had committed to it and with a message that names androidx + // rather than anything the developer wrote. + if (usesNearbyTransport && !useAndroidX) { + error("Error: com.codename1.nearby.transport needs" + + " play-services-nearby, whose transitive dependencies" + + " are AndroidX, and this build set" + + " android.useAndroidX=false. Remove that hint or set" + + " it to true.", new RuntimeException()); + return false; + } playServicesPlus = !request.getArg("android.playService.plus", "false" ).equals("false"); playServicesAuth = !request.getArg("android.playService.auth", (Boolean.valueOf(playFlag) || googleServicesJson.exists()) ? "true" : "false").equals("false"); playServicesBase = !request.getArg("android.playService.base", playFlag).equals("false"); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java index 4bde5e3b69c..d570c520a77 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java @@ -85,4 +85,23 @@ public void anyCompanionCallStillCountsAsCompanionUse() throws Exception { "companion use must be set for any CompanionDevices call," + " not only for the observing one"); } + + /** + * The transport refuses a build that turned AndroidX off. + * + *

play-services-nearby is AndroidX all the way down its transitive + * closure, and the preflight that catches this for every other feature + * reads the catalog's gradle dependencies -- which the transport does + * not use, because its artifact is selected through the builder's own + * Play-services table. So nothing would have caught it, and AGP + * rejected the generated project long after the build committed to + * it.

+ */ + @Test + public void theTransportRequiresAndroidX() throws Exception { + String src = scanner(); + assertTrue(src.contains("usesNearbyTransport && !useAndroidX"), + "the transport has to refuse android.useAndroidX=false" + + " itself; the catalog preflight cannot see it"); + } } From 8bd524fade5a8436710c33298bd40f9eb25d1779 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:00:49 +0300 Subject: [PATCH 76/94] Supersede an earlier simulated start with the newer one Only the stop moved the generation, so a second startAdvertising issued before the first answer ran shared its generation and BOTH resolved successfully -- while only one of them can be the current advertisement. Discovery had it worse: the stale callback went on to label every endpoint with the service id nobody was discovering under any more. Both start paths move it now, and a superseded start is told apart from a stopped one, because they are different things to an app: one it asked for, the other it did by asking again. The wording is the iOS port's, so the two agree about what happened. --- .../impl/nearby/LocalNearbyBridge.java | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index bf62e93500e..446e7c3d094 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -531,19 +531,33 @@ public void startAdvertising(final int requestId, String serviceId, String localName, int strategy) { advertising = true; advertiseStrategy = strategy; - final int generation = advertiseGeneration; + // Bumped by the START as well as by the stop. Only the stop moved it, + // so a second start issued before the first answer ran shared its + // generation and BOTH resolved successfully -- while only one of them + // can be the current advertisement. The ports fail a superseded + // start; this is the simulator agreeing with them. + final int generation = ++advertiseGeneration; answer(new Runnable() { @Override public void run() { // Stopped before the start was answered, the same race // discovery has: the answer is queued, and a stop can land // in front of it. - if (!advertising || generation != advertiseGeneration) { + if (!advertising) { NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_INVALIDATED.ordinal(), "advertising was stopped before it started"); return; } + if (generation != advertiseGeneration) { + // Told apart from the stop, because they are different + // things to an app: one it asked for, the other it did + // by asking again. Same wording the iOS port uses. + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "another advertising start replaced this one"); + return; + } NearbyTransport.deliverRequestOk(requestId); } }); @@ -560,7 +574,10 @@ public void startDiscovery(final int requestId, final String serviceId, int strategy) { discovering = true; discoverStrategy = strategy; - final int generation = discoverGeneration; + // Bumped by the START too, for the reason advertising is -- and here + // it also stops a superseded callback labelling every endpoint with + // the service id NOBODY is discovering under any more. + final int generation = ++discoverGeneration; answer(new Runnable() { @Override public void run() { @@ -568,12 +585,18 @@ public void run() { // discovery to report into. Reporting endpoints anyway had // the stopped simulator announcing peers nobody had asked // for, which is not what a device does. - if (!discovering || generation != discoverGeneration) { + if (!discovering) { NearbyTransport.deliverRequestFailed(requestId, NearbyError.SESSION_INVALIDATED.ordinal(), "discovery was stopped before it started"); return; } + if (generation != discoverGeneration) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "another discovery start replaced this one"); + return; + } NearbyTransport.deliverRequestOk(requestId); for (SimEndpoint e : endpoints) { e.serviceId = serviceId; From d570736eca2d6430ed1e3195ecedd6d30dd8bcfb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:01:39 +0300 Subject: [PATCH 77/94] Recognise a maxSdkVersion cap in every spelling XML allows android:maxSdkVersion = "30" and android:maxSdkVersion='30' are the same attribute as the one with no spaces and double quotes, and only the last was matched. A hand-written android.xpermissions fragment using either of the others therefore read as UNCAPPED, was left alone as already reaching far enough, and the nearby transport's discovery on Android 12 and 12L asked for a location grant the manifest still capped at 30 -- where the API refuses to start without one. The attribute is parsed now rather than string-matched, whitespace and either quote character, and the attribute name has to stand on its own so one that merely ends with it is not mistaken for it. The permission element is found under either quote for the same reason: missing the declaration meant adding a second one for the same permission. --- .../builders/NearbyManifestFragments.java | 94 +++++++++++++++---- .../builders/NearbyManifestFragmentsTest.java | 36 +++++++ 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java index 16a1b8b9b7e..95dcd0479d0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -265,7 +265,7 @@ private static String addPermission(String xPermissions, String name, /// @return the fragment, with the declaration added or widened static String widenPermission(String xPermissions, String name, int requiredThrough) { - int at = xPermissions.indexOf("\"" + name + "\""); + int at = indexOfQuoted(xPermissions, name); if (at < 0) { return addPermission(xPermissions, name, requiredThrough > 0 ? " android:maxSdkVersion=\"" + requiredThrough + "\"" : ""); @@ -276,33 +276,28 @@ static String widenPermission(String xPermissions, String name, return xPermissions; } String element = xPermissions.substring(start, end + 1); - String marker = "android:maxSdkVersion=\""; - int capAt = element.indexOf(marker); - if (capAt < 0) { + int[] cap = findAttribute(element, "android:maxSdkVersion"); + if (cap == null) { // Uncapped, so it already reaches further than anything asked for. return xPermissions; } - int capEnd = element.indexOf('"', capAt + marker.length()); - if (capEnd < 0) { - return xPermissions; - } - int cap; + int capped; try { - cap = Integer.parseInt(element.substring( - capAt + marker.length(), capEnd).trim()); + capped = Integer.parseInt( + element.substring(cap[2], cap[3]).trim()); } catch (NumberFormatException notANumber) { return xPermissions; } - if (requiredThrough > 0 && cap >= requiredThrough) { + if (requiredThrough > 0 && capped >= requiredThrough) { return xPermissions; } String widened; if (requiredThrough > 0) { - widened = element.substring(0, capAt + marker.length()) - + requiredThrough + element.substring(capEnd); + widened = element.substring(0, cap[2]) + requiredThrough + + element.substring(cap[3]); } else { - widened = element.substring(0, capAt) - + element.substring(capEnd + 1); + widened = element.substring(0, cap[0]) + + element.substring(cap[1]); // The attribute left a double space behind it. widened = widened.replace(" ", " "); } @@ -310,6 +305,73 @@ static String widenPermission(String xPermissions, String name, + xPermissions.substring(end + 1); } + /// Finds `value` written as an XML attribute value, under either quote. + /// + /// Not indexOf("\"" + value + "\""): a fragment an app wrote by hand is + /// as likely to use single quotes, and missing the declaration meant + /// adding a SECOND one for the same permission. + private static int indexOfQuoted(String xml, String value) { + int at = xml.indexOf("\"" + value + "\""); + if (at >= 0) { + return at; + } + return xml.indexOf("'" + value + "'"); + } + + /// Locates one attribute of an element, tolerating what XML allows. + /// + /// `android:maxSdkVersion="30"`, `android:maxSdkVersion = "30"` and + /// `android:maxSdkVersion='30'` are the same attribute, and only the + /// first was recognised -- so a permission an app had capped in either + /// of the other two spellings read as UNCAPPED, was left alone as + /// already reaching far enough, and discovery on Android 12 asked for a + /// location grant the manifest still capped at 30. + /// + /// @param element the whole element text, angle brackets included + /// @param name the attribute name + /// @return {attributeStart, attributeEnd, valueStart, valueEnd}, or null + /// when the element does not carry it + private static int[] findAttribute(String element, String name) { + int at = element.indexOf(name); + while (at >= 0) { + // A name that is the tail of a longer one is a different + // attribute: android:maxSdkVersion must not be found inside + // tools:android:maxSdkVersion. + char before = at == 0 ? ' ' : element.charAt(at - 1); + if (before == ' ' || before == '\t' || before == '\n' + || before == '\r' || before == '<') { + int scan = at + name.length(); + scan = skipSpace(element, scan); + if (scan < element.length() && element.charAt(scan) == '=') { + scan = skipSpace(element, scan + 1); + if (scan < element.length()) { + char quote = element.charAt(scan); + if (quote == '"' || quote == '\'') { + int close = element.indexOf(quote, scan + 1); + if (close > 0) { + return new int[] {at, close + 1, scan + 1, + close}; + } + } + } + } + } + at = element.indexOf(name, at + 1); + } + return null; + } + + private static int skipSpace(String s, int at) { + while (at < s.length()) { + char c = s.charAt(at); + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { + return at; + } + at++; + } + return at; + } + /// True when a comma-separated profile list names this profile. /// /// Compared on whole entries so "watch" does not match a longer name diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java index 187a511e45c..42835ada486 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -395,4 +395,40 @@ void usingNoneOfItChangesNothing() { assertEquals(seeded, NearbyManifestFragments.inject(seeded, false, false, false, false, "", 34)); } + + /** + * The cap is recognised in every spelling XML allows. + * + *

{@code android:maxSdkVersion = "30"} and + * {@code android:maxSdkVersion='30'} are the same attribute as the one + * with no spaces and double quotes, and only the last was matched -- so + * a hand-written fragment using either of the others read as UNCAPPED, + * was left alone as already reaching far enough, and discovery on + * Android 12 asked for a location grant the manifest still capped at + * 30.

+ */ + @Test + public void aCapIsWidenedWhateverItsSpacingAndQuotes() { + String[] spellings = { + "\n", + "\n", + "\n", + }; + for (int i = 0; i < spellings.length; i++) { + String out = NearbyManifestFragments.inject(spellings[i], false, + true, false, false, "", 34); + int at = out.indexOf("ACCESS_FINE_LOCATION"); + String element = out.substring(out.lastIndexOf('<', at), + out.indexOf('>', at)); + assertTrue(element.contains("32"), + "the cap should reach 32 in spelling " + i + ": " + + element); + assertEquals(out.indexOf("ACCESS_FINE_LOCATION"), + out.lastIndexOf("ACCESS_FINE_LOCATION"), + "and must not be declared twice: " + out); + } + } } From d965ca04eaac45d26f6deb2f52960ab7955442c8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:18:44 +0300 Subject: [PATCH 78/94] Address the fifty-seventh nearby review round - An association is identified by the platform's id, not by its MAC address. AssociationInfo exists only from API 33 and every association there has an id of its own; the MAC does not, because one device can hold SEVERAL associations. Giving them all the address they shared made getAssociations hand back duplicate ids, left the newly created association indistinguishable from the ones already held, and had disassociate remove whichever of them it met first. It also makes the API 36 presence calls work at all. They take an association id and parse this string to get one, so an id that was a MAC threw every time and that whole path failed silently. Below 33 the address IS the id and that half is unchanged -- and the companion service derives it the same way, or a presence event would contradict the association the app already holds. - The tvOS plist carries the local-network keys. MultipeerConnectivity ships on tvOS and is deliberately linked for that slice, but tvOS 14 gates local-network discovery on the same two declarations iOS does, and the tvOS plist is generated separately from bundle metadata, capabilities and fonts alone. So the framework was there, the native transport was compiled in, and the target could neither advertise nor browse. Keyed off the Bonjour array, which is written only for a build that uses the transport. --- .../android/nearby/AndroidNearbyBackend.java | 18 +++++++++-- .../nearby/CN1CompanionDeviceService.java | 7 +++- .../codename1/builders/TvNativeBuilder.java | 30 +++++++++++++++++ .../builders/TvNativeBuilderNearbyTest.java | 32 +++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 2bf80bbc6c3..5a593bfc7f7 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -1375,9 +1375,23 @@ private static String macOf(AssociationInfo info) { return address == null ? null : address.toString(); } + /// The association's id: the platform's, not its MAC address. + /// + /// AssociationInfo exists only from API 33, and from there every + /// association has an id of its own. The MAC does not: one device can + /// hold SEVERAL associations, and giving them all the address they share + /// meant getAssociations handed back duplicate ids, the newly created + /// association could not be told from the ones already held, and + /// disassociate removed whichever of them it met first. + /// + /// It also makes the API 36 presence calls work at all. They take an + /// association id and parse this string to get one, so an id that was a + /// MAC address threw every time and the whole path failed silently. + /// + /// Below 33 the address IS the id -- there is no AssociationInfo to take + /// one from -- and that half is unchanged. private static String idOf(AssociationInfo info) { - String mac = macOf(info); - return mac != null ? mac : Integer.toString(info.getId()); + return Integer.toString(info.getId()); } private static String encode(AssociationInfo info, boolean present) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index bfdf1a53784..f39f62d5257 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -207,7 +207,12 @@ private void deliver(AssociationInfo info, boolean present) { } android.net.MacAddress address = info.getDeviceMacAddress(); String mac = address == null ? null : address.toString(); - String id = mac != null ? mac : Integer.toString(info.getId()); + // The platform's association id, which is what AndroidNearbyBackend + // encodes from API 33 up. One device can hold several associations + // and they all share its address, so the address cannot name one -- + // and an id that did not match the backend's would have a presence + // event contradicting the association the app already holds. + String id = Integer.toString(info.getId()); if (UNOBSERVED.contains(id)) { // The platform keeps watching until told otherwise, and it // outlives the process. An event for an association the app has diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java index 16dd92fdde8..de03d1c96e2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java @@ -245,6 +245,36 @@ public boolean accept(File dir, String name) { } sb.append(" \n"); } + // The local-network keys the nearby transport needs, copied from what + // the iOS slice resolved. + // + // MultipeerConnectivity ships on tvOS and is deliberately linked for + // this slice, but tvOS 14 gates local-network discovery on the same + // two declarations iOS does -- and this plist is generated + // separately, carrying only bundle metadata, capabilities and fonts. + // So the framework was there, the native transport was compiled in, + // and the target could neither advertise nor browse. + // + // Keyed off the Bonjour services because that array is written only + // for a build that uses the transport; an app that declares none is + // not one, and gets neither key. + java.util.List bonjour = WatchNativeBuilder + .injectedPlistStringArray(request, "NSBonjourServices"); + if (!bonjour.isEmpty()) { + String why = request.getArg("ios.NSLocalNetworkUsageDescription", + null); + if (why != null && why.trim().length() > 0) { + plistString(sb, "NSLocalNetworkUsageDescription", + IPhoneBuilder.plistEscape(why)); + } + sb.append(" NSBonjourServices\n \n"); + for (String service : bonjour) { + sb.append(" ") + .append(IPhoneBuilder.plistEscape(service)) + .append("\n"); + } + sb.append(" \n"); + } sb.append("\n\n"); File plist = new File(appSrcDir, request.getMainClass() + "-TV-Info.plist"); owner.createFile(plist, sb.toString().getBytes(StandardCharsets.UTF_8)); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java index 1efb2597c38..b44befb3c2a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java @@ -41,6 +41,17 @@ */ class TvNativeBuilderNearbyTest { + /// The builder's own source, for a rule that lives inside a method with + /// no seam to call -- the same way the scanner parity tests do it. + private static String source() throws Exception { + java.io.File f = new java.io.File( + "src/main/java/com/codename1/builders/TvNativeBuilder.java"); + assertTrue(f.exists(), "builder source must be readable: " + + f.getAbsolutePath()); + return new String(java.nio.file.Files.readAllBytes(f.toPath()), + java.nio.charset.StandardCharsets.UTF_8); + } + private static String optionalFrameworks() throws Exception { Field f = TvNativeBuilder.class .getDeclaredField("TV_OPTIONAL_FRAMEWORKS"); @@ -60,4 +71,25 @@ void theFrameworkTvosShipsIsNotWeakLinked() throws Exception { String list = optionalFrameworks(); assertFalse(list.contains("MultipeerConnectivity.framework"), list); } + + /** + * The tvOS plist carries the local-network keys the transport needs. + * + *

MultipeerConnectivity ships on tvOS and is deliberately linked for + * this slice, but tvOS 14 gates local-network discovery on the same two + * declarations iOS does -- and the tvOS plist is generated separately, + * carrying only bundle metadata, capabilities and fonts. So the + * framework was there, the native transport was compiled in, and the + * target could neither advertise nor browse.

+ */ + @Test + void theTvPlistCarriesTheLocalNetworkKeys() throws Exception { + String src = source(); + assertTrue(src.contains("NSBonjourServices"), + "the tvOS plist has to declare the Bonjour services the" + + " iOS slice resolved"); + assertTrue(src.contains("NSLocalNetworkUsageDescription"), + "and the usage description, without which tvOS 14 refuses" + + " the discovery outright"); + } } From faa4b114e29ebf5061d3cadd47795fb7240ec1cd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:25:53 +0300 Subject: [PATCH 79/94] Read the generated Bonjour hint, and refuse an unusable disclosure Two halves of the same thing: a tvOS slice that links MultipeerConnectivity and cannot discover anything with it. The tvOS plist read only ios.plistInject, which is where an app that declares NSBonjourServices ITSELF puts it. Every other build -- the ordinary generated one -- gets a comma-separated ios.NSBonjourServices instead, so the copy found nothing in the normal case and the plist was written without either local-network key. Both sources are read now. And the purpose string is validated rather than defaulted-and-hoped. The catalog supplies one only where the app set nothing, so "false" suppressed the key outright and a blank value rendered an empty string -- and iOS treats either as no disclosure, which means no peers and no explanation. Refused before the cloud slot is spent, the way the Matter flow refuses a build it knows cannot work. --- .../com/codename1/builders/IPhoneBuilder.java | 26 ++++++++++++++ .../codename1/builders/TvNativeBuilder.java | 35 +++++++++++++++++-- .../builders/TvNativeBuilderNearbyTest.java | 20 +++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) 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 432f51042e2..a9d85bb3da0 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 @@ -4691,6 +4691,32 @@ public void usesClassMethod(String cls, String method) { + " startAdvertising)")); mergeNearbyBonjourServices(request, serviceTypes, usesBonjour); + // The disclosure is MANDATORY, so an unusable one is + // refused rather than shipped. + // + // The catalog supplies a default, but only where the app + // set nothing: "false" suppresses the key outright and a + // blank value renders an empty string, and iOS treats + // either as no disclosure at all -- so MultipeerConnectivity + // finds no peers and says nothing about why. Refused here, + // before the cloud slot is spent, the way the Matter flow + // refuses a build it knows cannot work. + String localNetwork = request.getArg( + "ios.NSLocalNetworkUsageDescription", ""); + if (localNetwork == null + || localNetwork.trim().length() == 0 + || "false".equalsIgnoreCase(localNetwork.trim())) { + throw new RuntimeException( + "This app uses com.codename1.nearby.transport," + + " which iOS will not let discover or" + + " advertise without a local-network purpose" + + " string, and" + + " ios.NSLocalNetworkUsageDescription is set" + + " to '" + localNetwork + "'. Give it a" + + " sentence telling the user why the app" + + " looks for nearby devices, or remove the" + + " hint and let the build supply one."); + } } if (usesNearbyCompanion) { // Info.plist keys and NO entitlement, deliberately. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java index de03d1c96e2..66857552783 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java @@ -258,8 +258,7 @@ public boolean accept(File dir, String name) { // Keyed off the Bonjour services because that array is written only // for a build that uses the transport; an app that declares none is // not one, and gets neither key. - java.util.List bonjour = WatchNativeBuilder - .injectedPlistStringArray(request, "NSBonjourServices"); + java.util.List bonjour = tvBonjourServices(request); if (!bonjour.isEmpty()) { String why = request.getArg("ios.NSLocalNetworkUsageDescription", null); @@ -280,6 +279,38 @@ public boolean accept(File dir, String name) { owner.createFile(plist, sb.toString().getBytes(StandardCharsets.UTF_8)); } + /// The Bonjour service types the iOS slice ended up declaring. + /// + /// TWO sources, because the build writes to whichever the app left it. + /// An app that declares the array itself puts it in ios.plistInject and + /// the merge leaves it alone; every other build -- the ordinary + /// generated one -- gets a comma-separated hint in ios.NSBonjourServices + /// instead. Reading only the first found nothing in the normal case, so + /// the tvOS plist was written without either local-network key and the + /// slice still could not discover anything. + private static java.util.List tvBonjourServices( + BuildRequest request) { + java.util.List declared = WatchNativeBuilder + .injectedPlistStringArray(request, "NSBonjourServices"); + if (!declared.isEmpty()) { + return declared; + } + java.util.List out = new java.util.ArrayList(); + String hint = request.getArg("ios.NSBonjourServices", ""); + if (hint == null) { + return out; + } + // Both separators, because mergeNearbyBonjourServices splits on both + // when it reads the hint back. + for (String entry : hint.split("[,;]")) { + String service = entry.trim(); + if (service.length() > 0) { + out.add(service); + } + } + return out; + } + private static void plistString(StringBuilder sb, String key, String value) { sb.append(" ").append(key).append("\n ") .append(value == null ? "" : value).append("\n"); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java index b44befb3c2a..a8110e90208 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java @@ -92,4 +92,24 @@ void theTvPlistCarriesTheLocalNetworkKeys() throws Exception { "and the usage description, without which tvOS 14 refuses" + " the discovery outright"); } + + /** + * The generated hint is read, not only a hand-written plistInject. + * + *

The build writes to whichever source the app left it: an app that + * declares NSBonjourServices itself puts it in {@code ios.plistInject} + * and the merge leaves it alone, while every other build -- the + * ordinary generated one -- gets a comma-separated + * {@code ios.NSBonjourServices} instead. Reading only the first found + * nothing in the normal case, so the tvOS plist was written without + * either local-network key and the slice still could not discover + * anything.

+ */ + @Test + void theTvPlistReadsTheGeneratedBonjourHint() throws Exception { + String src = source(); + assertTrue(src.contains("ios.NSBonjourServices"), + "the tvOS plist has to read the hint the nearby merge" + + " writes, not only ios.plistInject"); + } } From a840eb45bf35e44eee7639192d5f76a124dcb242 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:26:56 +0300 Subject: [PATCH 80/94] Address the fifty-eighth nearby review round - An acknowledgement takes the OLDEST outstanding send of its payload. The frame names only a payload id, and taking the newest let a late ack for an early send consume the record of a send made moments ago: the early send's own timer then failed a payload that had arrived, and the later send's acknowledgement was dropped as unknown. Reliable sends are delivered in order, so the oldest record is the one an ack belongs to. The tokened path is unchanged -- a timeout still settles its own send and nothing else. - The backend seeds its context from the port, not from the activity alone. The bridge can be built while the port holds a SERVICE context and no activity -- which is exactly the case companion presence creates -- and deriving the application context only from the activity stored null for the life of the process: the optional backends were built with nothing, companion support reported itself unavailable, and the later activity change only rewires the chooser and never went back to repair it. --- .../impl/android/nearby/AndroidNearbyBackend.java | 15 ++++++++++++--- Ports/iOSPort/nativeSources/CN1Nearby.m | 9 ++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 5a593bfc7f7..fcfd07cad8f 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -130,9 +130,18 @@ private int pendingAssociate() { public AndroidNearbyBackend(Activity activity) { this.initialActivity = new WeakReference(activity); - Context app = activity == null ? null : activity - .getApplicationContext(); - this.appContext = app != null ? app : activity; + // Not from the activity alone. The bridge can be built while the + // port holds a SERVICE context and no activity at all -- which is + // exactly the case companion presence creates -- and deriving the + // application context only from the activity stored null there for + // the life of the process: the optional backends were constructed + // with nothing, companion support reported itself unavailable, and + // a later activity change only rewires the chooser and never went + // back to repair it. + Context seed = activity != null ? (Context) activity + : AndroidImplementation.getContext(); + Context app = seed == null ? null : seed.getApplicationContext(); + this.appContext = app != null ? app : seed; this.ranging = load("com.codename1.impl.android.nearby." + "AndroidUwbRanging"); this.transport = load("com.codename1.impl.android.nearby." diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index d3a08d70597..f480b4efe02 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1030,7 +1030,14 @@ - (BOOL)takeAck:(JAVA_INT)payloadId fromPeer:(NSString *)pid if (outstanding == nil || [outstanding count] == 0) { return NO; } - NSUInteger at = [outstanding count] - 1; + // The OLDEST outstanding send, for an acknowledgement that names + // only a payload. Taking the newest let a late ack for an early + // send consume the record of a send made moments ago, and the early + // send's own timer then failed a payload that had arrived while the + // later send's acknowledgement was dropped as unknown. Reliable + // sends are delivered in order, so the oldest record is the one an + // ack belongs to. + NSUInteger at = 0; if (token != 0) { at = NSNotFound; for (NSUInteger i = 0; i < [outstanding count]; i++) { From 8e1020c8a3f299a780e95d89337932afe1bf6383 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:45:40 +0300 Subject: [PATCH 81/94] Address the fifty-ninth nearby review round - The presence bookkeeping moves out of the companion service. That class extends android.companion.CompanionDeviceService, which does not exist before API 31, and AndroidNearbyBackend -- which every transport, ranging or companion build constructs, from API 21 up -- referenced it in its constructor. The class failed to resolve on anything older and the NoClassDefFoundError escaped into the reflective catch that builds the bridge, so the whole nearby stack reported itself unsupported on Android 21 to 30, where transport and ranging work perfectly well. It lives in NearbyPresenceStore now, which touches nothing newer than SharedPreferences. With the coupling gone the service is also deleted from a build that never observes presence, which it could not be before. - Android accessory ranging answers NOT_SUPPORTED, as RangingSession .startAccessory and the developer guide have both documented all along. The method exists for Apple's accessory handshake and Android has none: what an accessory publishes there is a vendor format naming a channel and a session, which the app parses into a RangingToken for start(). Reading those bytes AS a token was worse than refusing them -- real accessory data answered INVALID_TOKEN, and only an app passing a token it should have given to start() succeeded, so cross-platform code branching on the documented error never saw it. The capability bit goes with it: the port, the javadoc and the guide now say one thing. --- .../android/nearby/AndroidNearbyBackend.java | 10 +- .../android/nearby/AndroidUwbRanging.java | 79 ++---- .../nearby/CN1CompanionDeviceService.java | 189 +------------- .../android/nearby/NearbyPresenceStore.java | 233 ++++++++++++++++++ .../builders/AndroidGradleBuilder.java | 24 +- 5 files changed, 284 insertions(+), 251 deletions(-) create mode 100644 Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index fcfd07cad8f..62d66162bff 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -158,7 +158,7 @@ public AndroidNearbyBackend(Activity activity) { /// they come back, which is the first thing an app touches on its way to /// registering a presence listener. private void restorePresence() { - String[] rows = CN1CompanionDeviceService.takePersistedPresence( + String[] rows = NearbyPresenceStore.takePersistedPresence( appContext); for (int i = 0; i < rows.length; i++) { int tab = rows[i].indexOf('\t'); @@ -1007,7 +1007,7 @@ public boolean startObservingPresence(String associationId) { if (macOf(cdm, associationId) == null) { if (Build.VERSION.SDK_INT >= 36 && observeByAssociationId(cdm, associationId)) { - CN1CompanionDeviceService.register(associationId); + NearbyPresenceStore.register(associationId); return true; } Log.w("CN1", "com.codename1.nearby.companion: this Android" @@ -1018,7 +1018,7 @@ && observeByAssociationId(cdm, associationId)) { return false; } cdm.startObservingDevicePresence(addressOf(cdm, associationId)); - CN1CompanionDeviceService.register(associationId); + NearbyPresenceStore.register(associationId); return true; } catch (Throwable t) { return false; @@ -1112,11 +1112,11 @@ public void stopObservingPresence(String associationId) { if (Build.VERSION.SDK_INT >= 36) { stopObservingByAssociationId(cdm, associationId); } - CN1CompanionDeviceService.unregister(associationId); + NearbyPresenceStore.unregister(associationId); return; } cdm.stopObservingDevicePresence(addressOf(cdm, associationId)); - CN1CompanionDeviceService.unregister(associationId); + NearbyPresenceStore.unregister(associationId); } catch (Throwable t) { // Nothing to report: the caller asked to stop and it is stopped // either way. diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index 52518f8b2c1..326156587e6 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -239,10 +239,10 @@ public int getRangingCapabilities() { if (probed > 0) { bits |= probed; } - // An accessory is ranged here by joining the session it names, which - // is the same code path as a peer -- so if ranging works at all, - // accessory ranging does. - bits |= NearbyBridge.CAPABILITY_ACCESSORY; + // No CAPABILITY_ACCESSORY. startAccessoryRanging answers + // NOT_SUPPORTED here, as the public API documents, so claiming the + // capability would have an app take a path that cannot work -- and + // the capability, the javadoc and the port have to say one thing. return bits; } @@ -365,13 +365,6 @@ public void startRanging(final int requestId, final int sessionHandle, try { peer = Peer.decode(peerToken); } catch (IllegalArgumentException e) { - // The marker goes with the failure. It is set before this point - // by startAccessoryRanging, and a token that will not decode - // never reaches the subscription that would clear it -- so an - // ordinary start() retried on the same still-open session was - // mistaken for an accessory start and answered into - // PENDING_ACCESSORY, leaving the real resource unsettled. - session.accessoryStart = false; fail(requestId, NearbyError.INVALID_TOKEN, e.getMessage()); return; } @@ -380,25 +373,26 @@ public void startRanging(final int requestId, final int sessionHandle, public void startAccessoryRanging(int requestId, int sessionHandle, byte[] accessoryData) { - // An accessory on Android is not a protocol, it is a set of session - // parameters the accessory published out of band -- which is exactly - // what a token is. So the radio work is the same as an ordinary - // start, and the public API documents building the token with - // RangingToken.forUwbAddress. + // NOT_SUPPORTED, which is what RangingSession.startAccessory and the + // developer guide both document Android answering. // - // The ANSWER is not the same, though, and delegating to startRanging - // sent it to the wrong place: startAccessory registers its resource - // in PENDING_ACCESSORY and deliverSessionStarted only looks in - // PENDING_SESSIONS, so the radio started and the caller waited for an - // answer that had already been given to nobody. Marked so the start - // is settled through deliverAccessoryConfiguration instead -- with no - // bytes, which is what the SPI documents for a platform that needs no - // handshake back. - Session session = sessions.get(Integer.valueOf(sessionHandle)); - if (session != null) { - session.accessoryStart = true; - } - startRanging(requestId, sessionHandle, accessoryData); + // The method exists for Apple's Nearby Interaction Accessory + // Protocol: the accessory publishes a blob, the phone answers with + // one, and ranging starts. Android has no such handshake. What an + // accessory publishes there is a vendor format naming a channel and + // a session, so the app parses it and builds a RangingToken -- + // which is what start() takes. + // + // Reading those bytes AS a token was worse than refusing them. + // Real accessory data is not a Codename One token, so it answered + // INVALID_TOKEN for the ordinary case and succeeded only for an app + // that passed a token it should have given to start() anyway -- and + // cross-platform code that branches on the documented NOT_SUPPORTED + // to pick the Android path never saw it. + fail(requestId, NearbyError.NOT_SUPPORTED, + "Android has no accessory handshake: build a token with" + + " RangingToken.forUwbAddress from what the accessory" + + " published and call start instead"); } private void run(final int requestId, final Session session, @@ -462,13 +456,6 @@ public void accept(Throwable error) { // session isClosed() still reported as open. // The scope is still valid; only this // subscription failed. - // - // deliverRequestFailed looks in both pending - // maps, so an accessory start is answered - // here too -- but the flag is cleared so a - // retry through the ordinary start does not - // inherit it. - session.accessoryStart = false; fail(pending, NearbyError.SESSION_FAILED, message(error)); return; @@ -497,7 +484,6 @@ public void accept(Throwable error) { } if (late) { started.dispose(); - session.accessoryStart = false; session.startRequest.set(0); fail(requestId, NearbyError.SESSION_INVALIDATED, "the session was stopped before ranging started"); @@ -505,9 +491,6 @@ public void accept(Throwable error) { } scheduleStartGrace(session); } catch (Throwable t) { - // Cleared for the reason the token failure above clears it: this - // start never reached the subscription. - session.accessoryStart = false; session.startRequest.set(0); fail(requestId, NearbyError.SESSION_FAILED, message(t)); } @@ -519,15 +502,10 @@ private static void settleStarted(Session session) { if (pending == 0) { return; } - if (session.accessoryStart) { - session.accessoryStart = false; - // No bytes: Android's accessory ranging is joining a session the - // accessory already published, so there is nothing to hand back - // to it. The SPI documents an empty array for exactly this. - Ranging.deliverAccessoryConfiguration(pending, session.handle, - new byte[0]); - return; - } + // Always a session start. There is no accessory start to tell it + // apart from any more: startAccessoryRanging answers NOT_SUPPORTED + // without touching the radio, so nothing here can be waiting in + // PENDING_ACCESSORY. Ranging.deliverSessionStarted(pending, session.handle); } @@ -711,9 +689,6 @@ private static final class Session { /// measurement, the first error, or the grace timer gets there. private final java.util.concurrent.atomic.AtomicInteger startRequest = new java.util.concurrent.atomic.AtomicInteger(); - /// Whether the pending start came from startAccessory, whose caller - /// waits on a different pending map. - private boolean accessoryStart; private Session(int handle, boolean controller) { this.handle = handle; diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index f39f62d5257..933343cd617 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -25,20 +25,11 @@ import android.annotation.SuppressLint; import android.companion.AssociationInfo; import android.companion.CompanionDeviceService; -import android.content.Context; -import android.content.SharedPreferences; import android.os.Build; -import android.util.Log; import com.codename1.impl.android.AndroidImplementation; -import com.codename1.nearby.companion.CompanionDevices; import com.codename1.ui.Display; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; /// The service the platform wakes when an associated device comes into or /// goes out of range. @@ -85,48 +76,6 @@ public class CN1CompanionDeviceService extends CompanionDeviceService { /// Whether this service is the one that started the Codename One context. private boolean startedContext; - /// Associations the app has explicitly STOPPED watching in this process. - /// - /// The filter keys off what was UNregistered, not off what was - /// registered. Observation survives process death -- the platform keeps - /// watching and keeps binding this service -- while a set of registered - /// ids starts empty and fills one registration at a time, so treating it - /// as the authoritative list made the first re-registration turn into a - /// whitelist: an appearance for a second still-watched association was - /// dropped until the app happened to re-register that one too, which it - /// may never do. - /// - /// Not knowing yet is not the same as not wanting it. Only an explicit - /// unregister says the app is done, and that is what this records. There - /// is deliberately no matching set of registered ids: nothing would read - /// it, and one that looked authoritative without being it is what caused - /// the defect. - private static final Set UNOBSERVED = - Collections.synchronizedSet(new HashSet()); - - /// Records that the app asked to watch an association, so an event for - /// one it stopped watching is dropped rather than delivered. - /// - /// #### Parameters - /// - /// - `associationId`: the association being watched - public static void register(String associationId) { - if (associationId != null) { - UNOBSERVED.remove(associationId); - } - } - - /// Forgets an association. - /// - /// #### Parameters - /// - /// - `associationId`: the association no longer watched - public static void unregister(String associationId) { - if (associationId != null) { - UNOBSERVED.add(associationId); - } - } - @Override public void onCreate() { super.onCreate(); @@ -193,12 +142,12 @@ private void deliverByAddress(String address, boolean present) { if (address == null) { return; } - if (UNOBSERVED.contains(address)) { + if (NearbyPresenceStore.isUnobserved(address)) { return; } String encoded = sanitize(address) + '\t' + sanitize(address) + '\t' + sanitize(address) + "\t0\t" + (present ? '1' : '0'); - record(this, encoded, present); + NearbyPresenceStore.record(this, encoded, present); } private void deliver(AssociationInfo info, boolean present) { @@ -213,7 +162,7 @@ private void deliver(AssociationInfo info, boolean present) { // and an id that did not match the backend's would have a presence // event contradicting the association the app already holds. String id = Integer.toString(info.getId()); - if (UNOBSERVED.contains(id)) { + if (NearbyPresenceStore.isUnobserved(id)) { // The platform keeps watching until told otherwise, and it // outlives the process. An event for an association the app has // since stopped watching is not the app's business -- but one it @@ -230,137 +179,7 @@ private void deliver(AssociationInfo info, boolean present) { + sanitize(mac == null ? "" : mac) + '\t' + AndroidNearbyBackend.profileOrdinalOf(info) + '\t' + (present ? '1' : '0'); - record(this, encoded, present); - } - - // ------------------------------------------------------------------ - // The backlog that outlives the process - // ------------------------------------------------------------------ - - /// Where a presence event waits for an app that is not running. - /// - /// The platform starts this service for the event and does NOT start the - /// application, which is the whole premise of the feature -- so the - /// event goes into CompanionDevices' in-memory backlog, and if Android - /// reclaims this idle process before the user opens the app, that - /// backlog dies with it. The platform does not replay, so the listener - /// documented to hear about the sighting "when the app next initializes" - /// heard nothing at all. A record of what happened while the app was - /// away has to survive the app not being there. - private static final String PRESENCE_PREFS = "cn1-nearby-presence"; - private static final String PRESENCE_KEY = "backlog"; - /// The same bound CompanionDevices keeps, for the same reason: a device - /// that flaps for a week must not grow this without limit. - private static final int MAX_PERSISTED = 64; - - /// Sequence numbers this process has already handed to CompanionDevices. - /// - /// Its lifetime is exactly the in-memory backlog's, which is what makes - /// the two agree: an event this process delivered is already in that - /// backlog, so the restore must skip it, and if the process died neither - /// this set nor that backlog exists and every persisted event replays. - private static final Set DELIVERED_HERE = - Collections.synchronizedSet(new HashSet()); - - private static long presenceSequence; - - /// Persists an event and hands it to the in-memory backlog. - private static void record(Context ctx, String encoded, boolean present) { - String seq; - synchronized (CN1CompanionDeviceService.class) { - // Qualified by pid, so a sequence minted by an earlier process - // cannot be mistaken for one this process delivered. A recycled - // pid is harmless: the set that would have to match it is empty - // in a process that has delivered nothing. - seq = android.os.Process.myPid() + "-" - + Long.toString(++presenceSequence); - } - persist(ctx, seq + '\t' + (present ? '1' : '0') + '\t' + encoded); - DELIVERED_HERE.add(seq); - CompanionDevices.deliverPresenceChanged(encoded, present); - } - - private static void persist(Context ctx, String row) { - if (ctx == null) { - return; - } - try { - SharedPreferences prefs = ctx.getSharedPreferences( - PRESENCE_PREFS, Context.MODE_PRIVATE); - String existing = prefs.getString(PRESENCE_KEY, ""); - List rows = new ArrayList(); - if (existing.length() > 0) { - for (String r : existing.split("\n")) { - if (r.length() > 0) { - rows.add(r); - } - } - } - rows.add(row); - while (rows.size() > MAX_PERSISTED) { - rows.remove(0); - } - StringBuilder out = new StringBuilder(); - for (int i = 0; i < rows.size(); i++) { - if (i > 0) { - out.append('\n'); - } - out.append(rows.get(i)); - } - prefs.edit().putString(PRESENCE_KEY, out.toString()).commit(); - } catch (Throwable unavailable) { - // Nothing can be done about a store that will not take it, and - // failing the event outright would lose what the in-memory - // backlog can still carry for a process that lives long enough. - Log.w("CN1Nearby", "presence backlog not persisted", unavailable); - } - } - - /// Hands back every persisted event this process has not already - /// delivered, and clears the store. - /// - /// Called when the nearby backend is built, which is what an app does on - /// its way to registering a presence listener. - /// - /// #### Parameters - /// - /// - `ctx`: any context; the store is per-application - /// - /// #### Returns - /// - /// rows of `present-flag TAB encoded`, oldest first, never null - static String[] takePersistedPresence(Context ctx) { - if (ctx == null) { - return new String[0]; - } - String existing; - try { - SharedPreferences prefs = ctx.getSharedPreferences( - PRESENCE_PREFS, Context.MODE_PRIVATE); - existing = prefs.getString(PRESENCE_KEY, ""); - prefs.edit().remove(PRESENCE_KEY).commit(); - } catch (Throwable unavailable) { - return new String[0]; - } - if (existing.length() == 0) { - return new String[0]; - } - List out = new ArrayList(); - for (String row : existing.split("\n")) { - int tab = row.indexOf('\t'); - if (tab <= 0) { - continue; - } - String seq = row.substring(0, tab); - if (DELIVERED_HERE.remove(seq)) { - // This process already gave it to CompanionDevices, so it is - // in the in-memory backlog and replaying it would deliver the - // same sighting twice. - continue; - } - out.add(row.substring(tab + 1)); - } - return out.toArray(new String[out.size()]); + NearbyPresenceStore.record(this, encoded, present); } private static String sanitize(String s) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java new file mode 100644 index 00000000000..63e937dd8ad --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java @@ -0,0 +1,233 @@ +/* + * 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.impl.android.nearby; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import com.codename1.nearby.companion.CompanionDevices; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// Where presence events wait for an app that is not running. +/// +/// Split out of `CN1CompanionDeviceService` because that class extends +/// `android.companion.CompanionDeviceService`, which does not exist before +/// API 31. Touching it from `AndroidNearbyBackend` -- which every transport, +/// ranging or companion build constructs, from API 21 up -- made the class +/// fail to resolve on anything older, and the NoClassDefFoundError escaped +/// the constructor into the reflective catch that builds the bridge. The +/// whole nearby stack then reported itself unsupported on Android 21 to 30, +/// where the transport and ranging halves work perfectly well. +/// +/// Nothing here touches an API newer than SharedPreferences. +/// +/// @hidden not part of the public API +class NearbyPresenceStore { + + private NearbyPresenceStore() { + } + + /// Associations the app has explicitly STOPPED watching in this process. + /// + /// The filter keys off what was UNregistered, not off what was + /// registered. Observation survives process death -- the platform keeps + /// watching and keeps binding this service -- while a set of registered + /// ids starts empty and fills one registration at a time, so treating it + /// as the authoritative list made the first re-registration turn into a + /// whitelist: an appearance for a second still-watched association was + /// dropped until the app happened to re-register that one too, which it + /// may never do. + /// + /// Not knowing yet is not the same as not wanting it. Only an explicit + /// unregister says the app is done, and that is what this records. There + /// is deliberately no matching set of registered ids: nothing would read + /// it, and one that looked authoritative without being it is what caused + /// the defect. + private static final Set UNOBSERVED = + Collections.synchronizedSet(new HashSet()); + + /// Records that the app asked to watch an association, so an event for + /// one it stopped watching is dropped rather than delivered. + /// + /// #### Parameters + /// + /// - `associationId`: the association being watched + public static void register(String associationId) { + if (associationId != null) { + UNOBSERVED.remove(associationId); + } + } + + /// Forgets an association. + /// + /// #### Parameters + /// + /// - `associationId`: the association no longer watched + public static void unregister(String associationId) { + if (associationId != null) { + UNOBSERVED.add(associationId); + } + } + + + // ------------------------------------------------------------------ + // The backlog that outlives the process + // ------------------------------------------------------------------ + + /// Where a presence event waits for an app that is not running. + /// + /// The platform starts this service for the event and does NOT start the + /// application, which is the whole premise of the feature -- so the + /// event goes into CompanionDevices' in-memory backlog, and if Android + /// reclaims this idle process before the user opens the app, that + /// backlog dies with it. The platform does not replay, so the listener + /// documented to hear about the sighting "when the app next initializes" + /// heard nothing at all. A record of what happened while the app was + /// away has to survive the app not being there. + private static final String PRESENCE_PREFS = "cn1-nearby-presence"; + private static final String PRESENCE_KEY = "backlog"; + /// The same bound CompanionDevices keeps, for the same reason: a device + /// that flaps for a week must not grow this without limit. + private static final int MAX_PERSISTED = 64; + + /// Sequence numbers this process has already handed to CompanionDevices. + /// + /// Its lifetime is exactly the in-memory backlog's, which is what makes + /// the two agree: an event this process delivered is already in that + /// backlog, so the restore must skip it, and if the process died neither + /// this set nor that backlog exists and every persisted event replays. + private static final Set DELIVERED_HERE = + Collections.synchronizedSet(new HashSet()); + + private static long presenceSequence; + + /// Persists an event and hands it to the in-memory backlog. + /// Whether the app has explicitly stopped watching this association. + static boolean isUnobserved(String associationId) { + return UNOBSERVED.contains(associationId); + } + + static void record(Context ctx, String encoded, boolean present) { + String seq; + synchronized (NearbyPresenceStore.class) { + // Qualified by pid, so a sequence minted by an earlier process + // cannot be mistaken for one this process delivered. A recycled + // pid is harmless: the set that would have to match it is empty + // in a process that has delivered nothing. + seq = android.os.Process.myPid() + "-" + + Long.toString(++presenceSequence); + } + persist(ctx, seq + '\t' + (present ? '1' : '0') + '\t' + encoded); + DELIVERED_HERE.add(seq); + CompanionDevices.deliverPresenceChanged(encoded, present); + } + + private static void persist(Context ctx, String row) { + if (ctx == null) { + return; + } + try { + SharedPreferences prefs = ctx.getSharedPreferences( + PRESENCE_PREFS, Context.MODE_PRIVATE); + String existing = prefs.getString(PRESENCE_KEY, ""); + List rows = new ArrayList(); + if (existing.length() > 0) { + for (String r : existing.split("\n")) { + if (r.length() > 0) { + rows.add(r); + } + } + } + rows.add(row); + while (rows.size() > MAX_PERSISTED) { + rows.remove(0); + } + StringBuilder out = new StringBuilder(); + for (int i = 0; i < rows.size(); i++) { + if (i > 0) { + out.append('\n'); + } + out.append(rows.get(i)); + } + prefs.edit().putString(PRESENCE_KEY, out.toString()).commit(); + } catch (Throwable unavailable) { + // Nothing can be done about a store that will not take it, and + // failing the event outright would lose what the in-memory + // backlog can still carry for a process that lives long enough. + Log.w("CN1Nearby", "presence backlog not persisted", unavailable); + } + } + + /// Hands back every persisted event this process has not already + /// delivered, and clears the store. + /// + /// Called when the nearby backend is built, which is what an app does on + /// its way to registering a presence listener. + /// + /// #### Parameters + /// + /// - `ctx`: any context; the store is per-application + /// + /// #### Returns + /// + /// rows of `present-flag TAB encoded`, oldest first, never null + static String[] takePersistedPresence(Context ctx) { + if (ctx == null) { + return new String[0]; + } + String existing; + try { + SharedPreferences prefs = ctx.getSharedPreferences( + PRESENCE_PREFS, Context.MODE_PRIVATE); + existing = prefs.getString(PRESENCE_KEY, ""); + prefs.edit().remove(PRESENCE_KEY).commit(); + } catch (Throwable unavailable) { + return new String[0]; + } + if (existing.length() == 0) { + return new String[0]; + } + List out = new ArrayList(); + for (String row : existing.split("\n")) { + int tab = row.indexOf('\t'); + if (tab <= 0) { + continue; + } + String seq = row.substring(0, tab); + if (DELIVERED_HERE.remove(seq)) { + // This process already gave it to CompanionDevices, so it is + // in the in-memory backlog and replaying it would deliver the + // same sighting twice. + continue; + } + out.add(row.substring(tab + 1)); + } + return out.toArray(new String[out.size()]); + } +} 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 e45795ac5ea..8341230653b 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 @@ -3935,15 +3935,21 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { if (!usesNearbyTransport) { new File(nearbyPackage, "AndroidNearbyTransport.java").delete(); } - // CN1CompanionDeviceService is deliberately NOT deleted for an - // app that skips presence observation. AndroidNearbyBackend calls - // its register/unregister unconditionally, the whole nearby - // package is excluded from the port jar, and no other definition - // exists -- so removing it made javac fail in the generated app - // for every ranging-only or transport-only build. It is a - // framework-only class that compiles against any modern SDK and - // costs an unused class in the dex; the manifest still names it - // only when presence is used, so nothing binds it otherwise. + if (!usesNearbyPresence) { + // Deletable now, and worth deleting: this is the one class + // in the package whose SUPERCLASS needs API 31, and an app + // that never observes presence has no use for it. + // + // It used to be kept because AndroidNearbyBackend called its + // register/unregister unconditionally, so removing it broke + // javac for every ranging-only or transport-only build. That + // coupling is gone -- the bookkeeping lives in + // NearbyPresenceStore, which touches nothing newer than + // SharedPreferences -- and the manifest names the service + // only when presence is used, so nothing binds it either. + new File(nearbyPackage, + "CN1CompanionDeviceService.java").delete(); + } } if (!arSupport) { From 9b3a0db8784e0c56f3d702cddfefd6f7b122b185 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:48:15 +0300 Subject: [PATCH 82/94] Validate the local-network string the plist will actually carry ios.plistInject wins over the hint in the renderer, so a fragment declaring NSLocalNetworkUsageDescription as or as a blank string left the catalog's perfectly good default sitting in the hint, unread -- and the validator meant to catch exactly that read the hint and passed it. effectivePurposeString already resolves the value that wins, and is what this asks now. --- .../java/com/codename1/builders/IPhoneBuilder.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 a9d85bb3da0..46709fedf6f 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 @@ -4701,8 +4701,15 @@ public void usesClassMethod(String cls, String method) { // finds no peers and says nothing about why. Refused here, // before the cloud slot is spent, the way the Matter flow // refuses a build it knows cannot work. - String localNetwork = request.getArg( - "ios.NSLocalNetworkUsageDescription", ""); + // The EFFECTIVE value, not the hint. ios.plistInject + // wins over the hint in the renderer, so a fragment + // declaring this key as or as a blank string + // left the catalog's perfectly good default sitting in + // the hint, unread -- and the validator that was meant + // to catch exactly that passed it. effectivePurposeString + // resolves what the plist will actually carry. + String localNetwork = effectivePurposeString(request, + "ios.NSLocalNetworkUsageDescription"); if (localNetwork == null || localNetwork.trim().length() == 0 || "false".equalsIgnoreCase(localNetwork.trim())) { From 4c91424e61a792408ea84fe542f88b0eb8e93942 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:01:47 +0300 Subject: [PATCH 83/94] Address the sixtieth nearby review round - A presence event is persisted only when nobody can receive it. A live app with a listener registered sees the event and is done with it; persisting it as well meant the NEXT launch replayed a sighting the app had already acted on, as though it had happened while the app was away. The durable copy exists for the one case the in-memory backlog cannot survive -- an event delivered into a process with no listener yet, which may be reclaimed before it gets one -- and CompanionDevices now answers which case this is. - A nearby define that was never enabled fails the build. replaceInFile is a String.replace, so a marker that is not there is a silent no-op: a port override or an older staged header let the build finish with the native compiled out, and the app shipped reporting the feature unsupported for usage the scanner had plainly found, with nothing anywhere saying why. An already-enabled define is treated as the success it is. - The tvOS plist reads the EFFECTIVE purpose string, the same one IPhoneBuilder validates. ios.plistInject wins over the hint, so an app that declared a perfectly good disclosure there left the hint blank -- and the tvOS plist, reading the hint, omitted the key entirely. --- .../nearby/companion/CompanionDevices.java | 19 ++++++++++++++ .../android/nearby/NearbyPresenceStore.java | 14 +++++++++- .../com/codename1/builders/IPhoneBuilder.java | 26 ++++++++++++++++--- .../codename1/builders/TvNativeBuilder.java | 8 ++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index b13b61ef7e4..7359a615bfd 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -267,6 +267,25 @@ public static void stopObservingPresence(String associationId) { } } + /// Whether any listener is registered to receive presence right now. + /// + /// For a PORT deciding whether an event needs to outlive the process. + /// One that can be delivered now does not: it goes to the listeners and + /// is done with. One that arrives with nobody listening is parked here, + /// and that in-memory backlog dies with the process -- which is the + /// case, and the only case, a durable copy is for. + /// + /// @hidden not part of the public API; for ports. + /// + /// #### Returns + /// + /// true when a presence listener is registered + public static boolean hasPresenceListener() { + synchronized (LISTENERS) { + return !LISTENERS.isEmpty(); + } + } + /// Registers a presence listener. Callbacks arrive on the EDT. /// /// Register from the app's `init()`: presence is exactly the event that diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java index 63e937dd8ad..6cbb24689fb 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java @@ -127,13 +127,25 @@ public static void unregister(String associationId) { private static long presenceSequence; - /// Persists an event and hands it to the in-memory backlog. + /// Hands an event to the backlog, persisting it only if nobody is + /// listening. + /// + /// A live app with a listener registered sees the event now and is done + /// with it -- persisting it as well meant the NEXT launch replayed a + /// sighting the app had already acted on, as though it had happened + /// while the app was away. The durable copy exists for the one case the + /// in-memory backlog cannot survive: an event delivered into a process + /// that has no listener yet and may be reclaimed before it gets one. /// Whether the app has explicitly stopped watching this association. static boolean isUnobserved(String associationId) { return UNOBSERVED.contains(associationId); } static void record(Context ctx, String encoded, boolean present) { + if (CompanionDevices.hasPresenceListener()) { + CompanionDevices.deliverPresenceChanged(encoded, present); + return; + } String seq; synchronized (NearbyPresenceStore.class) { // Qualified by pid, so a sequence minted by an earlier process 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 46709fedf6f..3697c70371b 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 @@ -226,10 +226,30 @@ private int reservedApplicationQueriesSchemes(BuildRequest request) { /// - `name`: the define to enable private void enableNearbyDefine(File buildinRes, String name) throws BuildException { + File header = new File(buildinRes, "CodenameOne_GLViewController.h"); try { - replaceInFile(new File(buildinRes, - "CodenameOne_GLViewController.h"), - "//#define " + name, "#define " + name); + // Checked, because replaceInFile is a String.replace and a + // marker that is not there is a silent no-op. A port override or + // an older staged header without this line let the build finish + // with the native compiled out -- so the app shipped, the API + // reported the feature unsupported, and nothing anywhere said + // why. The whole point of enabling the define is that the + // scanner found the usage. + String before = readFileToString(header); + if (before.indexOf("//#define " + name) < 0) { + if (before.indexOf("#define " + name) >= 0) { + // Already enabled, which is the same outcome. + return; + } + throw new BuildException("This app uses" + + " com.codename1.nearby, which needs " + name + + " enabled in CodenameOne_GLViewController.h, and" + + " the staged header does not carry that marker." + + " The iOS port in use is older than the nearby" + + " support or has been overridden; build against a" + + " port that has it."); + } + replaceInFile(header, "//#define " + name, "#define " + name); } catch (IOException ex) { throw new BuildException("Failed to enable " + name, ex); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java index 66857552783..7c965f36aa3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java @@ -260,8 +260,12 @@ public boolean accept(File dir, String name) { // not one, and gets neither key. java.util.List bonjour = tvBonjourServices(request); if (!bonjour.isEmpty()) { - String why = request.getArg("ios.NSLocalNetworkUsageDescription", - null); + // The EFFECTIVE value, the same one IPhoneBuilder validates: + // ios.plistInject wins over the hint, so an app that declared a + // perfectly good disclosure there left the hint blank and the + // tvOS plist -- reading the hint -- omitted the key entirely. + String why = IPhoneBuilder.effectivePurposeString(request, + "ios.NSLocalNetworkUsageDescription"); if (why != null && why.trim().length() > 0) { plistString(sb, "NSLocalNetworkUsageDescription", IPhoneBuilder.plistEscape(why)); From f058ef88f3f015434417956050b1d0066c5d2c62 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:05:51 +0300 Subject: [PATCH 84/94] Note why the companion profiles do not raise the compile SDK The glasses profile is an API 34 constant, and the nearby floor is 33 -- which reads like a build that cannot compile. It can: AndroidNearbyBackend never names AssociationRequest.DEVICE_PROFILE_GLASSES. It writes the role name that constant inlines to, behind a runtime SDK_INT check, exactly so this floor does not have to move for a hint that costs the app nothing at compile time. Raising it to 34 would raise it for every companion build. Written down because the question comes up at the floor, not at the literal, and a comment in the port is not where anyone reads it. --- .../java/com/codename1/builders/AndroidGradleBuilder.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 8341230653b..4886784a3a5 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 @@ -6737,6 +6737,14 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { // manifest attribute the transport's permissions carry whatever // the app targets; AAPT rejects an attribute the compile SDK has // never heard of, which failed the build even earlier. + // + // 33 is enough for every companion PROFILE too, including + // glasses, which is an API 34 constant. AndroidNearbyBackend + // never names AssociationRequest.DEVICE_PROFILE_GLASSES: it + // writes the role name that constant inlines to, guarded by a + // runtime SDK_INT check, exactly so this floor does not have to + // move for a hint that costs the app nothing at compile time. + // Raising it to 34 would raise it for every companion build. compileSdkVersion = ensureCompileSdkAtLeastTarget( compileSdkVersion, "33"); } From d3b1a2726d38c8ae38fc319e46f7c19b7a387bc7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:13:40 +0300 Subject: [PATCH 85/94] Address the sixty-first nearby review round - An injected AccessorySetupKit array has to carry what nearby needs. The key being present was taken as the app having handled it, so an empty array, a malformed one, or one simply missing Bluetooth went through: the build succeeded, the generated entry was skipped as redundant, and AccessorySetupKit never offered its picker on the device. Every value the build would have written must now be in the array the app declared, and the failure names the missing ones -- which is what the Bonjour merge already does with NSBonjourServices. - A failed file send cancels only its OWN transfers. progressByPayload is keyed by the portable payload id, which two overlapping sends of the same immutable Payload share, so cancelling by that key reached into a separately accepted send and cancelled its transfers too -- a send that had started perfectly well ended as CANCELED because an unrelated one could not reach every recipient. Cancelling the whole payload is still what cancel() does, which is the one place it is right. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 13 ++++++-- .../com/codename1/builders/IPhoneBuilder.java | 31 +++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index f480b4efe02..f958a242d5b 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -3027,6 +3027,12 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i JAVA_LONG fileBytes = fileSize == nil ? -1 : (JAVA_LONG)[fileSize longLongValue]; NSUInteger started = 0; + // THIS invocation's transfers. progressByPayload is keyed by the + // portable payload id, which two overlapping sends of the same + // immutable Payload share -- so cancelling by that key alone + // reached into a separately accepted send and cancelled its + // transfers too. + NSMutableArray *mine = [NSMutableArray array]; for (NSUInteger i = 0; i < [peers count]; i++) { MCPeerID *peer = [peers objectAtIndex:i]; MCSession *session = [cn1nbTransport @@ -3085,6 +3091,7 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i if (progress != nil) { started++; [progressHolder addObject:progress]; + [mine addObject:progress]; [cn1nbTransport rememberProgress:progress forPayload:payloadId]; } else { @@ -3109,8 +3116,10 @@ void com_codename1_impl_ios_IOSNative_nearbySendPayload___int_java_lang_String_i // app has been told failed must not go on to deliver the // file to some of its recipients. Bytes cannot be recalled // once queued; a file can. - for (NSProgress *partial in - [cn1nbTransport takeProgressesForPayload:payloadId]) { + for (NSProgress *partial in mine) { + [cn1nbTransport forgetProgress: + [NSArray arrayWithObject:partial] + forPayload:payloadId]; [partial cancel]; } cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, 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 3697c70371b..901e80e4922 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 @@ -563,11 +563,36 @@ private static String escapeNearbyPlistText(String value) { /// - `values`: the array entries /// - `why`: what the app loses if the entries are absent, for the log private void declareNearbyPlistArray(BuildRequest request, String key, - String[] values, String why) { + String[] values, String why) throws BuildException { String inject = request.getArg("ios.plistInject", ""); if (WatchNativeBuilder.injectedPlistKeys(inject).contains(key)) { - log("ios.plistInject already declares " + key + ", so the nearby" - + " entries were not added for you -- " + why + "."); + // Declared by the app, so the build leaves it alone -- but it + // has to actually CARRY what the feature needs. Accepting the + // key on sight let an empty array, a malformed one, or one + // simply missing the value through: the build succeeded, the + // generated entry was skipped as redundant, and the feature was + // inert on the device. The Bonjour merge checks its array for + // the same reason. + java.util.List declared = WatchNativeBuilder + .injectedPlistStringArray(request, key); + java.util.List missing = new java.util.ArrayList(); + for (int i = 0; i < values.length; i++) { + String want = values[i] == null ? "" : values[i].trim(); + if (want.length() > 0 && !declared.contains(want)) { + missing.add(want); + } + } + if (!missing.isEmpty()) { + throw new BuildException("This app uses" + + " com.codename1.nearby.companion and declares " + + key + " through ios.plistInject, but that array" + + " does not list " + missing + ". " + why + + ". Add those entries to the array in" + + " ios.plistInject, or remove the key from it and" + + " let the build declare it for you."); + } + log("ios.plistInject already declares " + key + " and it carries" + + " what nearby needs, so no entries were added for you."); return; } StringBuilder b = new StringBuilder(inject); From a1df8c6c2b07809e8a12a5f5ceed3d699b6148a3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:23:06 +0300 Subject: [PATCH 86/94] Address the sixty-second nearby review round - An incoming connection owns the service it was negotiated through. Nearby says which side started it, and treating any existing mapping as authoritative labelled a peer that answered THIS advertisement with the service discovery had seen it under -- so the connection, its lifecycle events and every payload on it were reported against the wrong service, which in an app running two of them routes the peer to the wrong protocol. An outgoing connection still keeps what discovery recorded, because that path carries the discoveryServiceId field, which may have moved on since the endpoint was found. - Receiving a file copies it off the callback thread. localPathFor copies the whole file when scoped storage gives only a content URI, and Nearby delivers that callback on the main thread -- so the transfer meant for large data froze the UI for as long as the copy took, and a big enough one was within reach of an ANR. The delivery reads what it needs out of the callback first, since none of it outlives the call. --- .../nearby/AndroidNearbyTransport.java | 86 +++++++++++++------ 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index bbd04601ce7..3d059b6f160 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -768,10 +768,24 @@ private ConnectionLifecycleCallback connectionCallback( public void onConnectionInitiated(String endpointId, ConnectionInfo info) { endpointNames.put(endpointId, info.getEndpointName()); - // An endpoint that arrives here without having been - // discovered came in through advertising, so that is the - // service it belongs to. - if (!endpointServices.containsKey(endpointId)) { + // Which service this connection belongs to depends on who + // started it, and Nearby says which. + // + // INCOMING means the peer answered THIS advertisement, so + // the service is the one this callback was built for, even + // when discovery had already seen the same peer under + // another. Treating any existing mapping as authoritative + // labelled that connection -- and every lifecycle and + // payload event on it -- with the service it was discovered + // under rather than the one it was negotiated through, which + // in an app running two services routes it to the wrong + // protocol. + // + // OUTGOING keeps the mapping discovery recorded, because + // this callback carries the discoveryServiceId FIELD, which + // may have moved on since the endpoint was found. + if (info.isIncomingConnection() + || !endpointServices.containsKey(endpointId)) { endpointServices.put(endpointId, serviceId); } NearbyTransport.deliverConnectionRequested( @@ -939,30 +953,46 @@ public void onPayloadTransferUpdate(String endpointId, != PayloadTransferUpdate.Status.SUCCESS) { return; } - String path = localPathFor(file); - if (path == null) { - // A payload whose only accessor cannot be used is worse - // than one that failed: getPath() is all a file Payload - // offers, so delivering it with a null path told the app - // the transfer succeeded and then gave it nothing to - // read. Reported as a failure instead, which is a state - // the API already documents. - NearbyTransport.deliverPayloadProgress( - encode(endpointId, nameOf(endpointId)), - senderIdOf(file), 0, update.getTotalBytes(), - PayloadStatus.FAILURE.ordinal()); - return; - } - // The terminal SUCCESS held back above, now that it is true. - NearbyTransport.deliverPayloadProgress( - encode(endpointId, nameOf(endpointId)), - senderIdOf(file), update.getBytesTransferred(), - update.getTotalBytes(), - PayloadStatus.SUCCESS.ordinal()); - NearbyTransport.deliverPayloadReceived( - encode(endpointId, nameOf(endpointId)), - senderIdOf(file), NearbyBridge.PAYLOAD_FILE, null, - "file://" + path); + // On a WORKER thread, because localPathFor copies the whole + // file when scoped storage gives only a content URI -- and + // Nearby delivers this callback on the main thread. A file + // payload is the one meant for large data, so copying it + // here froze the UI for as long as the copy took and put a + // big enough transfer within reach of an ANR. + // + // Everything the delivery needs is read out first: this + // callback's arguments do not outlive it. + final Payload received = file; + final String peer = encode(endpointId, nameOf(endpointId)); + final int senderId = senderIdOf(file); + final long moved = update.getBytesTransferred(); + final long total = update.getTotalBytes(); + new Thread(new Runnable() { + public void run() { + String path = localPathFor(received); + if (path == null) { + // A payload whose only accessor cannot be used is + // worse than one that failed: getPath() is all a + // file Payload offers, so delivering it with a + // null path told the app the transfer succeeded + // and then gave it nothing to read. Reported as a + // failure instead, which is a state the API + // already documents. + NearbyTransport.deliverPayloadProgress(peer, + senderId, 0, total, + PayloadStatus.FAILURE.ordinal()); + return; + } + // The terminal SUCCESS held back above, now that it + // is true. + NearbyTransport.deliverPayloadProgress(peer, senderId, + moved, total, + PayloadStatus.SUCCESS.ordinal()); + NearbyTransport.deliverPayloadReceived(peer, senderId, + NearbyBridge.PAYLOAD_FILE, null, + "file://" + path); + } + }, "CN1 nearby file receive").start(); } }; } From a1843d86f4b2bc0f217e2abddb50ea4c3612cabc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:33:02 +0300 Subject: [PATCH 87/94] Address the sixty-third nearby review round - An association snapshot carries the presence it was last reported with. getAssociations encoded every one as absent, so an app following CompanionDevice.isPresent()'s own instruction to re-read the association turned a device that had just appeared back into one that was not there. The platform has no query for this -- presence arrives as an event and nowhere else -- so the last event is the only answer there is, and the store that already sees every event is where it is kept. - A stale association failure is ignored. The platform can answer long after an activity recreation released that request and a new chooser took the slot, and releaseResultListener is not owner-checked: the stale failure tore down the LIVE request's listener, so its chooser result went nowhere and its resource never settled. launch() checks the same thing for the same reason. - The persisted backlog is read, written and cleared under one lock. The service persists on its callback thread while the backend restores on the thread that built it, so the restore could read the stored rows, the service append one to what it had read, and the restore then delete the whole key -- taking with it an event it never returned. If the process died before the in-memory copy reached a listener that event was gone, which is precisely the cold start this store exists for. --- .../android/nearby/AndroidNearbyBackend.java | 20 +++- .../nearby/CN1CompanionDeviceService.java | 4 +- .../android/nearby/NearbyPresenceStore.java | 105 ++++++++++++------ 3 files changed, 92 insertions(+), 37 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 62d66162bff..20ec7f4eb43 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -714,6 +714,16 @@ public void onDeviceFound(IntentSender chooserLauncher) { @Override public void onFailure(CharSequence error) { + // Still ours? The platform can answer long after an activity + // recreation released this request and a new chooser took + // the slot, and releaseResultListener is NOT owner-checked: + // a stale failure tore down the live request's listener, so + // its chooser result went nowhere and its resource never + // settled. launch() checks the same thing for the same + // reason. + if (pendingAssociate() != requestId) { + return; + } releaseAssociate(requestId); // The listener was installed before associate() was called, // and installing one marks CodenameOneActivity as waiting for @@ -937,16 +947,22 @@ public String[] getAssociations() { if (cdm == null) { return new String[0]; } + // With the presence each association was last reported with, not a + // flat false. CompanionDevice.isPresent() tells the app to re-read + // the association for a current answer, and re-reading turned a + // device that had just appeared back into one that was not there. List out = new ArrayList(); if (Build.VERSION.SDK_INT >= 33) { List all = cdm.getMyAssociations(); for (int i = 0; all != null && i < all.size(); i++) { - out.add(encode(all.get(i), false)); + out.add(encode(all.get(i), + NearbyPresenceStore.isPresent(idOf(all.get(i))))); } } else { List legacy = cdm.getAssociations(); for (int i = 0; legacy != null && i < legacy.size(); i++) { - out.add(encodeLegacy(legacy.get(i), legacy.get(i), false)); + out.add(encodeLegacy(legacy.get(i), legacy.get(i), + NearbyPresenceStore.isPresent(legacy.get(i)))); } } return out.toArray(new String[out.size()]); diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java index 933343cd617..c8dab72e179 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -147,7 +147,7 @@ private void deliverByAddress(String address, boolean present) { } String encoded = sanitize(address) + '\t' + sanitize(address) + '\t' + sanitize(address) + "\t0\t" + (present ? '1' : '0'); - NearbyPresenceStore.record(this, encoded, present); + NearbyPresenceStore.record(this, address, encoded, present); } private void deliver(AssociationInfo info, boolean present) { @@ -179,7 +179,7 @@ private void deliver(AssociationInfo info, boolean present) { + sanitize(mac == null ? "" : mac) + '\t' + AndroidNearbyBackend.profileOrdinalOf(info) + '\t' + (present ? '1' : '0'); - NearbyPresenceStore.record(this, encoded, present); + NearbyPresenceStore.record(this, id, encoded, present); } private static String sanitize(String s) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java index 6cbb24689fb..fc4608fbb30 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java @@ -127,6 +127,35 @@ public static void unregister(String associationId) { private static long presenceSequence; + /// The last presence each association was reported with. + /// + /// getAssociations() encoded every association as absent, so an app + /// following CompanionDevice.isPresent()'s own instruction to re-read + /// the association turned a device that had just appeared back into one + /// that was not there. The platform has no query for this -- presence + /// arrives as an event and nowhere else -- so the last event is the only + /// answer there is. + private static final java.util.Map PRESENT = + Collections.synchronizedMap(new java.util.HashMap()); + + /// Guards the whole persisted backlog, read-modify-write and all. + /// + /// The service persists on its callback thread while the backend + /// restores on the thread that built it. Unsynchronized, the restore + /// could read the stored rows, the service append one to what it had + /// read, and the restore then delete the KEY -- taking with it an event + /// that was never returned. If the process died before its in-memory + /// copy reached a listener that event was gone, which is precisely the + /// cold start this store exists for. + private static final Object STORE_LOCK = new Object(); + + /// Whether this association was last reported present. + static boolean isPresent(String associationId) { + Boolean known = PRESENT.get(associationId); + return known != null && known.booleanValue(); + } + /// Hands an event to the backlog, persisting it only if nobody is /// listening. /// @@ -141,7 +170,11 @@ static boolean isUnobserved(String associationId) { return UNOBSERVED.contains(associationId); } - static void record(Context ctx, String encoded, boolean present) { + static void record(Context ctx, String associationId, String encoded, + boolean present) { + if (associationId != null) { + PRESENT.put(associationId, Boolean.valueOf(present)); + } if (CompanionDevices.hasPresenceListener()) { CompanionDevices.deliverPresenceChanged(encoded, present); return; @@ -164,35 +197,38 @@ private static void persist(Context ctx, String row) { if (ctx == null) { return; } - try { - SharedPreferences prefs = ctx.getSharedPreferences( - PRESENCE_PREFS, Context.MODE_PRIVATE); - String existing = prefs.getString(PRESENCE_KEY, ""); - List rows = new ArrayList(); - if (existing.length() > 0) { - for (String r : existing.split("\n")) { - if (r.length() > 0) { - rows.add(r); + synchronized (STORE_LOCK) { + try { + SharedPreferences prefs = ctx.getSharedPreferences( + PRESENCE_PREFS, Context.MODE_PRIVATE); + String existing = prefs.getString(PRESENCE_KEY, ""); + List rows = new ArrayList(); + if (existing.length() > 0) { + for (String r : existing.split("\n")) { + if (r.length() > 0) { + rows.add(r); + } } } - } - rows.add(row); - while (rows.size() > MAX_PERSISTED) { - rows.remove(0); - } - StringBuilder out = new StringBuilder(); - for (int i = 0; i < rows.size(); i++) { - if (i > 0) { - out.append('\n'); + rows.add(row); + while (rows.size() > MAX_PERSISTED) { + rows.remove(0); } - out.append(rows.get(i)); + StringBuilder out = new StringBuilder(); + for (int i = 0; i < rows.size(); i++) { + if (i > 0) { + out.append('\n'); + } + out.append(rows.get(i)); + } + prefs.edit().putString(PRESENCE_KEY, out.toString()).commit(); + } catch (Throwable unavailable) { + // Nothing can be done about a store that will not take it, and + // failing the event outright would lose what the in-memory + // backlog can still carry for a process that lives long enough. + Log.w("CN1Nearby", "presence backlog not persisted", + unavailable); } - prefs.edit().putString(PRESENCE_KEY, out.toString()).commit(); - } catch (Throwable unavailable) { - // Nothing can be done about a store that will not take it, and - // failing the event outright would lose what the in-memory - // backlog can still carry for a process that lives long enough. - Log.w("CN1Nearby", "presence backlog not persisted", unavailable); } } @@ -214,13 +250,16 @@ static String[] takePersistedPresence(Context ctx) { return new String[0]; } String existing; - try { - SharedPreferences prefs = ctx.getSharedPreferences( - PRESENCE_PREFS, Context.MODE_PRIVATE); - existing = prefs.getString(PRESENCE_KEY, ""); - prefs.edit().remove(PRESENCE_KEY).commit(); - } catch (Throwable unavailable) { - return new String[0]; + // Read and removed as ONE step, against the same lock persist takes. + synchronized (STORE_LOCK) { + try { + SharedPreferences prefs = ctx.getSharedPreferences( + PRESENCE_PREFS, Context.MODE_PRIVATE); + existing = prefs.getString(PRESENCE_KEY, ""); + prefs.edit().remove(PRESENCE_KEY).commit(); + } catch (Throwable unavailable) { + return new String[0]; + } } if (existing.length() == 0) { return new String[0]; From 45fa2ba1d65c40dff3e8f5e54983edc2ccbf786e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:47:21 +0300 Subject: [PATCH 88/94] Address the sixty-fourth nearby review round - Building the Android bridge is serialized. Two threads reaching nearby for the first time both saw null and both built a backend; only one was kept, and the loser could already have prepared a UWB session or taken the companion chooser slot in state nothing could reach again -- so a later start or stop could not find its session, and the radio it had opened stayed open. - A restored presence event seeds the presence cache. Delivering it straight to CompanionDevices left getAssociations answering "absent" for the very device the listener had just been told had appeared, which is the contradiction the cache was added to remove. The replay goes through the store now, which is what knows both. - A provisional inbound accept is failed rather than disconnected. An accepted endpoint enters `connected` immediately, so a stop or a disconnect before its confirmation hop reported a DISCONNECTION for a connection the app had never been told it had -- while the outcome accept() documents, connected or connectionFailed, never arrived. The inbound path has its own reservation now, the way the outbound one has had since the connecting list was added. --- .../impl/nearby/LocalNearbyBridge.java | 46 +++++++++++++++++-- .../impl/android/AndroidImplementation.java | 9 +++- .../android/nearby/AndroidNearbyBackend.java | 12 ++--- .../android/nearby/NearbyPresenceStore.java | 29 ++++++++++++ 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java index 446e7c3d094..2578a8f1f2f 100644 --- a/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -116,6 +116,14 @@ public class LocalNearbyBridge implements NearbyBridge { /// connections the real ports refuse -- which is exactly the topology bug /// a simulator exists to surface rather than hide. private final List connecting = new ArrayList(); + /// Endpoints accepted but not yet confirmed connected. + /// + /// The inbound counterpart of `connecting`. An accepted endpoint goes + /// into `connected` straight away, so a stop or a disconnect before its + /// confirmation hop reported it as DISCONNECTED -- a connection the app + /// had never been told it had -- while the accept's documented outcome, + /// connected or connectionFailed, never arrived at all. + private final List accepting = new ArrayList(); /// The topology each half was started with, as a TransportStrategy /// ordinal. CLUSTER is the default, which is also what a caller that /// passed no strategy is given. @@ -706,6 +714,9 @@ public void acceptConnection(final int requestId, String endpointId) { if (!connected.contains(endpointId)) { connected.add(endpointId); } + if (!accepting.contains(endpointId)) { + accepting.add(endpointId); + } answerOk(requestId); // The lifecycle event, which on a real platform arrives from the // connection callback and here had no other source. accept() @@ -716,6 +727,7 @@ public void acceptConnection(final int requestId, String endpointId) { answer(new Runnable() { @Override public void run() { + accepting.remove(accepted); SimEndpoint e = findEndpoint(accepted); if (e != null && connected.contains(accepted)) { NearbyTransport.deliverConnectionResult(e.encode(), true, @@ -728,6 +740,7 @@ public void run() { @Override public void rejectConnection(String endpointId) { connected.remove(endpointId); + accepting.remove(endpointId); if (endpointId != null && !rejected.contains(endpointId)) { rejected.add(endpointId); } @@ -836,9 +849,20 @@ public void cancelPayload(int payloadId) { public void disconnect(String endpointId) { if (connected.remove(endpointId)) { SimEndpoint e = findEndpoint(endpointId); - if (e != null) { - NearbyTransport.deliverDisconnected(e.encode()); + if (e == null) { + accepting.remove(endpointId); + return; + } + if (accepting.remove(endpointId)) { + // Accepted and dropped before its confirmation, for the + // reason the stop path gives. + NearbyTransport.deliverConnectionResult(e.encode(), false, + NearbyError.SESSION_INVALIDATED.ordinal(), + "the connection was disconnected before it" + + " completed"); + return; } + NearbyTransport.deliverDisconnected(e.encode()); return; } // Not connected YET. The acceptance is queued behind this call, and @@ -886,12 +910,26 @@ public void stopAllTransport() { cancelledPayloads.clear(); pendingPayloads.clear(); List doomed = new ArrayList(connected); + List unconfirmed = new ArrayList(accepting); connected.clear(); + accepting.clear(); for (String id : doomed) { SimEndpoint e = findEndpoint(id); - if (e != null) { - NearbyTransport.deliverDisconnected(e.encode()); + if (e == null) { + continue; + } + if (unconfirmed.contains(id)) { + // Accepted, never confirmed. The app was never told this was + // connected, so it is not told it disconnected either -- it + // is told the accept did not come off, which is the outcome + // accept() documents. + NearbyTransport.deliverConnectionResult(e.encode(), false, + NearbyError.SESSION_INVALIDATED.ordinal(), + "the transport was stopped before the connection" + + " completed"); + continue; } + NearbyTransport.deliverDisconnected(e.encode()); } } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 01a0ea833bb..1b6a64c8edd 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13508,7 +13508,14 @@ public com.codename1.impl.ARImpl createARImpl() { /// bundled, so the public API reports NOT_SUPPORTED without this getter /// having to know how the app was built. @Override - public com.codename1.nearby.spi.NearbyBridge getNearbyBridge() { + public synchronized com.codename1.nearby.spi.NearbyBridge + getNearbyBridge() { + // Synchronized, because two threads reaching nearby for the first + // time both saw null and both built a backend. Only one was kept, + // and the loser could already have prepared a UWB session or taken + // the companion chooser slot in state nothing could reach again -- + // so a later start or stop could not find its session, and the radio + // it had opened stayed open. if (nearbyBridge == null) { nearbyBridge = new AndroidNearbyBridge(getActivity()); } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index 20ec7f4eb43..eaebad06375 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -161,13 +161,11 @@ private void restorePresence() { String[] rows = NearbyPresenceStore.takePersistedPresence( appContext); for (int i = 0; i < rows.length; i++) { - int tab = rows[i].indexOf('\t'); - if (tab <= 0) { - continue; - } - CompanionDevices.deliverPresenceChanged( - rows[i].substring(tab + 1), - "1".equals(rows[i].substring(0, tab))); + // Through the store, so the presence cache is seeded with what + // is being replayed. Delivering straight to CompanionDevices + // left getAssociations answering "absent" for the very device + // the listener had just been told had appeared. + NearbyPresenceStore.deliverRestored(rows[i]); } } diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java index fc4608fbb30..2c196e8773d 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java @@ -150,6 +150,35 @@ public static void unregister(String associationId) { /// cold start this store exists for. private static final Object STORE_LOCK = new Object(); + /// Replays one persisted row, seeding the presence it carries. + /// + /// The seeding is the point: a listener handling a cold-start appearance + /// and calling getAssociations() straight away got the same device back + /// as absent, because nothing had told the cache what the replayed event + /// says. Delivering without recording made the restore contradict itself. + /// + /// #### Parameters + /// + /// - `row`: `present-flag TAB encoded`, as takePersistedPresence returns + static void deliverRestored(String row) { + if (row == null) { + return; + } + int tab = row.indexOf('\t'); + if (tab <= 0) { + return; + } + boolean present = "1".equals(row.substring(0, tab)); + String encoded = row.substring(tab + 1); + // The id is the first field of the encoded record; see the service, + // which builds it. + int end = encoded.indexOf('\t'); + if (end > 0) { + PRESENT.put(encoded.substring(0, end), Boolean.valueOf(present)); + } + CompanionDevices.deliverPresenceChanged(encoded, present); + } + /// Whether this association was last reported present. static boolean isPresent(String associationId) { Boolean known = PRESENT.get(associationId); From eaf85c755dac7a4037841998d852da57cc8b555d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:02:16 +0300 Subject: [PATCH 89/94] Address the sixty-fifth nearby review round - Starting advertising or discovery replaces what was running. Nearby has one advertiser and one discoverer per client and refuses a second start as "already advertising", so a start issued while an earlier one was live was rejected -- and the earlier one went on broadcasting the service the app had moved off. The generation guards could not save it: the earlier start had already been answered, so nothing was left marked unowned for a failure to clean up. The simulated bridge and the iOS port both replace an existing start; this is Android doing the same. - Discovery and connection services are separate mappings. One map could not be both, and the fix that labelled an inbound connection correctly did it by writing over what discovery had recorded -- so a connection that came and went left the endpoint's later endpointLost naming a service its endpointFound never mentioned. Connection and payload events read the negotiated service; discovery events read the discovered one. - A late start refusal tears down the object it refused. MultipeerConnectivity promises no deadline for didNotStart, and the grace period that answers a start is a heuristic -- nothing else reports success, so waiting a moment for a refusal that does not come is the only positive signal there is. One arriving afterwards cannot un-resolve the resource: the SPI has one channel per request and it has been used. What it must not do is leave an advertiser installed that says it is advertising when nothing is, which made the next start think it was replacing a live operation and every later stop think it had something to stop. --- .../nearby/AndroidNearbyTransport.java | 76 +++++++++++++++++-- Ports/iOSPort/nativeSources/CN1Nearby.m | 24 ++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index 3d059b6f160..a5ce3683e3f 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -89,6 +89,17 @@ public class AndroidNearbyTransport implements NearbyBridge { private final Map payloadRecipients = Collections.synchronizedMap(new HashMap()); + /// The service each endpoint was NEGOTIATED through, when connected. + /// + /// Separate from endpointServices, which is what discovery saw. One map + /// could not be both: an inbound connection through the advertised + /// service has to label its own events with that service, and writing it + /// over the discovery entry destroyed the pairing discovery owes its + /// listener -- endpointFound reported A and the later endpointLost, after + /// the connection had closed, reported B. + private final Map connectionServices = + Collections.synchronizedMap(new HashMap()); + /// The service each endpoint was found under. /// /// One shared field was wrong: an app advertising service B while @@ -162,14 +173,36 @@ public class AndroidNearbyTransport implements NearbyBridge { /// A newer start owns the operation: leave the radio alone. private static final int START_SUPERSEDED = 2; - /// Claims a generation for a start that is about to be issued. + /// Claims a generation for a start that is about to be issued, replacing + /// whatever was running. + /// + /// The stop is the point. Nearby has ONE advertiser and one discoverer + /// per client and refuses a second start as "already advertising", so a + /// start issued while an earlier one was live was rejected -- and the + /// earlier one went on broadcasting the service the app had moved off. + /// The generation guards could not save it either: the earlier start had + /// already been answered, so nothing was left marked unowned for a + /// failure to clean up. The simulated bridge and the iOS port both + /// replace an existing start; this is Android doing the same. private int beginStart(boolean advertising) { synchronized (transportLock) { + boolean live = advertising + ? (advertisingWanted || unownedAdvertising) + : (discoveringWanted || unownedDiscovering); + if (live) { + if (advertising) { + client().stopAdvertising(); + } else { + client().stopDiscovery(); + } + } if (advertising) { advertisingWanted = true; + unownedAdvertising = false; return ++advertiseGeneration; } discoveringWanted = true; + unownedDiscovering = false; return ++discoverGeneration; } } @@ -695,6 +728,7 @@ public void stopAllTransport() { for (String id : discoveredOnly) { endpointNames.remove(id); endpointServices.remove(id); + connectionServices.remove(id); } // Discovery visibility goes for EVERYTHING, connected or not. // Discovery has stopped, so no onEndpointLost is coming for any @@ -734,13 +768,18 @@ public void onEndpointFound(String endpointId, endpointNames.put(endpointId, info.getEndpointName()); endpointServices.put(endpointId, serviceId); NearbyTransport.deliverEndpointFound( - encode(endpointId, info.getEndpointName()), true); + encodeDiscovered(endpointId, + info.getEndpointName()), true); } @Override public void onEndpointLost(String endpointId) { + // The DISCOVERY service, so this names the same one the + // endpointFound for it did -- even where a connection + // through another service came and went in between. NearbyTransport.deliverEndpointFound( - encode(endpointId, nameOf(endpointId)), false); + encodeDiscovered(endpointId, nameOf(endpointId)), + false); discoveredEndpoints.remove(endpointId); if (connectedEndpoints.contains(endpointId)) { // Still connected, so the metadata has to stay: payload @@ -757,6 +796,7 @@ public void onEndpointLost(String endpointId) { // different service -- and onConnectionInitiated leaves an // existing entry alone, so it kept reporting the old one. endpointServices.remove(endpointId); + connectionServices.remove(endpointId); } }; } @@ -784,8 +824,11 @@ public void onConnectionInitiated(String endpointId, // OUTGOING keeps the mapping discovery recorded, because // this callback carries the discoveryServiceId FIELD, which // may have moved on since the endpoint was found. - if (info.isIncomingConnection() - || !endpointServices.containsKey(endpointId)) { + connectionServices.put(endpointId, serviceId); + if (!endpointServices.containsKey(endpointId)) { + // Never discovered, so this is also the only service + // anything knows it by -- which is what an endpointLost + // for it would have to report. endpointServices.put(endpointId, serviceId); } NearbyTransport.deliverConnectionRequested( @@ -823,6 +866,7 @@ public void onConnectionResult(String endpointId, // came back under another advertised service. endpointNames.remove(endpointId); endpointServices.remove(endpointId); + connectionServices.remove(endpointId); } } @@ -831,6 +875,9 @@ public void onDisconnected(String endpointId) { NearbyTransport.deliverDisconnected( encode(endpointId, nameOf(endpointId))); connectedEndpoints.remove(endpointId); + // The negotiated service goes with the connection that had + // it; discovery's own entry is a separate question below. + connectionServices.remove(endpointId); // Only when discovery has ALSO lost sight of it. A connection // can close while the peer is still being advertised and // still in the discovered set -- the app disconnects, or the @@ -1043,7 +1090,26 @@ private String nameOf(String endpointId) { return name == null ? "" : name; } + /// Encodes an endpoint for a CONNECTION or payload event. + /// + /// The negotiated service wins where there is one: that is the service + /// this connection belongs to, whatever discovery happened to see the + /// peer under first. private String encode(String endpointId, String name) { + String service = connectionServices.get(endpointId); + if (service == null) { + service = endpointServices.get(endpointId); + } + return sanitize(endpointId) + '\t' + sanitize(name) + '\t' + + sanitize(service == null ? "" : service); + } + + /// Encodes an endpoint for a DISCOVERY event. + /// + /// Always the service discovery saw, so found and lost name the same one + /// even when a connection through another service came and went in + /// between. + private String encodeDiscovered(String endpointId, String name) { String service = endpointServices.get(endpointId); return sanitize(endpointId) + '\t' + sanitize(name) + '\t' + sanitize(service == null ? "" : service); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index f958a242d5b..3c89c777af2 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1586,7 +1586,24 @@ - (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser if (requestId != 0) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, [error localizedDescription]); + return; } + // Too late to fail the caller, so at least stop pretending. + // + // MultipeerConnectivity promises no deadline for this callback, and + // the grace period that answers a start is a heuristic: nothing else + // reports success, so waiting a moment for the refusal that does not + // come is the only positive signal there is. A refusal arriving + // after that cannot un-resolve the AsyncResource -- the SPI has one + // channel per request and it has been used. + // + // What it must not do is leave this advertiser installed. Nothing + // was advertising, and an object that says otherwise makes the next + // startAdvertising think it is replacing a live operation and every + // later stop think it has something to stop. + [self.advertiser stopAdvertisingPeer]; + self.advertiser.delegate = nil; + self.advertiser = nil; } } @@ -1644,7 +1661,14 @@ - (void)browser:(MCNearbyServiceBrowser *)browser if (requestId != 0) { cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, [error localizedDescription]); + return; } + // Torn down for the reason the advertising twin is: too late to fail + // the caller, and a browser that says it is browsing when nothing + // is misleads every call after it. + [self.browser stopBrowsingForPeers]; + self.browser.delegate = nil; + self.browser = nil; } } From dda7299cf297f1793800607d899f6716f95e4ab4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:10:18 +0300 Subject: [PATCH 90/94] Start the UWB grace period where the radio is asked subscribeOn defers the subscription to an io thread, and the timer was armed on the calling thread the moment subscribe() returned. A saturated or delayed scheduler therefore let it answer "ranging started" before rangingResultsObservable had asked for anything at all -- and the error that followed arrived as an invalidation, after the caller had already been told its start succeeded, which is the opposite of what start() documents. It is armed from doOnSubscribe now, placed UPSTREAM of subscribeOn deliberately: that is what puts the callback on the thread the subscription actually happens on. --- .../android/nearby/AndroidUwbRanging.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java index 326156587e6..c08b73c6769 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -431,6 +431,24 @@ private void run(final int requestId, final Session session, session.startRequest.set(requestId); Disposable started = UwbClientSessionScopeRx .rangingResultsObservable(session.scope, params) + // The grace period starts HERE, where the radio is + // actually asked -- not on the calling thread. + // + // subscribeOn defers the subscription to an io thread, so + // a saturated or delayed scheduler let the timer answer + // "ranging started" before rangingResultsObservable had + // asked for anything at all; the error that followed + // arrived as an invalidation, after the caller had been + // told its start succeeded. Placed UPSTREAM of + // subscribeOn deliberately: that is what puts this + // callback on the thread the subscription happens on. + .doOnSubscribe( + new io.reactivex.rxjava3.functions.Consumer< + Disposable>() { + public void accept(Disposable d) { + scheduleStartGrace(session); + } + }) .subscribeOn(Schedulers.io()) .subscribe(new io.reactivex.rxjava3.functions.Consumer< RangingResult>() { @@ -489,7 +507,6 @@ public void accept(Throwable error) { "the session was stopped before ranging started"); return; } - scheduleStartGrace(session); } catch (Throwable t) { session.startRequest.set(0); fail(requestId, NearbyError.SESSION_FAILED, message(t)); From ebd58b71b1d7b8b9ba2d4244ce9fbcdf1c87a8cd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:11:39 +0300 Subject: [PATCH 91/94] Address the sixty-sixth nearby review round - iOS keeps the discovery service apart from the negotiated one, as Android now does. One map could not be both: browsing A while advertising B, a peer that connected through B and was then found or lost by the browser had its entry rewritten to A, so every payload and disconnection on that live B connection carried a service it had nothing to do with. Connection events read the service the connection was negotiated through -- the advertised one for an invitation this device answered, the discovered one for an invitation it sent -- and discovery events read what the browser saw. - Stopping the transport REJECTS the invitations it is dropping. The handler is the only thing that tells the initiator its invitation was answered, so releasing it unanswered left that peer's requestConnection waiting on MultipeerConnectivity's own timeout instead of hearing immediately that it was refused -- which is what stopping means for an invitation nobody is going to look at. Answered outside the lock, since the handler runs framework code. --- Ports/iOSPort/nativeSources/CN1Nearby.m | 78 ++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 3c89c777af2..1aa812d16cb 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -501,6 +501,14 @@ @interface CN1NearbyTransport : NSObject Date: Mon, 24 Aug 2026 13:29:42 +0300 Subject: [PATCH 92/94] Drop the durable presence row once the backlog is replayed An event that arrived with no listener registered was persisted AND parked. When a listener appeared later in the same process the parked copy was replayed, but the durable row stayed on disk -- so the next launch restored it and delivered an appearance the app had already handled. The hasPresenceListener gate does not cover this: the listener did not exist when the event arrived. CompanionDevices now tells the port when the backlog has been handed to a listener, which is the only moment that can be known from there: parking happens in that class and so does the replay. The Android store forgets its rows on that signal, and the rows a PREVIOUS process left cannot be caught by it -- the restore takes all of those when the backend is built, before any listener can register. --- .../nearby/companion/CompanionDevices.java | 38 +++++++++++++++++-- .../android/nearby/AndroidNearbyBackend.java | 12 ++++++ .../android/nearby/NearbyPresenceStore.java | 25 ++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java index 7359a615bfd..6a5977bd093 100644 --- a/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -267,6 +267,27 @@ public static void stopObservingPresence(String associationId) { } } + /// Notified once the parked backlog has been handed to the listeners. + /// + /// A port that keeps a DURABLE copy of an event needs to know when the + /// in-memory one has been consumed, or it replays on the next launch + /// something the app has already handled. Nothing else can tell it: + /// parking happens here, and so does the replay. + private static Runnable presenceBacklogDrained; + + /// Registers the hook above. Replaces any previous one. + /// + /// @hidden not part of the public API; for ports. + /// + /// #### Parameters + /// + /// - `onDrained`: run on the EDT after the backlog empties, or null + public static void setPresenceBacklogDrainedHook(Runnable onDrained) { + synchronized (LISTENERS) { + presenceBacklogDrained = onDrained; + } + } + /// Whether any listener is registered to receive presence right now. /// /// For a PORT deciding whether an event needs to outlive the process. @@ -369,13 +390,24 @@ public void run() { /// Ends the replay, or continues it when events parked while the backlog /// was in flight. private static void finishReplay() { + boolean finished; + Runnable drained = null; synchronized (LISTENERS) { - if (PENDING_PRESENCE.isEmpty()) { + finished = PENDING_PRESENCE.isEmpty(); + if (finished) { replayingPresence = false; - return; + drained = presenceBacklogDrained; } } - replayPresence(); + if (!finished) { + replayPresence(); + return; + } + if (drained != null) { + // Outside the lock: a port's hook touches its own storage, and + // holding this monitor across it is how a deadlock is built. + drained.run(); + } } /// Removes a listener added by [#addPresenceListener]. diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java index eaebad06375..0a55304c06a 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -158,6 +158,18 @@ public AndroidNearbyBackend(Activity activity) { /// they come back, which is the first thing an app touches on its way to /// registering a presence listener. private void restorePresence() { + // Registered here, once, so the durable rows this process parks are + // dropped as soon as a listener has taken the in-memory backlog. + // Without it an event parked AFTER the backend was built stayed on + // disk after the app had handled it, and the next launch delivered + // it again. + final Context ctx = appContext; + CompanionDevices.setPresenceBacklogDrainedHook(new Runnable() { + @Override + public void run() { + NearbyPresenceStore.acknowledgeDelivered(ctx); + } + }); String[] rows = NearbyPresenceStore.takePersistedPresence( appContext); for (int i = 0; i < rows.length; i++) { diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java index 2c196e8773d..062f5d41f16 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java @@ -179,6 +179,31 @@ static void deliverRestored(String row) { CompanionDevices.deliverPresenceChanged(encoded, present); } + /// Forgets the durable rows this process parked. + /// + /// Called when CompanionDevices has handed the in-memory backlog to a + /// listener: the app has now seen those events, so a copy kept for the + /// next launch would deliver them a second time. The rows this process + /// never parked cannot be here -- the restore took them all when the + /// backend was built, before any listener could register. + static void acknowledgeDelivered(Context ctx) { + DELIVERED_HERE.clear(); + if (ctx == null) { + return; + } + synchronized (STORE_LOCK) { + try { + ctx.getSharedPreferences(PRESENCE_PREFS, Context.MODE_PRIVATE) + .edit().remove(PRESENCE_KEY).commit(); + } catch (Throwable unavailable) { + // Nothing to do: the worst case is a replay the next launch + // filters no further, which is what this was already. + Log.w("CN1Nearby", "presence backlog not cleared", + unavailable); + } + } + } + /// Whether this association was last reported present. static boolean isPresent(String associationId) { Boolean known = PRESENT.get(associationId); From 9161b6b2b0c8a0da11700f2040d98f49c37496d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:53:32 +0300 Subject: [PATCH 93/94] Bind an outgoing connection to the endpoint's discovered service discoveryServiceId is the service discovery is running for NOW, and a restart for another one moves it. Passing that labelled a connection to an endpoint found under the OLD service with the new one -- and since the connection service became a mapping of its own, nothing was left to correct it: the connected, payload and disconnection events all named a service the peer was never discovered on. The per-endpoint mapping is the source now, with the field as the fallback for an endpoint nothing recorded, which is one that arrived through advertising. iOS had the same defect in the same place, introduced when its two mappings were split, and gets the same fix. Also: the JavaSE simulator capture waits for the inspector's PROPERTIES to be populated. The details panel below settles empty and is already waited on; the properties settle the other way round -- the inspector selects a component and fills Class, UUID, Coordinates, Padding and Margin in, and the reference holds them populated. A capture taken before the selection propagated showed that layout with every value blank, which is not a state the simulator settles in, and failed four screenshots in one run on a slow runner while the commits either side passed. Measured on both sides: the reference draws 2625 dark pixels in that band and the unpopulated capture 93, so the threshold sits an order of magnitude clear of the failure. --- .../nearby/AndroidNearbyTransport.java | 16 +++++- Ports/iOSPort/nativeSources/CN1Nearby.m | 21 ++++++- .../lib/SimulatorWindowModeVerifier.java | 56 ++++++++++++++++++- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java index a5ce3683e3f..74c8539cb86 100644 --- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -548,10 +548,20 @@ public void requestConnection(final int requestId, String endpointId, String name = localName == null || localName.length() == 0 ? this.localName : localName; // Connecting OUT, so the endpoint belongs to whatever discovery - // found it -- the field is the right source here, and the mapping - // discoveryCallback recorded is left alone when one already exists. + // found IT -- which is the per-endpoint mapping, not the field. + // + // discoveryServiceId is the service discovery is running for NOW, + // and a restart for another one moves it. Passing that labelled a + // connection to an endpoint found under the old service with the + // new one, and since the connection service is recorded separately + // there is nothing left to correct it: the connected, payload and + // disconnection events all named a service the peer was never + // discovered on. The field remains the fallback for an endpoint + // nothing recorded, which is one that arrived through advertising. + String discovered = endpointServices.get(endpointId); client().requestConnection(name, endpointId, - connectionCallback(discoveryServiceId)) + connectionCallback(discovered != null ? discovered + : discoveryServiceId)) .addOnSuccessListener(new OnSuccessListener() { public void onSuccess(Void unused) { NearbyTransport.deliverRequestOk(requestId); diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m index 1aa812d16cb..6024efa5b4f 100644 --- a/Ports/iOSPort/nativeSources/CN1Nearby.m +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -1281,6 +1281,22 @@ - (NSString *)encodePeer:(MCPeerID *)peer service:(NSString *)serviceId { service == nil ? @"" : service, nil]); } +/// Records the service an OUTGOING connection to this peer belongs to. +/// +/// The service that peer was DISCOVERED under, not the one discovery is +/// running for now: a restart for another service moves the field, and an +/// invitation sent to an endpoint found under the old one would then be +/// labelled with the new one for the life of the connection. The field is +/// the fallback for a peer nothing recorded. +- (void)noteOutboundConnectionServiceForPeer:(NSString *)pid { + NSString *discovered; + @synchronized (self) { + discovered = [self.serviceIdByPeer objectForKey:pid]; + } + [self noteConnectionService:discovered != nil ? discovered + : self.discoverServiceId forPeer:pid]; +} + /// Records the service a CONNECTION with this peer was negotiated through. - (void)noteConnectionService:(NSString *)serviceId forPeer:(NSString *)pid { @@ -2967,10 +2983,9 @@ void com_codename1_impl_ios_IOSNative_nearbyRequestConnection___int_java_lang_St return; } // Connecting OUT, so this connection belongs to whatever discovery - // found the peer under -- recorded as the connection's service, so a + // found the PEER under -- recorded as the connection's service, so a // later sighting under another one cannot relabel it. - [cn1nbTransport noteConnectionService:cn1nbTransport.discoverServiceId - forPeer:pid]; + [cn1nbTransport noteOutboundConnectionServiceForPeer:pid]; // Reserved BEFORE the invitation goes out, so a second // requestConnection made while this one is still unanswered sees the // slot taken. diff --git a/scripts/javase/lib/SimulatorWindowModeVerifier.java b/scripts/javase/lib/SimulatorWindowModeVerifier.java index cbf2476e8ac..0020f76cbd7 100644 --- a/scripts/javase/lib/SimulatorWindowModeVerifier.java +++ b/scripts/javase/lib/SimulatorWindowModeVerifier.java @@ -146,7 +146,8 @@ public static void main(String[] args) { BufferedImage image = captureDesktop(); Instant renderDeadline = Instant.now().plusSeconds(30); while ((isBlankOrFlat(image) || isSingleWindowDeviceMissing(parsed, image) - || isComponentInspectorDetailsUnsettled(parsed, image)) + || isComponentInspectorDetailsUnsettled(parsed, image) + || isComponentInspectorPropertiesUnpopulated(parsed, image)) && Instant.now().isBefore(renderDeadline)) { Thread.sleep(500); image = captureDesktop(); @@ -218,6 +219,10 @@ private static void validateScreenshotContent(Args args, BufferedImage image) { throw new AssertionError("Component inspector details panel had not settled before capture; textPixels=" + countComponentDetailsPixels(image)); } + if (isComponentInspectorPropertiesUnpopulated(args, image)) { + throw new AssertionError("Component inspector properties had not been populated before capture; valuePixels=" + + countComponentPropertyValuePixels(image)); + } } private static boolean isBlankOrFlat(BufferedImage image) { @@ -302,6 +307,55 @@ private static int countComponentDetailsPixels(BufferedImage image) { */ private static final int MIN_COMPONENT_DETAILS_PIXELS = 200; + /** + * Whether the inspector's property VALUES have not been filled in yet. + * + *

The details panel below settles EMPTY, so the check above waits for it to go away. The + * properties above it settle the other way round: the inspector selects a component and fills + * the Class, UUID, Coordinates, Padding and Margin rows in, and the reference holds them + * populated. A capture taken before the selection propagates shows the same layout with every + * value blank -- which is not a state the simulator settles in, and comparing it against the + * reference fails over timing rather than over anything the run did.

+ * + *

This is the race that produced four differing screenshots in one run on a slow runner + * while the two commits either side of it passed. Read from the pixels for the reason the + * other two checks are: this verifier drives the simulator from another process.

+ */ + private static boolean isComponentInspectorPropertiesUnpopulated(Args args, BufferedImage image) { + if (!"component-inspector".equals(args.scenario)) { + return false; + } + return countComponentPropertyValuePixels(image) < MIN_COMPONENT_PROPERTY_PIXELS; + } + + /** The text drawn in the property VALUE column, beside the Class..Margin labels. */ + private static int countComponentPropertyValuePixels(BufferedImage image) { + int xMin = Math.min(image.getWidth(), 127); + int xMax = Math.min(image.getWidth(), 583); + int yMin = Math.min(image.getHeight(), 4); + int yMax = Math.min(image.getHeight(), 248); + if (xMax <= xMin || yMax <= yMin) { + return 0; + } + int textPixels = 0; + for (int y = yMin; y < yMax; y++) { + for (int x = xMin; x < xMax; x++) { + int rgb = image.getRGB(x, y); + if (((rgb >> 16) & 0xff) < 100 && ((rgb >> 8) & 0xff) < 100 && (rgb & 0xff) < 100) { + textPixels++; + } + } + } + return textPixels; + } + + /** + * Measured on both sides of the race this fixes: the stored reference draws about 2625 dark + * pixels in that band and the unpopulated capture draws 84, so the threshold sits an order of + * magnitude clear of the failure and well under the settled state. + */ + private static final int MIN_COMPONENT_PROPERTY_PIXELS = 800; + private static int minimumSingleWindowDevicePixels(Args args) { if ("test-recorder".equals(args.scenario)) { // The recorder window intentionally covers most of the simulator From 45b543b47a36612e42385cec955a90f6bcacfd57 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:06:51 +0300 Subject: [PATCH 94/94] Delete quality-report.md Signed-off-by: Shai Almog <67850168+shai-almog@users.noreply.github.com> --- quality-report.md | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 quality-report.md diff --git a/quality-report.md b/quality-report.md deleted file mode 100644 index a89d1a21ff4..00000000000 --- a/quality-report.md +++ /dev/null @@ -1,12 +0,0 @@ -## ✅ Continuous Quality Report - -### Test & Coverage -- ⚠️ No test results were found. -- ⚠️ Coverage report not generated. - -### Static Analysis -- ✅ SpotBugs: no findings (report was not generated by the build). -- ⚠️ PMD report not generated. -- ⚠️ Checkstyle report not generated. - -_Generated automatically by the PR CI workflow._