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/ 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..2578a8f1f2f --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/nearby/LocalNearbyBridge.java @@ -0,0 +1,1304 @@ +/* + * 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.nearby.transport.TransportStrategy; +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. +/// +/// #### 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(); + /// 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(); + /// 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. + private int advertiseStrategy = TransportStrategy.CLUSTER.ordinal(); + private int discoverStrategy = TransportStrategy.CLUSTER.ordinal(); + /// 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. + /// + /// @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 Set cancelledPayloads = new HashSet(); + /// Payload id to the number of queued sends still carrying it. + /// + /// 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. + /// + /// @hidden not part of the public API; test-only. + private List deferred; + + 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 meters, or -1 when it has + /// not started. + /// + /// #### Parameters + /// + /// - `sessionHandle`: the session to inspect + /// + /// #### Returns + /// + /// 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; + } + + /// 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 + // ------------------------------------------------------------------ + + @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 + // and out-of-line on a device is an app with a startup race. + answer(new PermissionAnswer(requestId)); + } + + // ------------------------------------------------------------------ + // 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, + ++sessionSequence); + sessions.put(Integer.valueOf(sessionHandle), s); + 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)); + 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() { + @Override + public void run() { + s.running = true; + Ranging.deliverSessionStarted(requestId, sessionHandle); + tick(s); + } + }); + } + + @Override + 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() { + @Override + 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); + } + }); + } + + @Override + public void stopRangingSession(int sessionHandle) { + SimSession s = sessions.remove(Integer.valueOf(sessionHandle)); + if (s != null) { + s.running = false; + } + } + + // ------------------------------------------------------------------ + // 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) { + // 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)); + } + }); + } + + @Override + public String[] getAssociations() { + String[] out = new String[associations.size()]; + int i = 0; + for (CompanionDevice d : associations.values()) { + out[i++] = NearbyWire.encodeCompanionDevice(d); + } + 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) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + "no such association"); + } else { + CompanionDevices.deliverDisassociated(requestId); + } + } + }); + } + + @Override + public boolean startObservingPresence(String associationId) { + if (!associations.containsKey(associationId)) { + return false; + } + observed.put(associationId, Boolean.TRUE); + return true; + } + + @Override + 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 + // ------------------------------------------------------------------ + + @Override + public int getMaxPayloadSize() { + // 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 + public void startAdvertising(final int requestId, String serviceId, + String localName, int strategy) { + advertising = true; + advertiseStrategy = strategy; + // 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) { + 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); + } + }); + } + + @Override + public void stopAdvertising() { + advertising = false; + advertiseGeneration++; + } + + @Override + public void startDiscovery(final int requestId, final String serviceId, + int strategy) { + discovering = true; + discoverStrategy = strategy; + // 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() { + // 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) { + 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; + NearbyTransport.deliverEndpointFound(e.encode(), true); + } + } + }); + } + + @Override + public void stopDiscovery() { + discovering = false; + // Bumped so a start still queued for this run of discovery can tell + // that it has been stopped -- and only THIS counter, so an unrelated + // advertise or connection in flight is left alone. + discoverGeneration++; + } + + @Override + 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; + } + // 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() && 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 + 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. + connecting.remove(endpointId); + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "the transport was stopped before the connection" + + " was answered"); + 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. + // + // 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); + NearbyTransport.deliverConnectionResult(e.encode(), + true, 0, null); + } + }); + } + }); + } + + @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. + if (advertiseStrategy == TransportStrategy.POINT_TO_POINT.ordinal() + && !(connected.isEmpty() && connecting.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); + } + 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() + // 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() { + accepting.remove(accepted); + SimEndpoint e = findEndpoint(accepted); + if (e != null && connected.contains(accepted)) { + NearbyTransport.deliverConnectionResult(e.encode(), true, + 0, null); + } + } + }); + } + + @Override + public void rejectConnection(String endpointId) { + connected.remove(endpointId); + accepting.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 + public void sendPayload(final int requestId, final String[] endpointIds, + final int payloadId, final int payloadType, final byte[] bytes, + final String path) { + 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() { + // 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. + // 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); + } + // 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)) { + unavailable.add(endpointId); + } + } + if (!unavailable.isEmpty()) { + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.PEER_UNAVAILABLE.ordinal(), + "these endpoints are not connected: " + + unavailable); + return; + } + NearbyTransport.deliverRequestOk(requestId); + for (String endpointId : endpointIds) { + final SimEndpoint e = findEndpoint(endpointId); + 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()); + if (echoPayloads) { + NearbyTransport.deliverPayloadReceived(e.encode(), + payloadId, payloadType, bytes, path); + } + } + } + }); + } + + @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. + // + // 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.containsKey(Integer.valueOf(payloadId))) { + cancelledPayloads.add(Integer.valueOf(payloadId)); + } + } + + @Override + public void disconnect(String endpointId) { + if (connected.remove(endpointId)) { + SimEndpoint e = findEndpoint(endpointId); + 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 + // 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"); + } + } + } + + @Override + 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++; + // 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); + List unconfirmed = new ArrayList(accepting); + connected.clear(); + accepting.clear(); + for (String id : doomed) { + SimEndpoint e = findEndpoint(id); + 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()); + } + } + + /// 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; + } + + /// 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; + } + + /// 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() { + @Override + 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 (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) { + 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) { + 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; + } + // 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; + } + + @Override + 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; + } + + @Override + 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; + } + + @Override + 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; + } + + @Override + 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; + } + + @Override + 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..1a25758adad --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/nearby/NearbyRequests.java @@ -0,0 +1,182 @@ +/* + * 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.impl.async.EdtResult; +import com.codename1.impl.async.PendingMap; +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; + + /// 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() { + } + + /// 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(); + } + + /// 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 + /// 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..a41abac79da --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/NearbyError.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.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, 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 + /// 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..bc1d2b96f2e --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevice.java @@ -0,0 +1,122 @@ +/* + * 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; + } + + @Override + 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); + } + + @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 new file mode 100644 index 00000000000..6a5977bd093 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/CompanionDevices.java @@ -0,0 +1,561 @@ +/* + * 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(); + /// 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; + /// 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. + private static final class PendingPresence { + final CompanionDevice device; + final boolean present; + + PendingPresence(CompanionDevice device, boolean present) { + this.device = device; + this.present = present; + } + } + + 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 (String row : rows) { + CompanionDevice d = NearbyWire.decodeCompanionDevice(row); + 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); + } + } + + /// 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. + /// 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 + /// 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 + /// + /// - `l`: the listener to add + 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) { + // Nothing parked, or another registration is already draining + // it -- and that drain will pick up anything that arrives + // while it runs. + return; + } + replayingPresence = true; + } + 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()) { + // 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); + PENDING_PRESENCE.clear(); + } + for (PendingPresence parked : batch) { + dispatchPresence(parked.device, parked.present); + } + } + } + + /// 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) { + finished = PENDING_PRESENCE.isEmpty(); + if (finished) { + replayingPresence = false; + drained = presenceBacklogDrained; + } + } + 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]. + /// + /// #### 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(); + PENDING_PRESENCE.clear(); + replayingPresence = false; + } + } + + // ------------------------------------------------------------------ + // 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; + } + synchronized (LISTENERS) { + if (LISTENERS.isEmpty() || !PENDING_PRESENCE.isEmpty() + || replayingPresence) { + 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() { + PresenceListener[] ls; + synchronized (LISTENERS) { + ls = LISTENERS.toArray( + new PresenceListener[LISTENERS.size()]); + } + for (PresenceListener l : ls) { + if (present) { + l.deviceAppeared(d); + } else { + l.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..d3b677ea7aa --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/companion/DeviceFilter.java @@ -0,0 +1,145 @@ +/* + * 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; + } + + @Override + 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..b14a41fdcdb --- /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 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 +/// 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..8e2b7d008d1 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/Ranging.java @@ -0,0 +1,498 @@ +/* + * 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 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 +/// +/// 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_SESSIONS = + new PendingMap(); + private static final PendingMap PENDING_ACCESSORY = + new PendingMap(); + private static final java.util.Map STARTING = + new java.util.HashMap(); + + 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(); + // 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; + if (permissions != null) { + for (NearbyPermission permission : permissions) { + bits |= permissionBit(permission); + } + } + int id = NearbyRequests.nextId(); + EdtResult out = NearbyRequests.openPermissionRequest(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 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. + /// + /// #### 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 = NearbyRequests.takePermissionRequest(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); + // 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); + } + 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) { + untrackStarting(requestId); + 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 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); + } + } + } + + /// 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) { + untrackStarting(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 + // 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); + } + } + + /// 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) { + // 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 = NearbyRequests.takePermissionRequest(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"); + 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; + } + + 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..6e355dbbb4d --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingAdapter.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. + */ +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 { + + @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 new file mode 100644 index 00000000000..7f58f754924 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingCapabilities.java @@ -0,0 +1,117 @@ +/* + * 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; + } + + @Override + 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..67837337f6b --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingSession.java @@ -0,0 +1,514 @@ +/* + * 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() { + synchronized (SESSIONS) { + 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 = reserveStart(); + 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); + Ranging.trackStarting(id, this); + 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 = reserveStart(); + 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); + Ranging.trackStarting(id, this); + 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 meters + /// - `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() { + @Override + public void run() { + // 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); + } + } + }); + } + + /// 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() { + @Override + public void run() { + if (s.isClosed()) { + return; + } + RangingListener[] ls = s.snapshot(); + for (RangingListener l : ls) { + l.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() { + @Override + public void run() { + if (s.isClosed()) { + return; + } + synchronized (SESSIONS) { + s.running = false; + } + RangingListener[] ls = s.snapshot(); + for (RangingListener l : ls) { + l.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() { + @Override + public void run() { + if (s.isClosed()) { + return; + } + synchronized (SESSIONS) { + s.running = true; + } + RangingListener[] ls = s.snapshot(); + for (RangingListener l : ls) { + l.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() { + @Override + public void run() { + // 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(); + } + for (RangingListener l : ls) { + l.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() { + 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, + /// 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() { + synchronized (SESSIONS) { + starting = false; + } + } + + private RangingListener[] snapshot() { + synchronized (listeners) { + return listeners.toArray(new RangingListener[listeners.size()]); + } + } + + /// 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; + } + } + + 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..ff7fd4e29a9 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingToken.java @@ -0,0 +1,262 @@ +/* + * 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); + // 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]; + 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); + } + + @Override + 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; + } + + @Override + public int hashCode() { + int h = platform; + for (byte b : payload) { + h = h * 31 + b; + } + return h; + } + + @Override + 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..e0a1b87a40c --- /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 +/// 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 { + /// Meters, the unit both platforms measure in. + METERS(1.0), + + /// International feet, 0.3048 m exactly. + FEET(0.3048), + + /// Centimeters. + CENTIMETERS(0.01), + + /// International inches, 0.0254 m exactly. + INCHES(0.0254); + + private final double metersPerUnit; + + RangingUnit(double metersPerUnit) { + this.metersPerUnit = metersPerUnit; + } + + /// Converts a distance expressed in meters into this unit. + /// + /// #### Parameters + /// + /// - `meters`: the distance in meters + /// + /// #### Returns + /// + /// the same distance expressed in this unit + public double fromMeters(double meters) { + return meters / metersPerUnit; + } + + /// Converts a distance expressed in this unit into meters. + /// + /// #### Parameters + /// + /// - `value`: the distance in this unit + /// + /// #### Returns + /// + /// 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 new file mode 100644 index 00000000000..4e8bee22073 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/ranging/RangingUpdate.java @@ -0,0 +1,188 @@ +/* + * 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 meters, 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. Zero is straight ahead -- + /// out of the top of a phone held upright -- and positive is to the + /// right. + /// + /// Undefined when [#hasDirection()] is `false`. + /// + /// **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; + } + + /// `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; + } + + @Override + 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..fc3be58efa2 --- /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 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. +/// +/// 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/Endpoint.java b/CodenameOne/src/com/codename1/nearby/transport/Endpoint.java new file mode 100644 index 00000000000..75de161951e --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/Endpoint.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.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; + } + + @Override + 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); + } + + @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 new file mode 100644 index 00000000000..ec3709b7eab --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/IncomingConnection.java @@ -0,0 +1,149 @@ +/* + * 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]. +/// +/// 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. +/// +/// #### Show the token +/// +/// [#getAuthenticationToken()] is a short string both devices compute from +/// 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 IncomingConnection { + + 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 IncomingConnection(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 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; + } + + /// Whether [#accept()] or [#reject()] has already been called. + public boolean isAnswered() { + 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 + /// [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 (!claim()) { + return; + } + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + // 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()); + } + } + + /// Rejects the connection. Calling this twice, or after [#accept()], + /// does nothing. + public void reject() { + if (!claim()) { + return; + } + NearbyBridge b = NearbyRequests.bridge(); + if (b != null) { + b.rejectConnection(endpoint.getId()); + } + } + + @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 new file mode 100644 index 00000000000..d1b7df31aff --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/NearbyTransport.java @@ -0,0 +1,779 @@ +/* + * 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.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. +/// +/// 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(IncomingConnection 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 { + + /// 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 = + 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 (NearbyPermission permission : permissions) { + bits |= bitFor(permission); + } + } + int id = NearbyRequests.nextId(); + // 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; + } + + /// 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. 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 + /// + /// - `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() { + synchronized (ACCEPTING) { + ACCEPTING.clear(); + } + 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) { + // 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. + /// + /// #### 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) { + 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); + 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); + } + } + + /// 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() { + @Override + public void run() { + TransportListener[] ls = snapshot(); + for (TransportListener l : ls) { + if (found) { + l.endpointFound(e); + } else { + l.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() { + @Override + public void run() { + IncomingConnection r = + new IncomingConnection(e, authenticationToken); + TransportListener[] ls = snapshot(); + for (TransportListener l : ls) { + l.connectionRequested(r); + } + 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(); + } + } + }); + } + + /// 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() { + @Override + public void run() { + TransportListener[] ls = snapshot(); + for (TransportListener l : ls) { + if (connected) { + l.connected(e); + } else { + l.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() { + @Override + public void run() { + TransportListener[] ls = snapshot(); + for (TransportListener l : ls) { + l.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() { + @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 (TransportListener l : ls) { + l.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() { + @Override + 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 (TransportListener l : ls) { + l.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..3454c8f7dbe --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/Payload.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.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; + } + + @Override + 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..c264dfd698d --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/PayloadTransferUpdate.java @@ -0,0 +1,81 @@ +/* + * 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; + } + + @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 new file mode 100644 index 00000000000..b2219a2ba91 --- /dev/null +++ b/CodenameOne/src/com/codename1/nearby/transport/TransportAdapter.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.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 { + + @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/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java b/CodenameOne/src/com/codename1/nearby/transport/TransportListener.java new file mode 100644 index 00000000000..2a7755925b7 --- /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 [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(IncomingConnection 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/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..1b6a64c8edd 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()){ @@ -13489,6 +13499,29 @@ 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 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()); + } + 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..4749efa04dc --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidNearbyBridge.java @@ -0,0 +1,341 @@ +/* + * 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; + /// 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. + /// + /// #### Parameters + /// + /// - `activity`: the host activity, which the backend needs for the + /// association chooser + public AndroidNearbyBridge(Activity activity) { + Object instance = null; + try { + Class clazz = Class.forName( + "com.codename1.impl.android.nearby.AndroidNearbyBackend"); + 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. + instance = null; + } + // 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; + 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. + } + } + + // ------------------------------------------------------------------ + // 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..0a55304c06a --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyBackend.java @@ -0,0 +1,1493 @@ +/* + * 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.content.pm.PackageManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +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.lang.ref.WeakReference; +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 +/// 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; + + /// 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; + + /// 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); + // 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." + + "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() { + // 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++) { + // 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]); + } + } + + /// The activity the association's result listener is installed on, or + /// null when none is. + /// + /// 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. + /// + /// 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() { + int outstanding = pendingAssociate(); + if (outstanding == 0) { + return; + } + Activity current = currentActivity(); + if (current == null || current == listeningActivity()) { + return; + } + CompanionDeviceManager cdm = manager(); + 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 = outstanding; + releaseAssociate(requestId); + 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 + /// 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.get(); + } + + /// 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 Context contextForBackends() { + return appContext; + } + + private NearbyBridge load(String className) { + Object instance = null; + try { + Class clazz = Class.forName(className); + instance = clazz.getConstructor(Context.class) + .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. + 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; + } + + // ------------------------------------------------------------------ + // 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) { + // 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. + // 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", ctx); + } + boolean transportBits = (permissionBits + & (NearbyBridge.PERMISSION_DISCOVERY + | NearbyBridge.PERMISSION_ADVERTISE + | NearbyBridge.PERMISSION_CONNECT)) != 0; + if (transportBits) { + // 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( + ctx, permissionBits); + for (int i = 0; i < transport.size(); i++) { + add(perms, transport.get(i), ctx); + } + } + 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; + } + // 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, host)); + } + + /// 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, + Context ctx) { + if (Build.VERSION.SDK_INT < 23) { + return; + } + if (ctx.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, final Activity activity) { + return new Runnable() { + @Override + public void run() { + com.codename1.nearby.ranging.Ranging.deliverPermissionResult( + 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); + final List requested = missing; + final Context checkAgainst = activity.getApplicationContext() != null + ? activity.getApplicationContext() : (Context) activity; + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + // 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 { + Thread.sleep(50); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } + }); + // 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 (ctx.checkSelfPermission(perms.get(i)) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + 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; + } + } + return 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 + // ------------------------------------------------------------------ + + /// 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 || appContext == null) { + return null; + } + try { + return (CompanionDeviceManager) appContext.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; + } + // 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"); + return; + } + AssociationRequest.Builder request = new AssociationRequest.Builder(); + request.setSingleDevice(singleDevice); + // 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) { + releaseAssociate(requestId); + 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 + // 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++) { + if (!addFilter(request, filters[i])) { + releaseAssociate(requestId); + 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 + // 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. + 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. + releaseAssociate(requestId); + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.BUSY.ordinal(), + "another activity result is outstanding; try again when" + + " it has finished"); + return; + } + // 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) { + releaseAssociate(requestId); + 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); + } + + @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 + // 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()); + } + }, new Handler(Looper.getMainLooper())); + } + + 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. + Activity host = currentActivity(); + if (host == null) { + releaseAssociate(requestId); + releaseResultListener(); + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.USER_CANCELED.ordinal(), + "the screen went away before the device chooser could" + + " open; associate again"); + return; + } + try { + host.startIntentSenderForResult(chooserLauncher, + ASSOCIATE_REQUEST, null, 0, 0, 0); + } catch (IntentSender.SendIntentException e) { + releaseAssociate(requestId); + // 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() { + listeningOn = null; + Activity current = currentActivity(); + if (current instanceof CodenameOneActivity) { + ((CodenameOneActivity) current).restoreIntentResultListener(); + } + } + + /// 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) { + Activity current = currentActivity(); + if (!(current instanceof CodenameOneActivity)) { + 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) 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) current; + listeningOn = new WeakReference(current); + host.setIntentResultListener(new IntentResultListener() { + public void onActivityResult(int requestCode, int resultCode, + Intent data) { + if (requestCode != ASSOCIATE_REQUEST) { + return; + } + host.restoreIntentResultListener(); + // Dropped here, not only when the next association replaces + // it: the flow this listener belongs to is over. + listeningOn = null; + releaseAssociate(requestId); + if (resultCode != Activity.RESULT_OK) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.USER_CANCELED.ordinal(), + "the user dismissed the chooser"); + return; + } + String encoded = newestAssociation(cdm, data, before); + if (encoded == null) { + CompanionDevices.deliverRequestFailed(requestId, + NearbyError.UNKNOWN.ordinal(), + "the chooser returned no device"); + } else { + CompanionDevices.deliverAssociated(requestId, encoded); + } + } + }); + return true; + } + + /// 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. + /// + /// 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, + 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(); + 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) { + 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(); + 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(); + 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), + 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), + NearbyPresenceStore.isPresent(legacy.get(i)))); + } + } + 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 { + // 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)) { + NearbyPresenceStore.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)); + NearbyPresenceStore.register(associationId); + return true; + } catch (Throwable t) { + return false; + } + } + + /// 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(); + if (cdm == null || associationId == null + || Build.VERSION.SDK_INT < 31) { + return; + } + try { + if (macOf(cdm, associationId) == null) { + if (Build.VERSION.SDK_INT >= 36) { + stopObservingByAssociationId(cdm, associationId); + } + NearbyPresenceStore.unregister(associationId); + return; + } + cdm.stopObservingDevicePresence(addressOf(cdm, associationId)); + NearbyPresenceStore.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 + // ------------------------------------------------------------------ + + /// 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) { + return null; + } + switch (profile) { + case 1: + 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 ? PROFILE_GLASSES : null; + case 3: + 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 + // privileges and shows the user a stronger prompt. + return null; + } + } + + /// 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); + 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) { + // 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) { + try { + request.addDeviceFilter(new WifiDeviceFilter.Builder() + .setNamePattern(Pattern.compile(Pattern.quote(value))) + .build()); + return true; + } catch (Throwable notUsable) { + return false; + } + } + 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; + } + + /// 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(); + } + + /// 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) { + return Integer.toString(info.getId()); + } + + 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, 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, + 0, present); + } + + /// Builds the record `com.codename1.impl.nearby.NearbyWire` decodes. + /// + /// 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, + int profileOrdinal, boolean present) { + return sanitize(id) + '\t' + sanitize(name) + '\t' + sanitize(address) + + '\t' + profileOrdinal + '\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 = macOf(all.get(i)); + 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..74c8539cb86 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java @@ -0,0 +1,1293 @@ +/* + * 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.impl.android.AndroidImplementation; +import com.codename1.ui.Display; + +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.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. +/// +/// 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 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 = + 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()); + /// How many recipients of an outgoing payload have yet to reach a + /// terminal transfer state. + 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 + /// 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 + /// 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()); + /// 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. + /// 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; + + /// 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; + + /// 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. + 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, 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; + } + } + + /// 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 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; + } + } + + /// 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 + /// + /// 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) { + return false; + } + advertisingWanted = false; + boolean orphaned = unownedAdvertising; + unownedAdvertising = false; + return orphaned; + } + if (generation != discoverGeneration) { + return false; + } + discoveringWanted = false; + boolean orphaned = unownedDiscovering; + unownedDiscovering = false; + return orphaned; + } + } + + /// Ends the operation, invalidating any start still in flight. + private void endStart(boolean advertising) { + synchronized (transportLock) { + if (advertising) { + advertiseGeneration++; + advertisingWanted = false; + unownedAdvertising = false; + return; + } + discoverGeneration++; + discoveringWanted = false; + unownedDiscovering = false; + } + } + + private String advertisingServiceId = ""; + private String discoveryServiceId = ""; + 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() { + // 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(); + } + + 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) { + // 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. + /// + /// 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); + } + } + + /// 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); + } + }; + } + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + public void startAdvertising(final int requestId, String serviceId, + String localName, int strategy) { + // Captured for THIS callback, for the reason startDiscovery does it. + 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 = beginStart(true); + AdvertisingOptions options = new AdvertisingOptions.Builder() + .setStrategy(strategyFor(strategy)) + .build(); + client().startAdvertising(this.localName, started, + connectionCallback(started), options) + .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, + // because the stop that ran before this had + // 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 (state == START_ORPHANED) { + client().stopAdvertising(); + } + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "advertising was stopped before it" + + " started"); + return; + } + NearbyTransport.deliverRequestOk(requestId); + } + }) + .addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + // 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. + // + // 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. + failAndCleanUp(true, generation); + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_FAILED.ordinal(), + e.getMessage()); + } + }); + } + + public void stopAdvertising() { + endStart(true); + client().stopAdvertising(); + } + + public void startDiscovery(final int requestId, String serviceId, + int strategy) { + // 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; + // The generation this start belongs to, for the reason + // startAdvertising keeps one. + 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) { + 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. + if (state == START_ORPHANED) { + client().stopDiscovery(); + } + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_INVALIDATED.ordinal(), + "discovery was stopped before it" + + " started"); + return; + } + NearbyTransport.deliverRequestOk(requestId); + } + }) + .addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + // Cleared, and the radio stopped if it was left + // running for nobody, for the reasons the advertising + // failure gives. + failAndCleanUp(false, generation); + NearbyTransport.deliverRequestFailed(requestId, + NearbyError.SESSION_FAILED.ordinal(), + e.getMessage()); + } + }); + } + + public void stopDiscovery() { + endStart(false); + client().stopDiscovery(); + } + + public void requestConnection(final int requestId, String endpointId, + String localName) { + String name = localName == null || localName.length() == 0 + ? this.localName : localName; + // Connecting OUT, so the endpoint belongs to whatever discovery + // 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(discovered != null ? discovered + : discoveryServiceId)) + .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); + } + // 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 + // 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, + 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)); + 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) { + NearbyTransport.deliverRequestOk(requestId); + } + }) + .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()); + } + }); + } + + public void cancelPayload(int payloadId) { + List doomed = new ArrayList(); + synchronized (payloadIds) { + for (Map.Entry e : payloadIds.entrySet()) { + if (e.getValue().intValue() == payloadId) { + // 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) { + // 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); + } + + 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. + // 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. + endStart(true); + endStart(false); + client().stopAdvertising(); + client().stopDiscovery(); + client().stopAllEndpoints(); + // 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); + connectionServices.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 + // 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. + } + + // ------------------------------------------------------------------ + // Callbacks + // ------------------------------------------------------------------ + + private EndpointDiscoveryCallback discoveryCallback( + final String serviceId) { + return new EndpointDiscoveryCallback() { + @Override + public void onEndpointFound(String endpointId, + DiscoveredEndpointInfo info) { + discoveredEndpoints.add(endpointId); + endpointNames.put(endpointId, info.getEndpointName()); + endpointServices.put(endpointId, serviceId); + NearbyTransport.deliverEndpointFound( + 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( + encodeDiscovered(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 + // 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); + connectionServices.remove(endpointId); + } + }; + } + + private ConnectionLifecycleCallback connectionCallback( + final String serviceId) { + return new ConnectionLifecycleCallback() { + @Override + public void onConnectionInitiated(String endpointId, + ConnectionInfo info) { + endpointNames.put(endpointId, info.getEndpointName()); + // 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. + 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( + encode(endpointId, info.getEndpointName()), + info.getAuthenticationDigits()); + } + + @Override + public void onConnectionResult(String endpointId, + ConnectionResolution resolution) { + boolean ok = resolution.getStatus().getStatusCode() + == ConnectionsStatusCodes.STATUS_OK; + if (ok) { + connectedEndpoints.add(endpointId); + } else { + connectedEndpoints.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); + connectionServices.remove(endpointId); + } + } + + @Override + 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 + // 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); + } + } + }; + } + + private PayloadCallback payloadCallback() { + return new 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. 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; + } + // 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); + return; + } + 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); + // 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))); + } + } + + @Override + public void onPayloadTransferUpdate(String endpointId, + PayloadTransferUpdate update) { + Long key = Long.valueOf(update.getPayloadId()); + Integer mapped = payloadIds.get(key); + int id = mapped == null ? (int) update.getPayloadId() + : mapped.intValue(); + // 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; + } + // 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 + // 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; + } + // 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(); + } + }; + } + + // ------------------------------------------------------------------ + // 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; + } + + /// 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); + } + + /// 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-"; + + /// 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) { + // 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 { + 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; + } + 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 ""; + } + 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..c08b73c6769 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidUwbRanging.java @@ -0,0 +1,815 @@ +/* + * 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; + + 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; + // 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(); + } + } + + // ------------------------------------------------------------------ + // 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() { + 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(); + } + // 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(); + } + + 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; + // The scope that carries the answer is opened by a background probe, + // 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; + synchronized (capabilityLock) { + probed = probedCapabilities; + } + if (probed > 0) { + bits |= probed; + } + // 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; + } + + 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) { + // 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); + } + + // ------------------------------------------------------------------ + // 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; + } + // 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 { + final Session session = new Session(sessionHandle, controller); + UwbManager uwb = managerOrThrow(); + if (controller) { + 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(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)); + } + } + + 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) { + // NOT_SUPPORTED, which is what RangingSession.startAccessory and the + // developer guide both document Android answering. + // + // 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, + 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); + // 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); + 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>() { + public void accept(RangingResult result) { + settleStarted(session); + deliver(session.handle, result); + } + }, new io.reactivex.rxjava3.functions.Consumer< + Throwable>() { + public void accept(Throwable 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. + // + // 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)); + return; + } + RangingSession.deliverInvalidated( + session.handle, + NearbyError.SESSION_INVALIDATED.ordinal(), + message(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.startRequest.set(0); + fail(requestId, NearbyError.SESSION_INVALIDATED, + "the session was stopped before ranging started"); + return; + } + } catch (Throwable t) { + session.startRequest.set(0); + 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) { + 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); + } + + /// 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, + RangingRemovalReason.TIMEOUT.ordinal()); + return; + } + if (!(result instanceof RangingResult.RangingResultPosition)) { + return; + } + 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(); + 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) { + 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(); + } + } + + // ------------------------------------------------------------------ + // 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; + /// 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. + private final java.util.concurrent.atomic.AtomicInteger startRequest = + new java.util.concurrent.atomic.AtomicInteger(); + + 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, "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, "address length"); + p += 4; + 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, "channel"); + p += 4; + peer.preamble = readInt(framed, p, "preamble index"); + p += 4; + peer.sessionId = readInt(framed, p, "session id"); + p += 4; + int keyLength = readInt(framed, p, "session key length"); + p += 4; + if (keyLength < 0 || keyLength > framed.length - p) { + 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; + } + + /// 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 new file mode 100644 index 00000000000..c8dab72e179 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/CN1CompanionDeviceService.java @@ -0,0 +1,191 @@ +/* + * 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.impl.android.AndroidImplementation; +import com.codename1.ui.Display; + + +/// 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 platform may start the process for THIS service alone, with no activity +/// 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 +/// `CompanionDeviceService` intent filter, only for an app that observes +/// presence. +@SuppressLint("NewApi") +public class CN1CompanionDeviceService extends CompanionDeviceService { + + /// Whether this service is the one that started the Codename One context. + private boolean startedContext; + + @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); + } + + @Override + 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 (NearbyPresenceStore.isUnobserved(address)) { + return; + } + String encoded = sanitize(address) + '\t' + sanitize(address) + '\t' + + sanitize(address) + "\t0\t" + (present ? '1' : '0'); + NearbyPresenceStore.record(this, address, encoded, present); + } + + private void deliver(AssociationInfo info, boolean present) { + if (info == null || Build.VERSION.SDK_INT < 31) { + return; + } + android.net.MacAddress address = info.getDeviceMacAddress(); + String mac = address == null ? null : address.toString(); + // 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 (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 + // simply has not re-registered yet still is. + 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) + '\t' + + AndroidNearbyBackend.profileOrdinalOf(info) + '\t' + + (present ? '1' : '0'); + NearbyPresenceStore.record(this, id, encoded, present); + } + + private static String sanitize(String s) { + if (s == null) { + return ""; + } + return s.replace('\t', ' ').replace('\n', ' ').replace('\r', ' '); + } +} 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..b1042ac37bb --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPermissions.java @@ -0,0 +1,155 @@ +/* + * 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"); + } + } + // 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 { + // 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; + } + + /// 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/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..062f5d41f16 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/nearby/NearbyPresenceStore.java @@ -0,0 +1,338 @@ +/* + * 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; + + /// 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(); + + /// 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); + } + + /// 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); + return known != null && known.booleanValue(); + } + + /// 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 associationId, String encoded, + boolean present) { + if (associationId != null) { + PRESENT.put(associationId, Boolean.valueOf(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 + // 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; + } + 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'); + } + 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; + // 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]; + } + 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/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/Ports/iOSPort/nativeSources/CN1Nearby.h b/Ports/iOSPort/nativeSources/CN1Nearby.h new file mode 100644 index 00000000000..cda79bc5b1c --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Nearby.h @@ -0,0 +1,117 @@ +/* + * 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. +/// 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 + +#endif diff --git a/Ports/iOSPort/nativeSources/CN1Nearby.m b/Ports/iOSPort/nativeSources/CN1Nearby.m new file mode 100644 index 00000000000..6024efa5b4f --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Nearby.m @@ -0,0 +1,3624 @@ +/* + * 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"]; +} + +/// 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)); +} + +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)); +} + +/// 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); +} + +/// 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 +// ===================================================================== + +#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; +- (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]; + } +} + +@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); +} + +/// 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]; + } + } +} + +- (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])); + cn1nbSessionsLockInit(); + @synchronized (cn1nbSessionsLock) { + [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 + +/// 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]] + retain] autorelease]; + } +} + +/// 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 + +// ===================================================================== +// Transport -- MultipeerConnectivity +// ===================================================================== + +#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; +/// 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; +@property (nonatomic, retain) NSMutableDictionary *invitations; +@property (nonatomic, retain) NSMutableDictionary *progressByPayload; +@property (nonatomic, retain) NSMutableSet *everConnected; +/// Peers invited and not yet answered. +/// +/// Counted toward the strategy limit alongside the connected ones: two +/// requestConnection calls made before either peer answers both saw a +/// connected count of zero, so both invitations went out and the discoverer +/// ended up holding two sessions under STAR or POINT_TO_POINT with neither +/// call ever told BUSY. +@property (nonatomic, retain) NSMutableSet *inviting; +/// Peers the browser lost while they were still connected. +/// +/// forgetPeer refuses to drop a connected peer's mappings, because a send to +/// it must still resolve -- so a peer lost mid-session was never forgotten at +/// all: the disconnect that followed cleared everConnected and nothing +/// retried. Remembered here and dropped when the session ends. +@property (nonatomic, retain) NSMutableSet *lostWhileConnected; +/// Peer id to the payload ids sent to it and not yet acknowledged. +/// +/// sendData succeeding means the bytes were QUEUED, not that they arrived -- +/// and PayloadStatus.SUCCESS documents that every byte arrived, which is what +/// Android reports because Nearby tells it so. MultipeerConnectivity has no +/// such signal, so the receiving end sends one back and the terminal status +/// waits for it. +@property (nonatomic, retain) NSMutableDictionary *awaitingAck; +/// Names each recorded send, so its timeout settles that send and no other. +@property (nonatomic, assign) JAVA_LONG ackToken; +@property (nonatomic, assign) int pendingAdvertiseRequest; +@property (nonatomic, assign) int pendingDiscoverRequest; +/// The advertising and discovery services are tracked SEPARATELY. +/// +/// One shared pair of fields cannot describe this object honestly: an app may +/// advertise "files" while browsing "chat", and whichever call ran last then +/// decided what both of them reported. Peers found by the browser belong to +/// the discovery service and peers that invite us belong to the advertising +/// one, so each is recorded against the peer that produced it (see +/// serviceIdByPeer) rather than read back out of a single mutable field. +/// +/// The unfolded id is what the CALLER passed. Endpoint.getServiceId() +/// documents the id the app used, and reporting the folded Bonjour type +/// instead ("com-example-cha" for "com.example.chat") made a comparison +/// against the argument to startDiscovery fail on iOS alone. +@property (nonatomic, retain) NSString *advertiseServiceType; +@property (nonatomic, retain) NSString *advertiseServiceId; +@property (nonatomic, retain) NSString *discoverServiceType; +@property (nonatomic, retain) NSString *discoverServiceId; +/// Peer id to the unfolded service id the peer was seen on. +@property (nonatomic, retain) NSMutableDictionary *serviceIdByPeer; +/// The service each CONNECTION was negotiated through. +/// +/// Separate from serviceIdByPeer, which is what discovery saw. One map could +/// not be both: browsing A while advertising B, a peer that connects through +/// B and is then found or lost by the browser had its entry rewritten to A, +/// so every payload and disconnection on that live B connection was labelled +/// with a service it had nothing to do with. +@property (nonatomic, retain) NSMutableDictionary *connectionServiceByPeer; +/// The topology each half was started with. +/// +/// MultipeerConnectivity enforces no topology of its own -- it will happily +/// connect a peer that asked for POINT_TO_POINT to a second one -- so the +/// strategy the caller passed has to be honoured here or not at all. Ignoring +/// it made TransportStrategy a documented promise the iOS port did not keep. +@property (nonatomic, assign) int advertiseStrategy; +@property (nonatomic, assign) int discoverStrategy; +/// Counts received files, so two with the same name get different paths. +/// Read and written under @synchronized(self): MultipeerConnectivity +/// delivers from a queue per session, so two arrivals really can race. +@property (nonatomic, assign) int receiveSequence; +@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. +/// The Bonjour service types the Info.plist declared, without their framing. +/// +/// iOS 14 and later refuse to browse a service type the app did not declare in +/// NSBonjourServices, and the refusal is a silent "no peers found" rather than +/// an error. So the plist is the authority here: whatever the app passes as a +/// service id is folded and then CHECKED against this list, and a type that is +/// not on it fails the call with a message naming the build hint to add it to. +/// Guessing instead -- registering the folded id and hoping -- is what produces +/// a transport that never works with nothing in any log to explain it. +static NSArray *cn1nbDeclaredServiceTypes(void) { + NSArray *declared = [[NSBundle mainBundle] + objectForInfoDictionaryKey:@"NSBonjourServices"]; + if (![declared isKindOfClass:[NSArray class]]) { + return [NSArray array]; + } + NSMutableArray *out = [NSMutableArray array]; + for (id entry in declared) { + if (![entry isKindOfClass:[NSString class]]) { + continue; + } + // "_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]; + } + 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; +} + +/// Cuts a display name down to the 63 UTF-8 bytes MCPeerID accepts. +/// +/// By BYTES and on a character boundary, not by taking a fixed number of +/// UTF-16 units: twenty emoji are about eighty bytes, so the old cut left an +/// over-long name that MCPeerID RAISES on -- a crash rather than an error -- +/// and cutting mid-unit could split a surrogate pair into something that is +/// not a string at all. +static NSString *cn1nbPeerName(NSString *name) { + if (name == nil || [name length] == 0) { + return @"Codename One"; + } + if ([name lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= 63) { + return name; + } + NSUInteger end = [name length]; + while (end > 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"; +} + +/// Four base-36 characters derived from the whole service id. +/// +/// FNV-1a over the id's UTF-8 bytes. Must stay identical to +/// `IPhoneBuilder.bonjourSuffix`: the type this device registers has to be the +/// type the build declared in the Info.plist, or iOS drops the traffic. +static NSString *cn1nbBonjourSuffix(NSString *serviceId) { + uint32_t hash = 0x811c9dc5u; + const char *bytes = [serviceId UTF8String]; + if (bytes != NULL) { + for (const unsigned char *p = (const unsigned char *)bytes; + *p != '\0'; p++) { + unsigned int b = (unsigned int)*p; + // ASCII-lowercased before hashing, so the suffix is as + // case-insensitive as the fold -- "Chat" and "chat" are one + // service. The builder does exactly this to exactly these bytes. + if (b >= '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]; + 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)]; + } + // 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 and inside the fifteen Apple allows. + if ([out length] > 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 + // fail, so the app crashed instead of failing to advertise. Prefixed + // rather than rejected, and the builder folds identically so the type + // this registers is the one the Info.plist declares. + BOOL hasLetter = NO; + for (NSUInteger i = 0; i < [out length]; i++) { + unichar c = [out characterAtIndex:i]; + if (c >= '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; +} + +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]; + [_sessionsById release]; + [_advertiser release]; + [_browser release]; + [_peersById release]; + [_invitations release]; + [_progressByPayload release]; + [_everConnected release]; + [_inviting release]; + [_lostWhileConnected release]; + [_awaitingAck release]; + [_advertiseServiceType release]; + [_advertiseServiceId release]; + [_discoverServiceType release]; + [_discoverServiceId release]; + [_serviceIdByPeer release]; + [_connectionServiceByPeer release]; + [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; + } + @synchronized (self) { + 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 { + // Retained across the removal. The dictionary is ordinarily the only + // 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; + @synchronized (self) { + session = [[[self.sessionsById objectForKey:endpointId] retain] + autorelease]; + if (session == nil) { + return; + } + [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]; + // 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. + 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 + // 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) { + 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 (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 objectAtIndex:0] intValue], 0, + (JAVA_LONG)[[stranded objectAtIndex:1] longLongValue], + CN1_NEARBY_PAYLOAD_FAILURE); + } + 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")); + } +} + +/// Records one recipient's transfer so cancel can reach it. +/// +/// #### Parameters +/// +/// - `progress`: the transfer +/// - `payloadId`: the payload every recipient of this send shares +- (void)rememberProgress:(NSProgress *)progress forPayload:(JAVA_INT)payloadId { + NSNumber *key = [NSNumber numberWithInt:(int)payloadId]; + @synchronized (self) { + NSMutableArray *all = [self.progressByPayload objectForKey:key]; + if (all == nil) { + all = [NSMutableArray array]; + [self.progressByPayload setObject:all forKey:key]; + } + [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]; + @synchronized (self) { + NSMutableArray *all = [self.progressByPayload objectForKey:key]; + if (all == nil) { + return; + } + [all removeObjectsInArray:holder]; + if ([all count] == 0) { + [self.progressByPayload removeObjectForKey:key]; + } + } +} + +/// 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 { + NSArray *pending; + @synchronized (self) { + pending = [[[self.invitations allValues] copy] autorelease]; + [self.invitations removeAllObjects]; + } + // REJECTED, not just dropped. 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 the transport means for an invitation nobody + // is going to look at. + // + // Answered outside the lock: the handler runs framework code, and + // holding the transport's monitor across it invites a deadlock against + // the delegate queue. + for (id handler in pending) { + ((void (^)(BOOL, MCSession *))handler)(NO, nil); + } +} + +/// 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]; + } +} + +/// How many peers are connected or have an invitation outstanding. +- (NSUInteger)heldPeerCount { + NSUInteger pending; + @synchronized (self) { + pending = [self.inviting count]; + } + return [self connectedPeerCount] + pending; +} + +/// Sends the one-frame acknowledgement for a received payload. +/// +/// 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]; + frame[0] = CN1_NEARBY_FRAME_ACK; + frame[1] = (unsigned char)((payloadId >> 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]; +} + +/// 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 + token:(JAVA_LONG)token encoded:(NSString *)encoded { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)CN1_NEARBY_ACK_TIMEOUT_NS), + dispatch_get_main_queue(), ^{ + @autoreleasepool { + JAVA_LONG length = -1; + // ITS send, named by token. Keyed by peer and payload alone this + // timer took whatever was outstanding, so a repeat send of the + // same Payload to the same peer was failed by the previous + // send's timer, well inside its own thirty seconds. + if (![self takeAck:payloadId fromPeer:pid token:token + length:&length]) { + return; + } + com_codename1_impl_ios_IOSNearbyCallbacks_payloadProgress___java_lang_String_int_long_long_int( + getThreadLocalData(), cn1nbJString(encoded), payloadId, + 0, length, CN1_NEARBY_PAYLOAD_FAILURE); + } + }); +} + +/// Records a payload sent to a peer and waiting for its acknowledgement. +/// +/// 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. +/// #### Returns +/// +/// the token identifying THIS send, which its timeout settles by +- (JAVA_LONG)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]; + } + // 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]; + NSMutableArray *outstanding = [ids objectForKey:key]; + if (outstanding == nil) { + outstanding = [NSMutableArray array]; + [ids setObject:outstanding forKey:key]; + } + // Each entry carries a token as well as its length. Peer and + // payload id alone did not identify a SEND: sending the same Payload + // to the same peer again within the timeout window let the earlier + // send's timer take the newer send's record and fail it early, and + // the acknowledgement that then arrived for it was dropped as + // unknown. + JAVA_LONG token = ++self->_ackToken; + [outstanding addObject:[NSArray arrayWithObjects: + [NSNumber numberWithLongLong:(long long)token], + [NSNumber numberWithLongLong:(long long)length], nil]]; + return token; + } +} + +/// Takes the acknowledgement for one payload, answering whether it was +/// 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 { + // 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]; + NSMutableArray *outstanding = [ids objectForKey:key]; + if (outstanding == nil || [outstanding count] == 0) { + return NO; + } + // 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++) { + 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 objectAtIndex:at] + objectAtIndex:1] longLongValue]; + } + [outstanding removeObjectAtIndex:at]; + if ([outstanding count] == 0) { + [ids removeObjectForKey:key]; + } + if ([ids count] == 0) { + [self.awaitingAck removeObjectForKey:pid]; + } + return YES; + } +} + +/// 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 (NSArray *entry in outstanding) { + [out addObject:[NSArray arrayWithObjects:pid, + [entry objectAtIndex:1], 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) { + 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. + // 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 (NSArray *entry in [ids objectForKey:key]) { + [out addObject:[NSArray arrayWithObjects:key, + [entry objectAtIndex:1], nil]]; + } + } + [self.awaitingAck removeObjectForKey:pid]; + return out; + } +} + +/// Records an invitation this device is about to send. +- (void)markInviting:(NSString *)pid { + @synchronized (self) { + [self.inviting addObject: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; + } +} + +/// 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) { + return; + } + if ([self isEverConnected:pid]) { + // Still connected, so the mappings have to stay -- but the loss is + // remembered, because nothing else would ever come back for it. + @synchronized (self) { + [self.lostWhileConnected addObject:pid]; + } + return; + } + @synchronized (self) { + [self.peersById removeObjectForKey:pid]; + [self.serviceIdByPeer removeObjectForKey:pid]; + [self.connectionServiceByPeer removeObjectForKey:pid]; + [self.lostWhileConnected removeObject:pid]; + } +} + +/// Forgets every peer, for a full stop. +- (void)forgetAllPeers { + @synchronized (self) { + [self.peersById removeAllObjects]; + [self.serviceIdByPeer removeAllObjects]; + [self.connectionServiceByPeer removeAllObjects]; + [self.everConnected removeAllObjects]; + [self.inviting removeAllObjects]; + [self.lostWhileConnected removeAllObjects]; + [self.awaitingAck removeAllObjects]; + } +} + +/// 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; + NSArray *sessions; + @synchronized (self) { + sessions = [[[self.sessionsById allValues] copy] autorelease]; + } + for (MCSession *session in sessions) { + n += [session.connectedPeers count]; + } + return n; +} + +/// Drops every session. +- (void)closeAllSessions { + NSArray *keys; + @synchronized (self) { + keys = [[[self.sessionsById allKeys] copy] autorelease]; + } + for (NSString *key in keys) { + [self closeSessionFor:key]; + } +} + +/// Encodes a peer DISCOVERY saw, remembering the service it was seen on. +/// +/// Always answers with that service, so a found and its matching lost name +/// the same one even where a connection through another service came and +/// went in between. +- (NSString *)encodePeer:(MCPeerID *)peer service:(NSString *)serviceId { + NSString *pid = cn1nbIdForPeer(peer); + NSString *service; + @synchronized (self) { + if (serviceId != nil) { + [self.serviceIdByPeer setObject:serviceId forKey:pid]; + } + [self.peersById setObject:peer forKey:pid]; + service = [self.serviceIdByPeer objectForKey:pid]; + if (service == nil) { + service = self.discoverServiceId; + } + } + return cn1nbJoin([NSArray arrayWithObjects:pid, + peer.displayName == nil ? @"" : peer.displayName, + 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 { + if (serviceId == nil || pid == nil) { + return; + } + @synchronized (self) { + [self.connectionServiceByPeer setObject:serviceId forKey:pid]; + } +} + +- (NSString *)encodePeer:(MCPeerID *)peer { + NSString *pid = cn1nbIdForPeer(peer); + 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. + // The connection's own service wins where there is one: that is + // what this peer is connected THROUGH, whatever the browser has + // seen it under since. + service = [self.connectionServiceByPeer objectForKey:pid]; + if (service == nil) { + 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, + service == nil ? @"" : service, nil]); +} + +- (MCPeerID *)peerForId:(NSString *)pid { + if (pid == nil) { + return nil; + } + @synchronized (self) { + return [self.peersById objectForKey:pid]; + } +} + +/// MultipeerConnectivity gives no comparison token, and this does not invent +/// one. +/// +/// Nearby Connections derives its authentication digits from the key exchange, +/// which is what makes comparing them on both screens detect a device in the +/// middle. MultipeerConnectivity exposes no equivalent: with +/// `securityIdentity:nil` the peers use ephemeral keys and +/// `session:didReceiveCertificate:` hands over nothing to bind a token to. +/// +/// An earlier version hashed the two display names and the service type. That +/// is public information a relay observes and can reproduce on both of its +/// sessions, so it would have shown matching digits at both ends while +/// relaying -- a check that looks like a defence and is not, which is worse +/// than no check at all. So iOS reports an empty token, which +/// `IncomingConnection.getAuthenticationToken()` documents as "the platform +/// does not produce one", and the guide tells iOS apps to verify identity +/// their own way. +- (NSString *)tokenForPeer:(MCPeerID *)peer { + return @""; +} + +// ---- MCSessionDelegate ---------------------------------------------- + +- (void)session:(MCSession *)session peer:(MCPeerID *)peerID + didChangeState:(MCSessionState)state { + @autoreleasepool { + if (state == MCSessionStateConnecting) { + return; + } + NSString *pid = cn1nbIdForPeer(peerID); + NSString *encoded = [self encodePeer:peerID]; + // Answered, so the reservation is released whichever way it went. + [self clearInviting:pid]; + if (state == MCSessionStateConnected) { + [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); + return; + } + // NotConnected covers two different events, and reporting both as a + // disconnection left an app that was INVITING a peer waiting forever + // 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. + BOOL lostWhileUp; + @synchronized (self) { + 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 (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, + (JAVA_LONG)[[stranded objectAtIndex:1] longLongValue], + CN1_NEARBY_PAYLOAD_FAILURE); + } + if (lostWhileUp) { + // The browser lost it before the session ended, so this is + // the moment its mappings can finally go. + [self forgetPeer:pid]; + } + com_codename1_impl_ios_IOSNearbyCallbacks_disconnected___java_lang_String( + getThreadLocalData(), cn1nbJString(encoded)); + return; + } + com_codename1_impl_ios_IOSNearbyCallbacks_connectionResult___java_lang_String_boolean_int_java_lang_String( + getThreadLocalData(), cn1nbJString(encoded), JAVA_FALSE, + CN1_NEARBY_ERR_PEER_UNAVAILABLE, + cn1nbJString(@"the peer declined the invitation or it timed" + @" out")); + } +} + +- (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. + // 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. + 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, + length, length, 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 + // 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); + } +} + +- (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]; + // 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), + 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 + // 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. + // 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:@"."] + || [safe isEqualToString:@".."] + || [safe rangeOfString:@"/"].location != NSNotFound) { + safe = @"payload"; + } + NSString *docs = [NSSearchPathForDirectoriesInDomains( + NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]; + // Unique per transfer, not per name. Built from the basename alone, + // a second "photo.jpg" from any peer overwrote the first -- and the + // first Payload had already been handed to the app as an immutable + // path, so its contents changed under it. The payload id is not + // enough on its own either: two peers can each send their own id 1. + int received; + @synchronized (self) { + received = ++self->_receiveSequence; + } + NSString *target = [docs stringByAppendingPathComponent: + [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), + filePayloadId, 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); + return; + } + [[NSFileManager defaultManager] removeItemAtPath:target error:nil]; + NSError *moveError = nil; + [[NSFileManager defaultManager] moveItemAtPath:[localURL path] + 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), + filePayloadId, 0, -1, CN1_NEARBY_PAYLOAD_FAILURE); + return; + } + // The terminal SUCCESS update, which only the failure paths above + // used to emit. A receiver that dismisses its transfer UI or releases + // 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, + 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, + 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 { + 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); + // The ADVERTISED service, recorded as this connection's, not as + // something discovery saw: the peer answered this advertisement. + [self noteConnectionService:self.advertiseServiceId forPeer:pid]; + 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 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])); + } +} + +- (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. + // Dropping the error left the caller believing it was advertising + // when it was not, with no second signal ever coming. The request id + // is kept precisely so this can fail it late. + int requestId = self.pendingAdvertiseRequest; + self.pendingAdvertiseRequest = 0; + 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; + } +} + +// ---- MCNearbyServiceBrowserDelegate ---------------------------------- + +- (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( + getThreadLocalData(), cn1nbJString(encoded), JAVA_TRUE); + } +} + +- (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( + getThreadLocalData(), cn1nbJString(encoded), JAVA_FALSE); + // Forgotten after the event is delivered, because encoding it needs + // the mappings. + [self forgetPeer:cn1nbIdForPeer(peerID)]; + } +} + +- (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; + self.pendingDiscoverRequest = 0; + 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; + } +} + +@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]; +} + +/// 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 = 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 heldPeerCount] == 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 closeAllSessions]; + t.localPeer = [[[MCPeerID alloc] initWithDisplayName:wanted] + autorelease]; + if (wasAdvertising) { + t.advertiser = [[[MCNearbyServiceAdvertiser alloc] + initWithPeer:t.localPeer + discoveryInfo:nil + serviceType:t.advertiseServiceType] autorelease]; + t.advertiser.delegate = t; + [t.advertiser startAdvertisingPeer]; + } + if (wasBrowsing) { + t.browser = [[[MCNearbyServiceBrowser alloc] + initWithPeer:t.localPeer + serviceType:t.discoverServiceType] 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 = cn1nbPeerName(name); + } + t.localPeer = [[[MCPeerID alloc] initWithDisplayName:name] + autorelease]; + } +} + +static CN1NearbyTransport *cn1nbTransportInit(NSString *serviceId, + NSString *localName, BOOL advertising) { + if (cn1nbTransport == nil) { + cn1nbTransport = [[CN1NearbyTransport alloc] init]; + cn1nbTransport.peersById = [NSMutableDictionary dictionary]; + cn1nbTransport.invitations = [NSMutableDictionary dictionary]; + cn1nbTransport.progressByPayload = [NSMutableDictionary dictionary]; + cn1nbTransport.everConnected = [NSMutableSet set]; + cn1nbTransport.inviting = [NSMutableSet set]; + cn1nbTransport.lostWhileConnected = [NSMutableSet set]; + cn1nbTransport.awaitingAck = [NSMutableDictionary dictionary]; + cn1nbTransport.sessionsById = [NSMutableDictionary dictionary]; + cn1nbTransport.serviceIdByPeer = [NSMutableDictionary dictionary]; + cn1nbTransport.connectionServiceByPeer = + [NSMutableDictionary dictionary]; + } + if (serviceId != nil) { + // 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; +} + +/// 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 +/// 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, + (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 + +// ===================================================================== +// 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; +/// 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; +/// 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 + +// 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]; + [_activationWaiters 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. +/// +/// 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; + } + 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 +/// 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. +/// The association id for an accessory, stable across process launches. +/// +/// NOT the object's hash. An accessory offered through the SSID filter has no +/// bluetoothIdentifier, and the hash that stood in for it was a different +/// number every launch -- so an id the public API documents as persistable +/// could not be persisted, and accessoryForId, which only ever compared +/// Bluetooth UUIDs, could not find it even within one launch. +/// +/// One function so the two cannot drift: whatever this returns is what +/// accessoryForId matches on. +static NSString *cn1nbAccessoryId(ASAccessory *accessory) + API_AVAILABLE(ios(18.0)) { + if (accessory.bluetoothIdentifier != nil) { + return [accessory.bluetoothIdentifier UUIDString]; + } + if (accessory.SSID != nil && [accessory.SSID length] > 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: + cn1nbAccessoryId(accessory), + accessory.displayName == nil ? @"" : accessory.displayName, + identifier, + @"0", + present ? @"1" : @"0", + nil]); +} + +- (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]) { + if (match != nil) { + return nil; + } + match = a; + } + } + 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; + } + void (^queued)(BOOL) = [[handler copy] autorelease]; + @synchronized (self) { + if (self.activationWaiters == nil) { + self.activationWaiters = [NSMutableArray array]; + } + [self.activationWaiters addObject:queued]; + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(2ull * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + @autoreleasepool { + // 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); + } + } + }); +} + +/// 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; + } + self.activated = YES; + self.activeSignal = dispatch_semaphore_create(0); + self.session = [[[ASAccessorySession alloc] init] autorelease]; + 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 + // 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) { + 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]; + } + 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 -- 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; +} + +#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 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; +} + +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; + } + @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)); + 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 = requestId; + [entry.session runWithConfiguration:config]; + cn1nbSettleRangingStart(entry); + 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) { + // 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. + @synchronized (cn1nbSessionsLock) { + [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]; + 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 = + [[[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. 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] + initWithName:value + productImage:[[[UIImage alloc] init] autorelease] + 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 + // builds exactly that for associate(null) -- so failing here + // rejected the API's own default request. AccessorySetupKit + // cannot discover anything it was not told about ahead of + // time, but the app HAS told it: NSAccessorySetupBluetoothServices + // in the Info.plist is the complete set it is allowed to see, + // which is as close to "everything visible" as this platform + // has. + NSArray *declared = [[NSBundle mainBundle] + objectForInfoDictionaryKey: + @"NSAccessorySetupBluetoothServices"]; + if ([declared isKindOfClass:[NSArray class]]) { + for (id entry in declared) { + if (![entry isKindOfClass:[NSString class]]) { + continue; + } + ASDiscoveryDescriptor *d = + [[[ASDiscoveryDescriptor alloc] init] + autorelease]; + d.supportedOptions = + ASAccessorySupportBluetoothPairingLE; + @try { + d.bluetoothServiceUUID = + [CBUUID UUIDWithString:(NSString *)entry]; + } @catch (NSException *bad) { + continue; + } + [items addObject:[[[ASPickerDisplayItem alloc] + initWithName:(NSString *)entry + productImage:[[[UIImage alloc] init] + autorelease] + descriptor:d] autorelease]]; + } + } + } + if ([items count] == 0) { + cn1nbFailCompanion(requestId, CN1_NEARBY_ERR_NOT_SUPPORTED, + @"AccessorySetupKit shows only accessories declared up" + @" front: pass a DeviceFilter, or set the" + @" ios.nearby.accessoryServices build hint"); + return; + } + // 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; + } + } + 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; + } + } +#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(); + 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]]; + } + 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 { + // 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); + // 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; + } + } +#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. 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 +} + +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); + 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, 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; + } + t.advertiser = [[[MCNearbyServiceAdvertiser alloc] + initWithPeer:t.localPeer + discoveryInfo:nil + 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. + 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. + // 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) { + cn1nbFailTransport(superseded, + CN1_NEARBY_ERR_SESSION_INVALIDATED, + @"another advertising start replaced this one"); + } + [t.advertiser startAdvertisingPeer]; + cn1nbSettleTransportStart(t, YES, 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; + } + // 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 +} + +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); + if (!cn1nbServiceTypeIsDeclared(sid)) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_SESSION_FAILED, + cn1nbUndeclaredServiceMessage(sid)); + return; + } + 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; + } + t.browser = [[[MCNearbyServiceBrowser alloc] + initWithPeer:t.localPeer + serviceType:t.discoverServiceType] autorelease]; + t.browser.delegate = t; + t.discoverStrategy = (int)strategy; + // Failed for the reason the advertising path is. + int superseded = t.pendingDiscoverRequest; + t.pendingDiscoverRequest = requestId; + if (superseded != 0 && superseded != requestId) { + cn1nbFailTransport(superseded, + CN1_NEARBY_ERR_SESSION_INVALIDATED, + @"another discovery start replaced this one"); + } + [t.browser startBrowsingForPeers]; + cn1nbSettleTransportStart(t, NO, 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; + } + // Settled on the way out, for the reason stopAdvertising is. + cn1nbCancelPendingStart(cn1nbTransport, NO); + } +#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; + } + // 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 heldPeerCount] > 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, + 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; + } + // Connecting OUT, so this connection belongs to whatever discovery + // found the PEER under -- recorded as the connection's service, so a + // later sighting under another one cannot relabel it. + [cn1nbTransport noteOutboundConnectionServiceForPeer:pid]; + // 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 + 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); + // 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 takeInvitationForPeer:pid]; + if (handler == nil) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"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 heldPeerCount] > 0) { + handler(NO, nil); + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_BUSY, + @"POINT_TO_POINT allows one connection at a time;" + @" disconnect the current peer first"); + return; + } + // Reserved on ACCEPT, not only on invite. Two incoming invitations + // accepted before either session reaches Connected both saw a held + // count of zero -- taking the invitation adds it to nothing -- so + // POINT_TO_POINT allowed the second one through. Released by the + // state change, whichever way it goes. + [cn1nbTransport markInviting:pid]; + handler(YES, [cn1nbTransport sessionFor:pid]); + 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 takeInvitationForPeer:pid]; + if (handler != nil) { + 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) { + 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]; + 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) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_PEER_UNAVAILABLE, + @"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://"]) { + p = [p substringFromIndex:7]; + } + NSURL *url = [NSURL fileURLWithPath:p]; + // The sender's payload id rides in the resource NAME: a resource + // transfer carries no other metadata, and the receiver otherwise + // 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; + // 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 + 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 + 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; + } + // 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, 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 + // 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) { + started++; + [progressHolder addObject:progress]; + [mine 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 != [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 mine) { + [cn1nbTransport forgetProgress: + [NSArray arrayWithObject:partial] + forPayload:payloadId]; + [partial cancel]; + } + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, + 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); + return; + } + NSData *data = cn1nbDataFromJavaArray(bytes); + // Framed with the payload id -- see didReceiveData for why. + NSMutableData *framed = [NSMutableData dataWithCapacity: + (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:CN1_NEARBY_FRAME_HEADER]; + if (data != nil) { + [framed appendData:data]; + } + // 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; + 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. + 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, + // 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]; + if (!ok) { + sent = NO; + if (err == nil) { + err = one; + } + // 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] + token:ackToken + 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; + } + [cn1nbTransport scheduleAckTimeout:payloadId + fromPeer:[peerIds objectAtIndex:i] + token:ackToken + encoded:encoded]; + } + if (!sent) { + cn1nbFailTransport(requestId, CN1_NEARBY_ERR_IO_ERROR, + [err localizedDescription]); + return; + } + 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_nearbyCancelPayload___int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT payloadId) { +#ifdef CN1_NEARBY_HAS_MPC + @autoreleasepool { + if (cn1nbTransport == nil) { + return; + } + // A file transfer CAN be recalled -- sendResourceAtURL hands back an + // NSProgress for exactly that -- so it is, and the completion handler + // 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 +} + +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) { + return; + } + // 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); + [cn1nbTransport closeSessionFor:pid]; + } +#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; + } + // 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]; + } +#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..8f7a78fd49c --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNearbyCallbacks.java @@ -0,0 +1,323 @@ +/* + * 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); + 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 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; + } +} 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..0005c6fc78d --- /dev/null +++ b/docs/developer-guide/Nearby-Devices.asciidoc @@ -0,0 +1,290 @@ +== 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 +| Connection authentication token | -- | yes | -- | -- +|=== + +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, 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. + +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 +`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. + +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. + +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] +---- +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 derive from the +connection's own key exchange, so a device relaying between them can't make both +ends show the same value. 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. + +It's empty on iOS. MultipeerConnectivity exposes nothing to derive one from, and +a token computed from the service name and the display names would be one a +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`. 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 +`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. + +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 +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 +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 | 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`. +| `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, +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 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/** 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..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 @@ -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,26 @@ 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)) { + usesNearbyCompanion = 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; usesHealthStore = true; @@ -2365,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 @@ -2528,6 +2616,59 @@ 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. + // + // 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) { + 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" : "") + + (usesNearbyTransport ? " transport" : "") + + (usesNearbyCompanion ? " companion" : "") + + (usesNearbyPresence ? " presence" : "")); + xPermissions = NearbyManifestFragments.inject(xPermissions, + usesNearbyRanging, usesNearbyTransport, + usesNearbyCompanion, usesNearbyPresence, + profiles.toString(), 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 @@ -2575,6 +2716,62 @@ 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. + // 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 + " 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 + // 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. + // + // 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 (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 + + " 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", ""); @@ -2995,6 +3192,43 @@ public void usesClassMethod(String cls, String method) { } + // Nearby Connections needs the modular play-services-nearby artifact. + // Legacy mode adds ONLY the 6.5.87 monolith, which predates that API + // by years, while the nearby transport sources are retained for the + // same input -- so the generated project would compile + // AndroidNearbyTransport against a bundle with no + // com.google.android.gms.nearby.connection package in it and fail + // with a wall of unresolved imports. Checked here, after every route + // into legacy mode has been taken, and refused with a message that + // names the cause. + if (legacyGplayServicesMode && usesNearbyTransport) { + error("Error: com.codename1.nearby.transport needs the modular" + + " play-services-nearby artifact, but this build selected" + + " the legacy monolithic Play Services bundle, which" + + " predates the Nearby Connections API. Remove" + + " android.includeGPlayServices (and build against a" + + " version newer than 3.3) -- the nearby dependency is" + + " 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"); @@ -3015,6 +3249,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 +3911,47 @@ && 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) { + // 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) { // The ARCore-backed impl package compiles against com.google.ar // classes that only exist when the AR gradle dependency is added, @@ -6426,6 +6710,44 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { supportLibVersion = "28"; } compileSdkVersion = ensureCompileSdkAtLeastTarget(compileSdkVersion, targetNumber); + if (usesNearbyRanging) { + // androidx.core.uwb declares minCompileSdk=36 in its AAR metadata, + // and Gradle rejects the project outright rather than compiling + // it -- so a ranging build whose compile SDK came from an older + // build-tools or target never got as far as javac. Raised + // independently of targetSdkVersion, which is the whole point: + // ranging is supported on a target-30 app and that app still has + // to COMPILE against 36. (The AAR also wants AGP 8.9.1 or newer; + // ANDROID_GRADLE_PLUGIN_8_VERSION is well past that.) + compileSdkVersion = ensureCompileSdkAtLeastTarget( + compileSdkVersion, "36"); + } + 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. + // + // 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"); + } 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 ce3318f169d..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 @@ -213,6 +213,402 @@ 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 { + File header = new File(buildinRes, "CodenameOne_GLViewController.h"); + try { + // 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); + } + } + + /// Every Bonjour service type this app may register, folded to what + /// MultipeerConnectivity will accept. + /// + /// 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 + /// + /// - `request`: the build request + /// + /// #### Returns + /// + /// 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(); + // 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')) { + 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); + } + // 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 + // 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(); + } + + /// 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 + /// 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 + /// 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 + /// @param usesBonjour whether the app also uses com.codename1.io.bonjour + private void mergeNearbyBonjourServices(BuildRequest request, + 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."); + 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(">", ">"); + } + + /// 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) throws BuildException { + String inject = request.getArg("ios.plistInject", ""); + if (WatchNativeBuilder.injectedPlistKeys(inject).contains(key)) { + // 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); + 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 +796,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 +1949,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; } @@ -1869,6 +2283,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) { @@ -4189,6 +4648,175 @@ 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"); + // com.apple.developer.nearby-interaction stays behind the + // hint, and is NOT injected for ordinary foreground + // ranging. + // + // It was suggested that the entitlement gates access to + // the framework itself rather than only background + // execution, and that a foreground build therefore links + // Nearby Interaction but cannot run an NISession. Three + // things say otherwise, and all three are checkable + // without a device: + // + // - The entitlement arrived with iOS 16. Nearby + // Interaction shipped in iOS 14 and NISession.isSupported + // is deprecated FROM 16 -- so two OS versions of + // foreground ranging predate the entitlement entirely, + // and requiring it would have broken every app built + // against them. + // - NIError declares no missing-entitlement code. Its + // failures are InvalidConfiguration, SessionFailed, + // ResourceUsageTimeout, ActiveSessionsLimitExceeded, + // UserDidNotAllow, InvalidARConfiguration and + // AccessoryPeerDeviceUnavailable. What gates a session + // on consent is UserDidNotAllow, and what governs that + // is the NSNearbyInteraction* usage string the catalog + // entry already injects. + // - Apple documents the capability as permitting Nearby + // Interaction in the BACKGROUND. + // + // Injecting it unconditionally is not free: 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 sets and + // the reason that one is behind a hint too. So the cost of + // being wrong in this direction is every ranging app + // failing to sign; the cost of being wrong the other way + // is background ranging needing one build hint. + // 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. + 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)")); + 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. + // 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())) { + 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. + // + // 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", + 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..95dcd0479d0 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java @@ -0,0 +1,812 @@ +/* + * 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, + String profiles, int targetSdkVersion) { + String out = xPermissions == null ? "" : xPermissions; + boolean modern = targetSdkVersion >= 31; + boolean tiramisu = targetSdkVersion >= 33; + + if (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); + } + + 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); + // 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", + ""); + 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. + // + // 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); + // 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) { + 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", + ""); + // 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", ""); + } + } + // 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; + } + + /** + * 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; + } + + /// 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 = indexOfQuoted(xPermissions, 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); + int[] cap = findAttribute(element, "android:maxSdkVersion"); + if (cap == null) { + // Uncapped, so it already reaches further than anything asked for. + return xPermissions; + } + int capped; + try { + capped = Integer.parseInt( + element.substring(cap[2], cap[3]).trim()); + } catch (NumberFormatException notANumber) { + return xPermissions; + } + if (requiredThrough > 0 && capped >= requiredThrough) { + return xPermissions; + } + String widened; + if (requiredThrough > 0) { + widened = element.substring(0, cap[2]) + requiredThrough + + element.substring(cap[3]); + } else { + widened = element.substring(0, cap[0]) + + element.substring(cap[1]); + // The attribute left a double space behind it. + widened = widened.replace(" ", " "); + } + return xPermissions.substring(0, start) + widened + + 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 + /// 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 + "\"")) { + return xPermissions; + } + return " \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; + } + 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; + } + // 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) { + 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/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java index eb54f34d443..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 @@ -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 @@ -232,11 +245,76 @@ 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 = tvBonjourServices(request); + if (!bonjour.isEmpty()) { + // 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)); + } + 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)); } + /// 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/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/NearbyBonjourMergeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java new file mode 100644 index 00000000000..a3c4b3fb62a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.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; + +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.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; + +/** + * 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 { + 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, + boolean.class); + m.setAccessible(true); + try { + m.invoke(b, request, new ArrayList( + Arrays.asList(serviceTypes)), + Boolean.valueOf(usesBonjour)); + } 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"); + } + + @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); + 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/NearbyBonjourServiceTypeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java new file mode 100644 index 00000000000..5e01de4a227 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java @@ -0,0 +1,308 @@ +/* + * 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.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; + +/** + * 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; + } + + /** 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); + 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() { + // 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 + 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 = only( + request("com.example.chat", null)); + assertLegal(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 + void anOverlongPackageIsTruncatedToTheLimit() { + String type = only( + 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 = only( + request("ab.cdefghijklm.x", null)); + assertLegal(type); + } + + @Test + void runsOfIllegalCharactersCollapseToOneHyphen() { + String type = only( + request("com...example___app", null)); + assertLegal(type); + assertEquals("com-exampl-" + + IPhoneBuilder.bonjourSuffix("com...example___app"), type); + } + + @Test + void uppercaseIsLowered() { + assertEquals("mychat-" + IPhoneBuilder.bonjourSuffix("MyChat"), + only(request("com.example.app", "MyChat"))); + } + + @Test + void somethingWithNoUsableCharactersFallsBackRatherThanRaising() { + // 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-exampl-" + + IPhoneBuilder.bonjourSuffix("com.example.app"), + only(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(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-" + 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); + } + } + + @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-4xwr", 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-4xwr", + IPhoneBuilder.foldBonjourServiceType("chat")); + assertEquals("com-exampl-jd3q", + IPhoneBuilder.foldBonjourServiceType("com.example.chat")); + // 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 + 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); + } + } + } + + @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() { + // 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) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 'a' && c <= 'z') { + return true; + } + } + 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); + } + } + } +} 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..4d71215ead9 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java @@ -0,0 +1,272 @@ +/* + * 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"); + } + } + + /** + * 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/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..42835ada486 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java @@ -0,0 +1,434 @@ +/* + * 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, "", 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 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, "", 30); + assertTrue(legacy.contains("android.permission.UWB_RANGING")); + String modern = NearbyManifestFragments.inject("", true, 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(legacy.contains("android.hardware.uwb")); + } + + @Test + void transportCarriesTheAndroid12SplitWithTheLegacyPairCapped() { + String out = NearbyManifestFragments.inject("", false, true, 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, "", 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 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, "", 31); + assertTrue(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, "", 30); + assertTrue(out.contains( + "android:name=\"android.permission.BLUETOOTH\" />")); + } + + @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, "", 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 + 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, "", 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, "", 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 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, "", 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, "", 30); + assertFalse(eleven.contains( + "REQUEST_COMPANION_START_FOREGROUND_SERVICES")); + } + + @Test + void theWatchProfilePermissionIsOptInButNotTargetGated() { + assertFalse(NearbyManifestFragments.inject("", false, false, true, + false, "", 34) + .contains("REQUEST_COMPANION_PROFILE_WATCH")); + assertTrue(NearbyManifestFragments.inject("", false, false, true, + 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, "watch", 30) + .contains("android.permission.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, "", 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, "", 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, "", 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, "", 34); + 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, "", 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, "", 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 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, "", 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, "", 31); + assertTrue(out.contains("android:name=\"android.permission" + + ".ACCESS_COARSE_LOCATION\" />"), out); + } + + @Test + void aCapThatAlreadyReachesFarEnoughIsLeftAlone() { + String seeded = " \n"; + String out = NearbyManifestFragments.inject(seeded, false, true, false, + false, "", 34); + assertTrue(out.contains("android:maxSdkVersion=\"33\""), + "a wider cap is not narrowed: " + out); + } + + @Test + void usingNoneOfItChangesNothing() { + String seeded = " \n"; + 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); + } + } +} 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..d570c520a77 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java @@ -0,0 +1,107 @@ +/* + * 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"); + } + + /** + * 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"); + } +} 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..a8110e90208 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.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 { + + /// 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"); + 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); + } + + /** + * 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"); + } + + /** + * 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"); + } +} 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..8ba98b17e04 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/LocalNearbyTest.java @@ -0,0 +1,1440 @@ +/* + * 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.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; +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.IncomingConnection; +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 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(); + 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 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(); + 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 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")); + } + + // ------------------------------------------------------------------ + // 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 + // 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 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 + // 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 + // 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 + // 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 -- + // 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); + 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 + 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()); + } + + @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"); + } + + @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) { + 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 aSecondConnectionRequestIsRefusedWhileTheFirstIsStillInFlight() { + // Both requests saw an empty connected list, because the first + // acceptance had not run yet -- so the simulator established two + // connections the real ports refuse, which is exactly the topology + // bug a simulator exists to surface rather than hide. + List 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); + 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 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 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 + // 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(); + 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()); + } + + @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 + // ------------------------------------------------------------------ + + 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..3549a27da84 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/NearbyDegradationTest.java @@ -0,0 +1,188 @@ +/* + * 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.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); + } + + /// 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()); + 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..049aa4a35e3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/nearby/RangingTokenTest.java @@ -0,0 +1,171 @@ +/* + * 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 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, + 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")); + } +} 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..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 @@ -632,6 +632,82 @@ 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.") + // 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/") + // 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..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 @@ -630,4 +630,98 @@ 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()); + // 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 + 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()); + } } 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