diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml index fb0e52a5ebe..6e05bb99c6a 100644 --- a/.github/workflows/ios-packaging.yml +++ b/.github/workflows/ios-packaging.yml @@ -108,10 +108,18 @@ jobs: | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') + # The build INVOCATION belongs in the key, not just the sources. Derived data + # embeds the SDK, destination and per-target product layout an invocation + # produced; restoring a tree built by a different one leaves Xcode's build + # database claiming targets are up to date when their products are absent + # ("Build input file cannot be found ... output of a script phase"). SCRIPT_HASH=$(shasum -a 256 \ scripts/setup-workspace.sh \ scripts/build-ios-port.sh \ scripts/build-native-themes.sh \ + scripts/build-ios-app.sh \ + scripts/run-ios-device-release-build.sh \ + scripts/run-ios-ui-tests.sh \ .github/workflows/_build-ios-port.yml \ | shasum -a 256 | awk '{print $1}') echo "hash=${SRC_HASH:0:16}-${POM_HASH:0:16}-${SCRIPT_HASH:0:16}" >> "$GITHUB_OUTPUT" @@ -165,8 +173,10 @@ jobs: with: path: ${{ runner.temp }}/cn1-ios-device-release-derived key: ${{ runner.os }}-ios-device-release-derived-${{ steps.src_hash.outputs.hash }} - restore-keys: | - ${{ runner.os }}-ios-device-release-derived- + # Deliberately no restore-keys prefix. A partial match here restores derived + # data produced by a DIFFERENT build invocation, and a stale Xcode build + # database is worse than a cold compile: it reports success for targets it + # skipped. An exact hit or nothing. - name: Build Release iOS device app without signing env: diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 9f3e98c5cc0..a990e380e04 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -6000,6 +6000,18 @@ public boolean isCarConnected() { return b != null && b.isConnected(); } + /// Returns the platform bridge that carries the `com.codename1.wearable` phone-to-watch API over + /// the native transport (Apple's `WCSession` / Google's Wearable Data Layer), or null when this + /// device has no wearable counterpart (the base implementation). When null, the + /// `com.codename1.wearable` API degrades to a harmless no-op. + /// + /// #### Returns + /// + /// the wearable bridge, or null when unsupported + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + 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/health/EdtResult.java b/CodenameOne/src/com/codename1/impl/health/EdtResult.java index 720ff5b2786..648ae65bea4 100644 --- a/CodenameOne/src/com/codename1/impl/health/EdtResult.java +++ b/CodenameOne/src/com/codename1/impl/health/EdtResult.java @@ -179,4 +179,5 @@ void superComplete(T value) { void superError(Throwable t) { super.error(t); } + } diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java index c32cb46a3bb..626011af130 100644 --- a/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java @@ -50,6 +50,18 @@ public final class SurfaceSerializer { private SurfaceSerializer() { } + /// True when the timeline carries a layout for at least one size family. Iterating the enum + /// rather than naming families keeps this correct as the catalog grows -- the watch + /// complication families joined it without touching this method. + private static boolean hasAnyExplicitContent(WidgetTimeline timeline) { + for (WidgetSize size : WidgetSize.values()) { + if (timeline.getExplicitContent(size) != null) { + return true; + } + } + return false; + } + /// Serializes a widget timeline. /// /// #### Parameters @@ -63,11 +75,7 @@ private SurfaceSerializer() { /// the timeline JSON public static String serializeTimeline(String kindId, WidgetTimeline timeline, Map imagesOut) { - if (timeline.getDefaultContent() == null - && timeline.getContent(WidgetSize.SMALL) == null - && timeline.getContent(WidgetSize.MEDIUM) == null - && timeline.getContent(WidgetSize.LARGE) == null - && timeline.getContent(WidgetSize.LOCKSCREEN) == null) { + if (timeline.getDefaultContent() == null && !hasAnyExplicitContent(timeline)) { throw new IllegalArgumentException("A widget timeline needs content: call " + "setContent(...) before publishing"); } diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetSize.java b/CodenameOne/src/com/codename1/surfaces/WidgetSize.java index d4f3a8fa288..81ae25e2e99 100644 --- a/CodenameOne/src/com/codename1/surfaces/WidgetSize.java +++ b/CodenameOne/src/com/codename1/surfaces/WidgetSize.java @@ -22,15 +22,41 @@ */ package com.codename1.surfaces; -/// The size families a widget kind supports. iOS maps these to the WidgetKit families -/// (`systemSmall` / `systemMedium` / `systemLarge` and `accessoryRectangular` for `LOCKSCREEN`); -/// Android and desktop treat them as size hints. `LOCKSCREEN` is ignored on Android in this -/// version. +/// The size families a widget kind supports. +/// +/// The first four are the phone families: iOS maps them to the WidgetKit families +/// (`systemSmall` / `systemMedium` / `systemLarge`, and `accessoryRectangular` for `LOCKSCREEN`); +/// Android and desktop treat them as size hints, and `LOCKSCREEN` is ignored on Android. +/// +/// The `WATCH_*` families are **complications** -- the small live readouts on a watch face. They +/// live here rather than in an API of their own because they are the same concept as a widget: +/// content-driven, rendered while your app is not running, and fed by the same [WidgetTimeline]. On +/// Apple a complication is literally a WidgetKit widget in an accessory family; on Wear OS the +/// simple families become complication data and the richer ones become a Tile. +/// +/// Design them for a glance. A complication is a few dozen pixels someone reads in under a second, +/// so a `SurfaceVector` gauge or a single number beats any layout that has to be read. public enum WidgetSize { + /// Small square home-screen widget. iOS `systemSmall`. SMALL("small"), + /// Medium home-screen widget. iOS `systemMedium`. MEDIUM("medium"), + /// Large home-screen widget. iOS `systemLarge`. LARGE("large"), - LOCKSCREEN("lockscreen"); + /// Lock-screen widget. iOS `accessoryRectangular`. + LOCKSCREEN("lockscreen"), + /// Round complication -- the corner or centre slots of a watch face. iOS `accessoryCircular`; + /// Wear OS `RANGED_VALUE` or `MONOCHROMATIC_IMAGE`. Room for a gauge or one glyph. + WATCH_CIRCULAR("watchCircular"), + /// Wide complication, a band across the watch face. iOS `accessoryRectangular`; Wear OS + /// `LONG_TEXT`, or a Tile when the layout is richer than text. The roomiest family. + WATCH_RECTANGULAR("watchRectangular"), + /// One line of text alongside the time. iOS `accessoryInline`; Wear OS `SHORT_TEXT`. Text only -- + /// anything else is dropped. + WATCH_INLINE("watchInline"), + /// Curved complication hugging the bezel of a round face. iOS `accessoryCorner`; renders as the + /// circular family on Wear OS, which has no corner slot. + WATCH_CORNER("watchCorner"); private final String jsonName; @@ -42,4 +68,33 @@ public enum WidgetSize { public String getJsonName() { return jsonName; } + + /// True for the watch complication families, which are published to a watch face rather than to + /// a home or lock screen. + /// + /// #### Returns + /// + /// true if this is a complication family + public boolean isWatchFamily() { + return this == WATCH_CIRCULAR || this == WATCH_RECTANGULAR + || this == WATCH_INLINE || this == WATCH_CORNER; + } + + /// Resolves a wire-format name back to its family. + /// + /// #### Parameters + /// + /// - `jsonName`: the name produced by [#getJsonName()] + /// + /// #### Returns + /// + /// the matching family, or null when the name is unknown + public static WidgetSize fromJsonName(String jsonName) { + for (WidgetSize s : values()) { + if (s.jsonName.equals(jsonName)) { + return s; + } + } + return null; + } } diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java index e59f846a209..81178261914 100644 --- a/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java +++ b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java @@ -73,10 +73,12 @@ public Map getState() { } private SurfaceNode defaultContent; - private SurfaceNode smallContent; - private SurfaceNode mediumContent; - private SurfaceNode largeContent; - private SurfaceNode lockscreenContent; + /// Per-family layout overrides. A map rather than a field per family: the catalog grows (the + /// watch accessory families joined the phone ones) and a switch per accessor did not. A plain + /// HashMap rather than an EnumMap -- the Codename One runtime has no EnumMap, and lookups here + /// are by key so the ordering an EnumMap would give buys nothing. + private final Map overrides = + new java.util.HashMap(); private final List entries = new ArrayList(); private int reloadPolicy = RELOAD_AT_END; @@ -105,21 +107,12 @@ public WidgetTimeline setContent(SurfaceNode root) { /// /// this timeline, for chaining public WidgetTimeline setContent(WidgetSize size, SurfaceNode root) { - switch (size) { - case SMALL: - smallContent = root; - break; - case MEDIUM: - mediumContent = root; - break; - case LARGE: - largeContent = root; - break; - case LOCKSCREEN: - lockscreenContent = root; - break; - default: - break; + if (size != null) { + if (root == null) { + overrides.remove(size); + } else { + overrides.put(size, root); + } } return this; } @@ -172,41 +165,14 @@ public WidgetTimeline setReloadPolicy(int policy) { /// /// the layout root, or null when neither an override nor a default was set public SurfaceNode getContent(WidgetSize size) { - SurfaceNode override = null; - switch (size) { - case SMALL: - override = smallContent; - break; - case MEDIUM: - override = mediumContent; - break; - case LARGE: - override = largeContent; - break; - case LOCKSCREEN: - override = lockscreenContent; - break; - default: - break; - } + SurfaceNode override = size == null ? null : overrides.get(size); return override != null ? override : defaultContent; } /// Returns the explicit per-size override, or null when the size family falls back to the /// default content. Used by the serializer so only real overrides are emitted per size. SurfaceNode getExplicitContent(WidgetSize size) { - switch (size) { - case SMALL: - return smallContent; - case MEDIUM: - return mediumContent; - case LARGE: - return largeContent; - case LOCKSCREEN: - return lockscreenContent; - default: - return null; - } + return size == null ? null : overrides.get(size); } /// Returns the layout used for size families without an explicit override, or null. diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 5fb2956ca11..1cb495a9df6 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4713,6 +4713,18 @@ public com.codename1.car.spi.CarBridge getCarBridge() { return impl.getCarBridge(); } + /// Returns the platform bridge used by the `com.codename1.wearable` API to talk to the + /// counterpart watch or phone app, or null when this device has no wearable counterpart. + /// Internal -- application code uses the `com.codename1.wearable` API rather than this bridge + /// directly. + /// + /// #### Returns + /// + /// the wearable bridge, or null + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + return impl.getWearableBridge(); + } + /// 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/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java new file mode 100644 index 00000000000..e26f7d3cdca --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -0,0 +1,1188 @@ +/* + * 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.wearable; + +import com.codename1.ui.Display; +import com.codename1.wearable.spi.WearableBridge; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The link between a phone app and its watch app. The same API on both ends, and the same API on +/// Apple Watch and Wear OS. +/// +/// ```java +/// // On the phone: publish state the watch should show whenever it next wakes. +/// WearableConnection.putData(new WearableMessage("/steps").put("count", steps)); +/// +/// // On the watch: react to it, and ask for a fresh value on demand. +/// WearableConnection.addDataListener(new WearableDataListener() { +/// public void dataChanged(WearableMessage data) { label.setText("" + data.getInt("count", 0)); } +/// public void dataRemoved(String path) { label.setText("--"); } +/// }); +/// ``` +/// +/// Register listeners from your app's `init()`. A payload that arrives before the first listener is +/// registered -- including the one that made the platform launch your app -- is queued and replayed, +/// but only to a listener that exists by the time the EDT gets to it. +/// +/// Where the platform provides no wearable link at all, [#isSupported()] returns false and every +/// call here is an inert no-op, so this API needs no platform conditionals around it. Note what +/// that method does NOT tell you: an iPhone with no watch paired to it still reports true, because +/// the question is whether the API exists. Gate wearable UI on [#isPaired()] or [#isReachable()]. +/// See the package documentation for how to choose between a message, replicated data and a file +/// transfer. +public final class WearableConnection { + private static final List messageListeners = + new ArrayList(); + private static final List dataListeners = + new ArrayList(); + private static final List stateListeners = + new ArrayList(); + + /// Payloads that arrived before anyone was listening. The platform can start an app purely to + /// hand it a message, so dropping these would lose exactly the payload that mattered most. + /// + /// Queued separately per listener type: an app that registers its data listener first would + /// otherwise drain a queued *message* while messageListeners was still empty, losing it for + /// good. + /// How many deliveries may be parked while no listener exists. + /// + /// Bounded on purpose. A peer that keeps sending to an app version which never registers the + /// matching listener -- a retired message path, a build that dropped the feature -- would + /// otherwise grow these without limit, and each queued runnable captures its whole payload, so + /// the cost tracks traffic rather than count. + /// + /// At the cap the oldest REPLACEABLE delivery is dropped: for a replicated value the newest is + /// the one that matters, and for a live message a listener that has never appeared was not + /// going to read the old ones either. A one-shot file transfer is not replaceable and is + /// evicted only when nothing else is parked -- see [#evictOne]. + private static final int MAX_PENDING = 256; + private static final List pendingMessages = new ArrayList(); + private static final List pendingData = new ArrayList(); + + /// Outstanding requests, keyed by the token handed to the bridge. The request path is kept + /// alongside the handler so the reply decodes onto a real path -- a payload has to have one. + private static final Map pendingReplies = + new HashMap(); + private static int nextReplyToken = 1; + + /// A request waiting for its answer. + private static final class PendingReply { + final WearableReplyHandler handler; + final String path; + + PendingReply(WearableReplyHandler handler, String path) { + this.handler = handler; + this.path = path; + } + } + + private WearableConnection() { + } + + private static WearableBridge bridge() { + return Display.getInstance().getWearableBridge(); + } + + /// Brings the platform bridge into existence. + /// + /// An app that only listens never calls anything that would otherwise create it, and on Apple + /// the native session is not activated until the bridge is first touched -- so a pure listener + /// would sit waiting for traffic that the platform was never told to deliver. + private static void activate() { + WearableBridge b = bridge(); + if (b != null) { + b.isSupported(); + } + } + + // --- state -------------------------------------------------------------- + + /// Returns true when this PLATFORM provides a wearable link, not when a counterpart exists. + /// + /// False on a desktop build and on any platform with no wearable API at all, and when false + /// every other call here does nothing. But an iPhone with no watch paired to it still reports + /// true: the question this answers is whether the API is present, and Apple's is. The same + /// holds on Android whenever the app was built with the wearable glue. + /// + /// Ask [#isPaired()] whether a counterpart device is actually paired, and [#isReachable()] + /// whether its app can receive something right now. Treating this method as either of those + /// will offer wearable features on a phone that has no watch. + /// + /// #### Returns + /// + /// true if the platform provides the wearable link + public static boolean isSupported() { + WearableBridge b = bridge(); + return b != null && b.isSupported(); + } + + /// Returns true when a counterpart device is paired, whether or not it is switched on or in + /// range. Distinct from [#isReachable()], which asks whether its app can receive something now. + /// + /// Do not decide your UI from a single call at startup. Both platforms answer from state that + /// is queried asynchronously, so the first calls in a cold process can report false for a + /// device that is paired -- there is nothing to report until the first query lands. Register a + /// [WearableStateListener] and react when the answer changes; that is what it is for. + /// + /// On Android there is one case this cannot see at all: a paired watch that has never run your + /// watch app. The Data Layer exposes pairing only through the nodes it knows about, and a watch that + /// never ran the app appears in no such list -- so a phone that is genuinely paired reports + /// false until the watch app has run once. Treat false as "no counterpart known", not as proof + /// that none exists, and prefer showing setup guidance over hiding it. Apple's API answers the + /// pairing question directly and has no such gap. + /// + /// #### Returns + /// + /// true if a counterpart device is known to be paired + public static boolean isPaired() { + WearableBridge b = bridge(); + return b != null && b.isPaired(); + } + + /// Returns true when the peer app can receive a live message right now. This is the condition + /// [#sendMessage(WearableMessage)] needs; [#putData(WearableMessage)] does not. + /// + /// #### Returns + /// + /// true if the peer app is reachable + public static boolean isReachable() { + WearableBridge b = bridge(); + return b != null && b.isReachable(); + } + + /// Returns true when the counterpart app is installed on the paired device. A watch that is + /// paired but has no watch app installed is worth prompting the user about, and is the usual + /// reason a correct-looking `sendMessage` never arrives. + /// + /// #### Returns + /// + /// true if the peer app is installed + public static boolean isCompanionAppInstalled() { + WearableBridge b = bridge(); + return b != null && b.isCompanionAppInstalled(); + } + + /// Returns the counterpart devices currently connected. Apple pairs one watch at a time, so + /// expect at most one; Wear OS allows several. + /// + /// #### Returns + /// + /// the connected nodes, never null + public static List getConnectedNodes() { + List out = new ArrayList(); + WearableBridge b = bridge(); + if (b == null) { + return out; + } + String[] raw = b.getConnectedNodes(); + if (raw == null) { + return out; + } + for (String entry : raw) { + if (entry == null) { + continue; + } + // id \t displayName \t nearby -- see WearableBridge#getConnectedNodes. + String[] parts = com.codename1.util.StringUtil.tokenize(entry, '\t') + .toArray(new String[0]); + if (parts.length == 0) { + continue; + } + String id = parts[0]; + String name = parts.length > 1 ? parts[1] : id; + boolean nearby = parts.length > 2 && "1".equals(parts[2]); + out.add(new WearableNode(id, name, nearby)); + } + return out; + } + + // --- sending ------------------------------------------------------------ + + /// Sends a live message to the peer app, with no reply expected. + /// + /// The message is delivered only if the peer is reachable; if it is not, the message is dropped. + /// Use [#putData(WearableMessage)] when the peer needs to see it eventually rather than now. + /// + /// #### Parameters + /// + /// - `message`: the payload to send + public static void sendMessage(WearableMessage message) { + sendMessage(message, null); + } + + /// Sends a live message to the peer app and waits for its answer. + /// + /// Exactly one method on the handler is called, on the EDT. A reply is not guaranteed: the peer + /// may be asleep, out of range, or running a version of your app that does not know this path. + /// + /// #### Parameters + /// + /// - `message`: the payload to send + /// - `reply`: notified with the answer, or null when no answer is wanted + public static void sendMessage(WearableMessage message, WearableReplyHandler reply) { + if (message == null) { + return; + } + WearableBridge b = bridge(); + if (b == null || !b.isSupported()) { + if (reply != null) { + failReply(reply, "No wearable link on this device"); + } + return; + } + // Serialized BEFORE the handler is registered. toByteArray refuses a message with more + // entries than the wire format can express, and throwing after the registration left a + // handler waiting on a request that was never sent -- no reply, and no port timeout to + // release it either, so it was retained for the life of the process. + byte[] wire = message.toByteArray(); + int token = 0; + if (reply != null) { + synchronized (pendingReplies) { + token = nextReplyToken++; + pendingReplies.put(Integer.valueOf(token), + new PendingReply(reply, message.getPath())); + } + } + b.sendMessage(message.getPath(), wire, token); + } + + /// Publishes the current value at a path, replacing whatever was there. + /// + /// This is the transport to reach for by default. The value survives both apps being killed and + /// reaches the peer whenever it next runs, so the peer always converges on the latest value. + /// Because each path holds one value, this is state replication and not a message queue -- two + /// rapid updates to the same path may be collapsed into one delivery. + /// + /// #### Parameters + /// + /// - `data`: the payload to publish, addressed to the path to publish under + public static void putData(WearableMessage data) { + if (data == null) { + return; + } + WearableBridge b = bridge(); + if (b != null && b.isSupported()) { + b.putData(data.getPath(), data.toByteArray()); + } + } + + /// Reads the replicated value at a path, as published by either side. + /// + /// #### Parameters + /// + /// - `path`: the path to read + /// + /// #### Returns + /// + /// the value, or null when nothing is published at that path + public static WearableMessage getData(String path) { + WearableBridge b = bridge(); + if (b == null || !b.isSupported() || path == null) { + return null; + } + byte[] raw = b.getData(path); + return raw == null ? null : WearableMessage.fromByteArray(path, raw); + } + + /// Removes the replicated value at a path. The peer is notified through + /// [WearableDataListener#dataRemoved(String)]. + /// + /// #### Parameters + /// + /// - `path`: the path to clear + public static void removeData(String path) { + WearableBridge b = bridge(); + if (b != null && b.isSupported() && path != null) { + b.removeData(path); + } + } + + /// Returns every path that currently holds a replicated value. + /// + /// #### Returns + /// + /// the published paths, never null + public static List getDataPaths() { + List out = new ArrayList(); + WearableBridge b = bridge(); + if (b == null || !b.isSupported()) { + return out; + } + String[] paths = b.getDataPaths(); + if (paths != null) { + for (String p : paths) { + if (p != null) { + out.add(p); + } + } + } + return out; + } + + /// Sends a file to the peer in the background. + /// + /// Delivery is not immediate and may happen after this app has exited -- that is the point. Use + /// it for anything too big for a message: a captured image, a synced document, a map tile. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on + /// - `name`: the file name to present to the peer + /// - `contents`: the file bytes + public static void transferFile(String path, String name, byte[] contents) { + // Empty is rejected here, alongside null, because WearableMessage rejects it too and the + // receiving side builds one. Letting it through made the failure platform-dependent and + // put it in the worst possible place: JavaSE threw immediately in the caller's own frame, + // while Android and iOS sent it happily and threw IllegalArgumentException later on the + // EDT, where it aborts the delivery pass and takes unrelated deliveries with it. + if (path == null || path.length() == 0 || contents == null) { + return; + } + WearableBridge b = bridge(); + if (b != null && b.isSupported()) { + b.transferFile(path, name, contents); + } + } + + // --- listeners ---------------------------------------------------------- + + /// Registers a listener for live messages from the peer. Register from your app's `init()`: a + /// message queued while the app was starting is replayed only to listeners that exist by the + /// time the EDT drains the queue. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addMessageListener(WearableMessageListener l) { + if (l == null) { + return; + } + boolean added; + // One step, for the reason given on addDataListener. + synchronized (pendingMessages) { + added = !messageListeners.contains(l); + if (added) { + messageListeners.add(l); + } + } + if (added) { + activate(); + drainPending(pendingMessages, false); + } + } + + /// Removes a previously registered message listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removeMessageListener(WearableMessageListener l) { + messageListeners.remove(l); + } + + /// Registers a listener for replicated data changes. Register from your app's `init()` for the + /// same reason as [#addMessageListener(WearableMessageListener)]. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addDataListener(WearableDataListener l) { + if (l == null) { + return; + } + boolean added; + // The membership test and the add are ONE step. Split, two threads registering the same + // instance both passed the test before either appended, and every later change was then + // reported to that listener twice. Concurrent registration is not hypothetical here -- the + // drain guard is a count precisely because two of them can run at once. + synchronized (pendingData) { + added = !dataListeners.contains(l); + if (added) { + dataListeners.add(l); + } + } + if (added) { + activate(); + drainPending(pendingData, true); + } + } + + /// Removes a previously registered data listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removeDataListener(WearableDataListener l) { + dataListeners.remove(l); + } + + /// Registers a listener for changes to the link itself -- reachability, pairing, whether the + /// peer app is installed. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addStateListener(WearableStateListener l) { + if (l != null && !stateListeners.contains(l)) { + stateListeners.add(l); + activate(); + } + } + + /// Removes a previously registered state listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removeStateListener(WearableStateListener l) { + stateListeners.remove(l); + } + + // --- platform port entry points ----------------------------------------- + + /// Framework/port entry point: hands a message received from the peer to the app. Called by the + /// platform port on whatever thread the native transport uses; delivery is marshalled to the + /// EDT, and queued if no listener has been registered yet. + /// + /// #### Parameters + /// + /// - `path`: the path the message arrived on + /// - `payload`: the encoded payload + /// - `replyToken`: a positive token when the peer is waiting for an answer, otherwise 0 + public static void deliverMessage(final String path, final byte[] payload, final int replyToken) { + deliver(new Runnable() { + @Override + public void run() { + WearableMessage m = WearableMessage.fromByteArray(path, payload); + WearableMessage reply = null; + WearableMessageListener[] copy = + messageListeners.toArray(new WearableMessageListener[messageListeners.size()]); + for (WearableMessageListener l : copy) { + WearableMessage r = l.messageReceived(m, replyToken != 0); + if (r != null && reply == null) { + reply = r; + } + } + // Nothing answers for an app that has no listener left. The snapshot can empty + // between the dispatch and this runnable -- an app shutting down, or one that + // deregisters on pause -- and replying anyway handed the sender an empty SUCCESS, + // so replyReceived fired for a request no application code ever saw. Staying + // silent lets the sender's own timeout report the failure it actually had. + if (replyToken != 0 && copy.length > 0) { + WearableBridge b = bridge(); + if (b != null) { + b.sendReply(replyToken, + reply == null ? new byte[0] : reply.toByteArray()); + } + } + } + }, messageListeners, pendingMessages); + } + + /// Framework/port entry point: hands the peer's answer to the waiting reply handler. Called by + /// the platform port; a token with no waiting handler is ignored. + /// + /// #### Parameters + /// + /// - `replyToken`: the token returned with the original request + /// - `payload`: the encoded reply payload, or null when the request failed + /// - `error`: a description of the failure, or null on success + public static void deliverReply(int replyToken, final byte[] payload, final String error) { + final PendingReply pending; + synchronized (pendingReplies) { + pending = pendingReplies.remove(Integer.valueOf(replyToken)); + } + if (pending == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (error != null) { + pending.handler.replyFailed(error); + } else { + // On the request's own path: a message always has one, and answering on the + // path you asked about is what a handler wants to see. + pending.handler.replyReceived( + WearableMessage.fromByteArray(pending.path, payload)); + } + } + }); + } + + /// Framework/port entry point: reports that the peer published or updated a replicated value. + /// Called by the platform port; queued across a cold start like a message. + /// + /// #### Parameters + /// + /// - `path`: the path whose value changed + /// - `payload`: the encoded new value + public static void deliverDataChanged(final String path, final byte[] payload) { + deliverTagged(path, new Runnable() { + @Override + public void run() { + WearableDataListener[] copy = + dataListeners.toArray(new WearableDataListener[dataListeners.size()]); + if (copy.length == 0) { + // The last listener went away between the park check and this EDT turn -- an + // app pausing while a native callback was in flight. Dispatching to nobody + // would DISCARD the change: the port has already recorded the path as + // delivered, so registering a listener again replays nothing, and a replicated + // value raises no second callback while it stays unchanged. Park it instead, + // which is what would have happened had the list been empty a moment earlier. + deliverTagged(path, this); + return; + } + WearableMessage m = WearableMessage.fromByteArray(path, payload); + for (WearableDataListener l : copy) { + l.dataChanged(m); + } + } + }); + } + + /// Framework/port entry point: reports that the peer removed a replicated value. Called by the + /// platform port. + /// + /// #### Parameters + /// + /// - `path`: the path whose value is gone + public static void deliverDataRemoved(final String path) { + deliverTagged(path, new Runnable() { + @Override + public void run() { + WearableDataListener[] copy = + dataListeners.toArray(new WearableDataListener[dataListeners.size()]); + if (copy.length == 0) { + // Same gap as deliverDataChanged, and worse for a removal: the item is gone, so + // there is nothing left for any later enumeration to find. Park it. + deliverTagged(path, this, true); + return; + } + for (WearableDataListener l : copy) { + l.dataRemoved(path); + } + } + }, true); + } + + /// Framework/port entry point: reports that reachability, pairing or peer-app installation + /// changed. Called by the platform port. Unlike payload delivery this is not queued -- state is + /// re-queried by the listener, so a stale notification is worthless. + public static void notifyStateChanged() { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + WearableStateListener[] copy = + stateListeners.toArray(new WearableStateListener[stateListeners.size()]); + for (WearableStateListener l : copy) { + l.connectionStateChanged(); + } + } + }); + } + + /// Parks a replicated delivery TAGGED with its path, so the cap can prefer an entry the + /// incoming one actually supersedes. + private static void deliverTagged(String path, Runnable delivery) { + deliverTagged(path, delivery, false); + } + + private static void deliverTagged(String path, Runnable delivery, boolean removal) { + deliver(delivery, dataListeners, pendingData, null, false, path, removal, true); + } + + /// Runs a delivery on the EDT, or parks it until a listener exists. + /// + /// The platform starts an app to hand it a payload, so the payload routinely arrives before the + /// app has finished wiring itself up. Parking rather than dropping is what makes it safe to + /// register listeners in `init()`. + private static boolean deliver(Runnable delivery, List listeners, List queue) { + return deliver(delivery, listeners, queue, null, false); + } + + /// Runs a delivery on the EDT, or parks it until a listener exists. + /// + /// `oneShot` marks a delivery whose payload has no other copy in this process -- a file + /// transfer. It changes only what the cap evicts. + private static boolean deliver(Runnable delivery, List listeners, List queue, + Runnable onDropped, boolean oneShot) { + return deliver(delivery, listeners, queue, onDropped, oneShot, null); + } + + private static boolean deliver(Runnable delivery, List listeners, List queue, + Runnable onDropped, boolean oneShot, String path) { + return deliver(delivery, listeners, queue, onDropped, oneShot, path, false); + } + + private static boolean deliver(Runnable delivery, List listeners, List queue, + Runnable onDropped, boolean oneShot, String path, boolean removal) { + return deliver(delivery, listeners, queue, onDropped, oneShot, path, removal, false); + } + + /// @param dataQueue whether this is the replicated-data queue, whose drain has a tail of + /// recovery work that must not be overtaken + private static boolean deliver(Runnable delivery, List listeners, List queue, + Runnable onDropped, boolean oneShot, String path, boolean removal, boolean dataQueue) { + List release = null; + boolean parked; + // The listener check and the enqueue share the queue's monitor with drainPending, so a + // delivery can never be parked after the drain that would have replayed it. + synchronized (queue) { + // Parked when there is no listener OR when anything is already parked for this queue. + // + // The second half is what keeps ORDER. A listener is registered and the backlog drained + // as two steps, and an update arriving between them saw a listener, dispatched straight + // to the EDT, and landed AHEAD of older state that was still parked -- a republished + // value followed by the stale removal it replaced, leaving the listener removed while + // getData returned the value. Queueing behind an existing backlog makes that ordering + // structural instead of a matter of timing, and it also routes the update through the + // park path, which is where a superseded recovery record is cancelled. + parked = listeners.isEmpty() || !queue.isEmpty() || (dataQueue && drainingData > 0); + if (parked) { + while (queue.size() >= MAX_PENDING) { + Runnable evicted = evictOne(queue, path); + if (evicted != null) { + if (release == null) { + release = new ArrayList(); + } + release.add(evicted); + } + } + queue.add(oneShot ? new OneShot(delivery, onDropped) + : (path != null ? new Replicated(delivery, path, removal) : delivery)); + if (path != null) { + // Any recovery record for THIS path is now obsolete: the delivery just parked + // is a newer statement about it than the one that was discarded. + // + // Leaving them cost more than a redundant re-offer. droppedRemovals is + // re-announced by the drain itself, AFTER the parked deliveries are handed to + // the EDT -- so a path whose removal was evicted and which was then + // republished ended with the listener told "removed" on top of the newer + // value, while getData returned the value. Nothing later corrects that: the + // republication has already been consumed. + droppedRemovals.remove(path); + droppedPaths.remove(path); + } + } + } + // Run outside the monitor: this calls back into the port, and holding the queue's lock + // across foreign code invites a deadlock with whatever the port synchronises on. + if (release != null) { + for (Runnable r : release) { + r.run(); + } + } + if (parked) { + return false; + } + Display.getInstance().callSerially(delivery); + return true; + } + + /// Makes room for one delivery, taking a replaceable one first, and returns anything whose + /// port-side claim has to be released. Run OUTSIDE the queue's monitor. + /// + /// The cap used to take the oldest entry outright, and replicated updates share this queue with + /// file transfers. A replicated update is safely replaceable -- a later publication of the same + /// path supersedes it, and the value is still readable with `getData`. A transfer is not: it is + /// one-shot, the payload exists nowhere else in this process, and dropping it is the whole + /// delivery. + /// + /// So transfers are evicted only when every parked delivery is one, and even then the eviction + /// merely drops the runnable: the confirmation callback is NOT invoked, so the port's durable + /// copy -- the iOS inbox entry, the JavaSE file, the Android transfer claim -- is left + /// unretired and the payload is redelivered on the next activation instead of being lost. + private static Runnable evictOne(List queue, String incomingPath) { + // SUPERSEDED first: an older delivery for the same path as the one arriving, which the + // newcomer genuinely replaces. That is the case the "safely replaceable" reasoning above + // actually describes, and it was applied to any replicated entry regardless of path -- so a + // burst across many paths discarded callbacks nothing would replace, for paths the ports + // have already marked as seen. + if (incomingPath != null) { + for (int i = 0; i < queue.size(); i++) { + Runnable parked = queue.get(i); + if (parked instanceof Replicated + && incomingPath.equals(((Replicated) parked).path)) { + queue.remove(i); + return null; + } + } + } + // Nothing superseded. Then the oldest replicated entry -- but its path is REMEMBERED, and + // handed to the port after the drain. The ports record a delivery as made before queueing + // it, so their own replay would skip this path as already delivered; and a removal cannot + // be reconstructed from getData at all. Without that hand-back the listener stays + // permanently wrong about a path nothing will mention again. + for (int i = 0; i < queue.size(); i++) { + Runnable parked = queue.get(i); + if (!(parked instanceof OneShot)) { + queue.remove(i); + if (parked instanceof Replicated) { + Replicated r = (Replicated) parked; + if (r.removal) { + droppedRemovals.add(r.path); + } else { + droppedPaths.add(r.path); + } + } + return null; + } + } + return ((OneShot) queue.remove(0)).onDropped; + } + + /// A parked replicated delivery, tagged with its path so the cap can drop one the incoming + /// delivery actually supersedes. See [#evictOne]. + private static final class Replicated implements Runnable { + private final Runnable delivery; + final String path; + /// Whether this parked delivery was a removal rather than a value change. + final boolean removal; + + Replicated(Runnable delivery, String path, boolean removal) { + this.delivery = delivery; + this.path = path; + this.removal = removal; + } + + @Override + public void run() { + delivery.run(); + } + } + + /// Marks a parked delivery as the only copy of its payload. See [#evictOne]. + private static final class OneShot implements Runnable { + private final Runnable delivery; + /// Releases the port's in-process claim on this payload when the delivery is evicted, or + /// null when the port has nothing to release. + final Runnable onDropped; + + OneShot(Runnable delivery, Runnable onDropped) { + this.delivery = delivery; + this.onDropped = onDropped; + } + + @Override + public void run() { + delivery.run(); + } + } + + /// Framework/port entry point: as [#deliverDataChanged], reporting whether the delivery reached + /// a registered listener rather than being parked for a cold start. + /// + /// Ports use this where the answer changes what they record. A file transfer is the case: its + /// one-shot claim must not be made durable while the payload exists only in this process's + /// pending queue, because a process death then loses the payload AND suppresses the redelivery + /// that would have replaced it. + /// + /// #### Parameters + /// + /// - `path`: the path whose value changed + /// - `payload`: the encoded new value + /// + /// #### Returns + /// + /// `true` when a listener was registered and the delivery was dispatched; `false` when it was + /// queued for a listener that does not exist yet. + public static boolean deliverDataChangedTracked(final String path, final byte[] payload) { + return deliverDataChangedTracked(path, payload, null); + } + + /// As above, invoking `onDelivered` once application listeners have actually RUN. + /// + /// The distinction matters for anything that records a delivery durably. `deliver` returning + /// true means the runnable was handed to the EDT, not that it executed -- a process death in + /// between loses the payload while the record says it arrived. A one-shot file transfer + /// suppresses its own redelivery on the strength of that record, so the difference between + /// "dispatched" and "delivered" is the difference between a duplicate and a permanent loss. + /// + /// #### Parameters + /// + /// - `path`: the path whose value changed + /// - `payload`: the encoded new value + /// - `onDelivered`: run on the EDT after the listeners, or null + /// + /// #### Returns + /// + /// `true` when a listener was registered and the delivery was dispatched. + public static boolean deliverDataChangedTracked(final String path, final byte[] payload, + final Runnable onDelivered) { + return deliverDataChangedTracked(path, payload, onDelivered, null); + } + + /// As above, additionally releasing the port's in-process claim if the parked delivery is + /// evicted to make room under the queue cap. + /// + /// Dropping the runnable alone is not enough for a one-shot transfer. The ports suppress a + /// second callback for a payload they have already handed over -- Android holds an in-memory + /// transfer claim, JavaSE has recorded the file in its seen set -- so nothing would redeliver + /// it while this process stays alive, and Android's sender-side retention can expire in the + /// meantime. `onRelinquished` undoes exactly that bookkeeping, so the payload is offered again + /// on the next scan instead of waiting for a restart. + /// + /// #### Parameters + /// + /// - `path`: the path whose value changed + /// - `payload`: the encoded new value + /// - `onDelivered`: run on the EDT after the listeners, or null + /// - `onRelinquished`: run when the parked delivery is evicted undelivered, or null + /// + /// #### Returns + /// + /// `true` when a listener was registered and the delivery was dispatched. + public static boolean deliverDataChangedTracked(final String path, final byte[] payload, + final Runnable onDelivered, final Runnable onRelinquished) { + return deliver(new Runnable() { + @Override + public void run() { + WearableMessage m = WearableMessage.fromByteArray(path, payload); + WearableDataListener[] copy = + dataListeners.toArray(new WearableDataListener[dataListeners.size()]); + if (copy.length == 0) { + // Every listener went away between the dispatch and this runnable -- an app + // shutting down, or one that deregisters on pause. Confirming here would be a + // lie with consequences: for a one-shot transfer the confirmation DELETES the + // port's durable copy, so the payload would be destroyed having reached nobody. + // Hand it back instead, exactly as an eviction does. + if (onRelinquished != null) { + onRelinquished.run(); + } + return; + } + for (WearableDataListener l : copy) { + l.dataChanged(m); + } + if (onDelivered != null) { + onDelivered.run(); + } + } + }, dataListeners, pendingData, onRelinquished, onDelivered != null, path, false, true); + } + + /// What a port does about a replicated delivery the cap had to discard, or null when it has + /// registered nothing. See [#setDroppedDeliveryHandler]. + /// Guarded by the pendingData monitor rather than declared volatile. + /// + /// Every read already happens under that lock -- the drain holds it while deciding whether to + /// hand paths back -- so volatile bought nothing beyond the write, and a lock that covers the + /// decision is stronger than a field that only covers the load. + private static DroppedDeliveryHandler droppedDeliveries; + + /// The registered handler, read under the monitor that guards it. + /// + /// Callers already hold that monitor in the drain, so this is reentrant there; it exists so no + /// read of the field is left depending on the caller remembering to take the lock. + private static DroppedDeliveryHandler droppedHandler() { + synchronized (pendingData) { + return droppedDeliveries; + } + } + + /// How a port recovers a parked delivery the cap discarded. + /// + /// Dropping the runnable is not enough on its own: the port recorded the delivery as made + /// before queueing it, so its own replay will skip that path as already delivered. The port has + /// to forget that record and offer the path again. + public interface DroppedDeliveryHandler { + /// Called once per discarded path, on the EDT, after the queue has drained and listeners + /// exist -- so a re-offer can actually be delivered rather than parked and dropped again. + /// + /// A null path means the record itself overflowed and more was discarded than can be + /// named: re-offer everything available rather than one path. + /// + /// Only value changes reach here. A discarded REMOVAL is re-announced by this class + /// directly, because the path is the whole of it and no port could rediscover one -- the + /// evidence of a removal is an item that is not there. + void deliveryDropped(String path); + } + + /// Framework/port entry point: registers what to do about a discarded delivery. + /// + /// #### Parameters + /// + /// - `handler`: the port's recovery action, or null to remove it + public static void setDroppedDeliveryHandler(DroppedDeliveryHandler handler) { + synchronized (pendingData) { + droppedDeliveries = handler; + } + } + + /// Paths whose parked delivery was discarded and not superseded, awaiting the drain. + /// + /// A SET, and bounded like every other cache here. Recovery is per path, so a path repeated + /// adds nothing, and an app that never registers a listener while the peer churns through + /// paths would otherwise grow this list without limit -- defeating the cap it exists to serve. + /// Past the bound the oldest entry goes: the port's own startup replay remains the backstop for + /// anything that falls off, which is the same guarantee as before this list existed. + private static final java.util.Set droppedPaths = + java.util.Collections.newSetFromMap(new java.util.LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() > MAX_PENDING) { + // Overflowing loses a specific path, so instead of forgetting quietly, ask + // the port to re-offer everything it can. One flag, however many paths + // overflow: the whole point of the bound is that per-path bookkeeping has + // stopped being affordable. + rescanRequested = true; + return true; + } + return false; + } + }); + + /// Paths whose discarded delivery was a REMOVAL, kept apart from the changes. + /// + /// Two reasons. A removal is re-announced from here directly rather than handed to the port: + /// its entire content is the path, which this class already has, and no port can rediscover it + /// -- the evidence of a removal is the absence of an item, so there is nothing to enumerate, + /// nothing in a received context, and no file on disk. Asking a port to "re-offer" one is + /// asking it to invent something. + /// + /// And keeping them in their own set means a burst of ordinary changes cannot evict them. + private static final java.util.Set droppedRemovals = + java.util.Collections.newSetFromMap(new java.util.LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() <= MAX_REMEMBERED_REMOVALS) { + return false; + } + // The one loss in this class that nothing downstream can repair. A discarded + // CHANGE can be re-offered by the port, and overflowing that record asks for a + // rescan; a removal has no such fallback, because the item is gone and there is + // nothing anywhere to rediscover. So this is said out loud rather than dropped + // quietly -- an app whose listener arrives after this many distinct removals + // has one it will never hear about, and the log is the only evidence. + com.codename1.io.Log.p("com.codename1.wearable: no listener has registered and " + + MAX_REMEMBERED_REMOVALS + " removals are already waiting; the " + + "removal of " + eldest.getKey() + " can no longer be delivered. " + + "Register a WearableDataListener from init()."); + return true; + } + }); + + /// How many discarded removals are remembered. Larger than the delivery cap on purpose: a + /// removal is a path and nothing else, so remembering one is cheap, and it is the only kind + /// whose loss cannot be repaired by re-offering something that still exists. + private static final int MAX_REMEMBERED_REMOVALS = 4096; + + /// Set when the dropped-path set overflowed, so the port is asked for a full rescan instead of + /// a list of paths it can no longer be given. Read and cleared with [#droppedPaths]'s monitor. + /// Takes the overflow flag and clears it. + /// + /// Synchronized on {@link #pendingData} by NAME even though the caller already holds that + /// monitor -- this runs only while draining pendingData itself, so the acquisition is reentrant + /// and nothing changes at runtime. The caller locks a queue it received as a parameter, which + /// merely happens to alias the static field, and a guard that depends on an alias is one a + /// reader cannot check and a static analyzer must assume is broken. + private static boolean takeRescanRequest() { + synchronized (pendingData) { + boolean requested = rescanRequested; + rescanRequested = false; + return requested; + } + } + + /// True while a data drain is between taking its recovery records and announcing them. + /// + /// Those records are copied out and cleared under the monitor, then announced with it released + /// -- so a publication arriving in that gap saw an empty queue, dispatched straight to the EDT, + /// and could not cancel a record already copied into the drain's own list. The stale removal + /// was then announced after the newer value, which is the failure the cancellation exists to + /// prevent, reached by a different route. While this is set, deliveries park and are drained in + /// order behind the recovery work. + /// + /// Guarded by the pendingData monitor. + /// + /// A COUNT, not a flag. addDataListener does not serialise, so two threads registering + /// listeners can drain at once -- and with a boolean the one that finished first cleared it + /// while the other was still announcing, reopening the window for whatever arrived next. + private static int drainingData; + + /// Sets the draining flag under the monitor that guards it, named rather than aliased. + /// + /// The callers hold that monitor already -- both run while draining pendingData itself, so the + /// acquisition is reentrant -- but they reach it through a parameter, and a guard that holds + /// only through an alias is one neither a reader nor an analyzer can check. Same reasoning as + /// takeRescanRequest. + private static void setDrainingData(boolean draining) { + synchronized (pendingData) { + drainingData += draining ? 1 : -1; + if (drainingData < 0) { + // Cannot happen from the paired calls in drainPendingOnce, but a count that goes + // negative would silently disable the guard for every later drain, so it is pinned + // rather than trusted. + drainingData = 0; + } + } + } + + private static boolean rescanRequested; + + /// Actions a port asked to run once deliveries can actually reach a listener, keyed so repeated + /// requests for the same operation collapse into one. + private static final java.util.LinkedHashMap replayRequests = + new java.util.LinkedHashMap(); + + /// Framework/port entry point: asks for `replay` to run once a listener exists. + /// + /// A port whose payload was evicted from the pending queue cannot simply re-offer it: nothing + /// has changed yet, so the delivery would be parked, immediately evict another one-shot to make + /// room, and that one would re-offer in turn. Deferring to the moment the queue drains breaks + /// that cycle -- by then a listener exists and deliveries dispatch instead of parking. + /// + /// Requests are keyed, and a repeat replaces the pending one. A port whose replay re-offers + /// EVERYTHING it is holding -- as a rescan does -- should pass a constant key: one such action + /// covers any number of evictions, and queueing one per evicted payload would both grow this + /// map past the delivery cap and rescan the whole backlog once per eviction. + /// + /// Runs immediately when a data listener is already registered. + /// + /// #### Parameters + /// + /// - `key`: identifies the operation; a later request with the same key supersedes this one + /// - `replay`: the port's re-offer action + public static void requestReplayAfterDrain(String key, Runnable replay) { + if (replay == null) { + return; + } + synchronized (pendingData) { + if (dataListeners.isEmpty()) { + replayRequests.put(key == null ? replay.toString() : key, replay); + return; + } + } + replay.run(); + } + + /// Replays what was queued for one listener type, once a listener of that type exists. + /// @param dataQueue whether this is the replicated-data queue, which carries the recovery + /// bookkeeping the message queue has none of + private static void drainPending(List queue, boolean dataQueue) { + // Until the queue is genuinely empty, not once. + // + // Deliveries now park behind an existing backlog, and the tail of a drain calls out to the + // port -- recovery hand-backs and replays -- with the monitor released. Anything arriving + // in that window parks, and without this loop it would sit there until the next listener + // registration, which for a single-listener app never comes. + for (;;) { + drainPendingOnce(queue, dataQueue); + synchronized (queue) { + if (queue.isEmpty()) { + return; + } + } + } + } + + private static void drainPendingOnce(List queue, boolean dataQueue) { + List replays = null; + List dropped = null; + List removals = null; + synchronized (queue) { + if (dataQueue) { + // Held across the recovery announcements below, which run with the monitor + // released. Anything arriving meanwhile parks rather than overtaking them. + setDrainingData(true); + } + // Only taken when a port can actually act on them. Clearing the set with no handler + // registered would discard the one record that a path needs re-offering -- and iOS, + // which registers late in its bridge's construction, would lose whatever arrived first. + if (dataQueue && !droppedRemovals.isEmpty()) { + // Taken whether or not a port registered a handler: these are re-announced here. + removals = new ArrayList(droppedRemovals); + droppedRemovals.clear(); + } + if (dataQueue && droppedHandler() != null && !droppedPaths.isEmpty()) { + dropped = new ArrayList(droppedPaths); + droppedPaths.clear(); + if (takeRescanRequest()) { + // A null path is the rescan request: more was lost than can be named. + dropped.add(null); + } + } + if (dataQueue && !replayRequests.isEmpty()) { + replays = new ArrayList(replayRequests.values()); + replayRequests.clear(); + } + if (!queue.isEmpty()) { + List drained = new ArrayList(queue); + queue.clear(); + // Handed to the EDT while the monitor is STILL held. Clearing the queue and then + // dispatching outside it reopened the same gap from the other side: a delivery + // arriving in between saw an empty queue, dispatched itself, and overtook the batch + // that had already been taken out. callSerially only enqueues -- it runs no + // listener code on this thread -- so holding the lock across it is safe, and the + // same reasoning is already documented on deliverIfOutranks. + for (Runnable r : drained) { + Display.getInstance().callSerially(r); + } + } + } + // Discarded REMOVALS are simply re-announced. The path is the whole of a removal, so this + // is a complete recovery rather than a request for one -- and it is the only recovery + // available, since the item is gone and no port can enumerate an absence. + if (removals != null) { + for (String removed : removals) { + deliverDataRemoved(removed); + } + } + // Paths the cap discarded, handed back now that a listener exists and there is room. + if (dropped != null) { + DroppedDeliveryHandler handler = droppedHandler(); + if (handler != null) { + for (String path : dropped) { + handler.deliveryDropped(path); + } + } + } + // After the drain, so a re-offered payload finds room and a registered listener rather than + // landing straight back in a full queue. + if (replays != null) { + for (Runnable replay : replays) { + replay.run(); + } + } + if (dataQueue) { + // Cleared only now that every recovery announcement has been made. The drain loop + // re-checks the queue immediately after, so anything that parked while this was held + // is drained in the next pass rather than waiting for a listener registration. + setDrainingData(false); + } + } + + private static void failReply(final WearableReplyHandler reply, final String message) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + reply.replyFailed(message); + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableDataListener.java b/CodenameOne/src/com/codename1/wearable/WearableDataListener.java new file mode 100644 index 00000000000..08f98733d69 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableDataListener.java @@ -0,0 +1,45 @@ +/* + * 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.wearable; + +/// Notified when replicated data changes on the peer. +/// +/// Callbacks arrive on the EDT, and changes that landed while your app was not running are replayed +/// to the first listener you register -- that is the point of replicated data, so register from your +/// app's `init()`. +public interface WearableDataListener { + + /// Called when the peer publishes or updates the value at a path. + /// + /// #### Parameters + /// + /// - `data`: the new value, addressed to the path the peer published it under + void dataChanged(WearableMessage data); + + /// Called when the peer removes the value at a path. + /// + /// #### Parameters + /// + /// - `path`: the path whose value is gone + void dataRemoved(String path); +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableMessage.java b/CodenameOne/src/com/codename1/wearable/WearableMessage.java new file mode 100644 index 00000000000..b466b8c248b --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableMessage.java @@ -0,0 +1,479 @@ +/* + * 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.wearable; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A payload addressed to a path, used both for live messages and for replicated data. +/// +/// The path is what the receiving side matches on -- `"/steps"`, `"/workout/start"` -- and works +/// like a URL path, so give related payloads a common prefix. Values are the primitive types every +/// wearable transport can carry natively on both platforms: string, int, long, double, boolean and +/// raw bytes. +/// +/// ```java +/// WearableMessage m = new WearableMessage("/steps") +/// .put("count", 8412) +/// .put("goalReached", true); +/// WearableConnection.putData(m); +/// ``` +/// +/// Reads name a default, so a peer running an older version of your app that never sent a key gets +/// a sane value rather than an exception. That matters more than usual here: the two apps are +/// updated independently and can be different versions of each other for a long time. +public class WearableMessage { + /// Wire format version, so a newer peer can recognize a payload it cannot parse instead of + /// misreading it. + private static final int FORMAT_VERSION = 1; + + private static final int TYPE_STRING = 1; + private static final int TYPE_INT = 2; + private static final int TYPE_LONG = 3; + private static final int TYPE_DOUBLE = 4; + private static final int TYPE_BOOLEAN = 5; + private static final int TYPE_BYTES = 6; + + private final String path; + private final Map values = new LinkedHashMap(); + + /// Creates an empty message addressed to a path. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on, conventionally starting with `/` + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the path is null or empty + public WearableMessage(String path) { + if (path == null || path.length() == 0) { + throw new IllegalArgumentException("A wearable message needs a path"); + } + this.path = path; + } + + /// Returns the path this message is addressed to. + /// + /// #### Returns + /// + /// the path + public String getPath() { + return path; + } + + /// Returns the keys carried by this message, in insertion order. + /// + /// #### Returns + /// + /// the keys present in the payload + public List getKeys() { + return new ArrayList(values.keySet()); + } + + /// Returns true if the payload carries a value under the supplied key. + /// + /// #### Parameters + /// + /// - `key`: the key to look for + /// + /// #### Returns + /// + /// true if the key is present + public boolean contains(String key) { + return values.containsKey(key); + } + + /// Adds a string value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value; a null value removes the key + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, String value) { + return set(key, value); + } + + /// Adds an int value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, int value) { + return set(key, Integer.valueOf(value)); + } + + /// Adds a long value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, long value) { + return set(key, Long.valueOf(value)); + } + + /// Adds a double value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, double value) { + return set(key, Double.valueOf(value)); + } + + /// Adds a boolean value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, boolean value) { + return set(key, Boolean.valueOf(value)); + } + + /// Adds a raw byte payload. Keep it small: a message is delivered over a low-bandwidth link and + /// the platforms reject oversized payloads outright. Use + /// [WearableConnection#transferFile(String,String,byte[])] for anything substantial. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the bytes; a null value removes the key + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, byte[] value) { + return set(key, value); + } + + private WearableMessage set(String key, Object value) { + if (key == null || key.length() == 0) { + throw new IllegalArgumentException("A wearable message value needs a key"); + } + if (value == null) { + values.remove(key); + } else { + values.put(key, value); + } + return this; + } + + /// Reads a string value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds another type + /// + /// #### Returns + /// + /// the value, or the default + public String getString(String key, String defaultValue) { + Object o = values.get(key); + return o instanceof String ? (String) o : defaultValue; + } + + /// Reads an int value. Accepts any numeric value, so a peer that sent a long or a double still + /// reads back sensibly. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds a non-numeric type + /// + /// #### Returns + /// + /// the value, or the default + public int getInt(String key, int defaultValue) { + Object o = values.get(key); + return o instanceof Number ? ((Number) o).intValue() : defaultValue; + } + + /// Reads a long value. Accepts any numeric value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds a non-numeric type + /// + /// #### Returns + /// + /// the value, or the default + public long getLong(String key, long defaultValue) { + Object o = values.get(key); + return o instanceof Number ? ((Number) o).longValue() : defaultValue; + } + + /// Reads a double value. Accepts any numeric value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds a non-numeric type + /// + /// #### Returns + /// + /// the value, or the default + public double getDouble(String key, double defaultValue) { + Object o = values.get(key); + return o instanceof Number ? ((Number) o).doubleValue() : defaultValue; + } + + /// Reads a boolean value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds another type + /// + /// #### Returns + /// + /// the value, or the default + public boolean getBoolean(String key, boolean defaultValue) { + Object o = values.get(key); + return o instanceof Boolean ? ((Boolean) o).booleanValue() : defaultValue; + } + + /// Reads a raw byte payload. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds another type + /// + /// #### Returns + /// + /// the value, or the default + public byte[] getBytes(String key, byte[] defaultValue) { + Object o = values.get(key); + return o instanceof byte[] ? (byte[]) o : defaultValue; + } + + + /// Writes a string as a 32-bit length followed by its UTF-8 bytes. + /// + /// Not `DataOutputStream.writeUTF`: that caps a string at 65,535 encoded bytes and throws + /// beyond it. Nothing in the public API says a value has to be short, and a payload that + /// silently fails to encode because a string grew is a poor way to find out. + private static void writeLongUTF(DataOutputStream out, String value) throws IOException { + byte[] utf8 = value.getBytes("UTF-8"); + out.writeInt(utf8.length); + out.write(utf8); + } + + /// Reads a string written by [#writeLongUTF(DataOutputStream,String)]. + private static String readLongUTF(DataInputStream in) throws IOException { + byte[] utf8 = new byte[readLength(in)]; + in.readFully(utf8); + return new String(utf8, "UTF-8"); + } + + /// Reads a length that is about to size an allocation. + /// + /// A negative or absurd value means the payload is malformed or came from a peer this build + /// does not understand. Throwing IOException keeps that inside the decoder's own handler, which + /// answers with an empty message -- an unchecked NegativeArraySizeException would escape onto + /// the EDT instead. + private static int readLength(DataInputStream in) throws IOException { + int n = in.readInt(); + if (n < 0 || n > in.available() + 1) { + throw new IOException("Implausible length " + n + " in a wearable payload"); + } + return n; + } + + // --- wire format -------------------------------------------------------- + + /// Serializes the payload to the compact form the platform bridges carry. Application code does + /// not normally call this; [WearableConnection] does it on the way out. + /// + /// #### Returns + /// + /// the encoded payload, never null + /// The most entries the wire format can carry, set by its 16-bit count field. + static final int MAX_WIRE_ENTRIES = 65535; + + public byte[] toByteArray() { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bo); + try { + out.writeByte(FORMAT_VERSION); + if (values.size() > MAX_WIRE_ENTRIES) { + // Refused, not truncated and not silently mangled. The count is a 16-bit field, so + // 32768 entries wrote a NEGATIVE short, the reader looped zero times, and the peer + // accepted a message with every value gone -- no error anywhere, on either side. + // A payload this size is already past what either transport will carry, so failing + // here costs nothing that would otherwise have worked. + throw new IllegalStateException("A WearableMessage carries at most " + + MAX_WIRE_ENTRIES + " entries; this one has " + values.size()); + } + out.writeShort(values.size()); + for (Map.Entry e : values.entrySet()) { + writeLongUTF(out, e.getKey()); + Object v = e.getValue(); + if (v instanceof String) { + out.writeByte(TYPE_STRING); + writeLongUTF(out, (String) v); + } else if (v instanceof Integer) { + out.writeByte(TYPE_INT); + out.writeInt(((Integer) v).intValue()); + } else if (v instanceof Long) { + out.writeByte(TYPE_LONG); + out.writeLong(((Long) v).longValue()); + } else if (v instanceof Double) { + out.writeByte(TYPE_DOUBLE); + out.writeDouble(((Double) v).doubleValue()); + } else if (v instanceof Boolean) { + out.writeByte(TYPE_BOOLEAN); + out.writeBoolean(((Boolean) v).booleanValue()); + } else { + byte[] b = (byte[]) v; + out.writeByte(TYPE_BYTES); + out.writeInt(b.length); + out.write(b); + } + } + out.flush(); + } catch (IOException err) { + // A ByteArrayOutputStream cannot fail; rethrowing keeps callers honest + // if that ever stops being true. + IllegalStateException wrapped = + new IllegalStateException("Failed to encode wearable payload: " + err); + wrapped.initCause(err); + throw wrapped; + } + return bo.toByteArray(); + } + + /// Reconstructs a payload received from the peer. Application code does not normally call this; + /// [WearableConnection] does it on the way in. + /// + /// #### Parameters + /// + /// - `path`: the path the payload arrived on + /// - `data`: the encoded payload, may be null or empty for a payload with no values + /// + /// #### Returns + /// + /// the decoded message, never null; a payload this build cannot parse decodes to an empty + /// message on the same path rather than throwing + public static WearableMessage fromByteArray(String path, byte[] data) { + WearableMessage m = new WearableMessage(path); + if (data == null || data.length == 0) { + return m; + } + // Decoded into a SEPARATE message and only adopted once the whole payload has validated. + // Filling `m` as it went meant a payload that was fine for three fields and then truncated + // handed the app those three -- an incomplete update that looks like a valid one, which is + // worse than the empty message this method documents as its fallback, because nothing + // distinguishes it from a real partial write. + WearableMessage decoded = new WearableMessage(path); + DataInputStream in = new DataInputStream(new ByteArrayInputStream(data)); + try { + int version = in.readByte(); + if (version != FORMAT_VERSION) { + // A peer running a future version of the app. Reading on would + // produce garbage values, which is worse than no values at all. + com.codename1.io.Log.p("Wearable: ignoring a payload on " + path + + " in wire format " + version + "; this build understands " + + FORMAT_VERSION); + return m; + } + // UNSIGNED: the writer's short is a count, never negative, and reading it signed + // halved the range for no reason -- and turned an over-large one into zero rather than + // into an error. + int count = in.readUnsignedShort(); + for (int i = 0; i < count; i++) { + String key = readLongUTF(in); + int type = in.readByte(); + switch (type) { + case TYPE_STRING: + decoded.put(key, readLongUTF(in)); + break; + case TYPE_INT: + decoded.put(key, in.readInt()); + break; + case TYPE_LONG: + decoded.put(key, in.readLong()); + break; + case TYPE_DOUBLE: + decoded.put(key, in.readDouble()); + break; + case TYPE_BOOLEAN: + decoded.put(key, in.readBoolean()); + break; + case TYPE_BYTES: + byte[] b = new byte[readLength(in)]; + in.readFully(b); + decoded.put(key, b); + break; + default: + com.codename1.io.Log.p("Wearable: unknown value type " + type + + " on " + path + "; the rest of the payload is unreadable"); + return m; // nothing adopted: the partial fields are dropped with it + } + } + // Complete and well-formed, so publish it. + return decoded; + } catch (IOException err) { + com.codename1.io.Log.p("Wearable: unreadable payload on " + path + ": " + err); + } + return m; + } + + @Override + public String toString() { + return "WearableMessage[" + path + " " + values.keySet() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableMessageListener.java b/CodenameOne/src/com/codename1/wearable/WearableMessageListener.java new file mode 100644 index 00000000000..9c73bbe7ed6 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableMessageListener.java @@ -0,0 +1,46 @@ +/* + * 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.wearable; + +/// Notified when the peer app sends a live message. +/// +/// Callbacks arrive on the EDT. A message that arrived while your app was starting -- including the +/// one that caused the platform to launch it -- is replayed to the first listener you register, so +/// register from your app's `init()` rather than from a form. +public interface WearableMessageListener { + + /// Called when a message arrives from the peer app. + /// + /// If the sender asked for a reply, answer it by returning a message; returning null sends an + /// empty reply. The sender is blocked waiting, so answer quickly and do slow work afterwards. + /// + /// #### Parameters + /// + /// - `message`: the received payload, addressed to the path the sender chose + /// - `expectsReply`: true when the sender is waiting for an answer + /// + /// #### Returns + /// + /// the reply to send back, or null for none + WearableMessage messageReceived(WearableMessage message, boolean expectsReply); +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableNode.java b/CodenameOne/src/com/codename1/wearable/WearableNode.java new file mode 100644 index 00000000000..79efce3d1c8 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableNode.java @@ -0,0 +1,84 @@ +/* + * 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.wearable; + +/// A device on the other end of the link: the watch as seen from the phone, or the phone as seen +/// from the watch. +/// +/// Apple pairs a phone with exactly one watch at a time, so there is at most one node there. Wear OS +/// allows several watches paired to one phone, so a phone app can see more than one -- send to all +/// of them unless you have a reason to pick. +public class WearableNode { + private final String id; + private final String displayName; + private final boolean nearby; + + /// Creates a node description. Called by the platform ports; application code obtains nodes from + /// [WearableConnection#getConnectedNodes()]. + /// + /// #### Parameters + /// + /// - `id`: the platform's opaque identifier for the device + /// - `displayName`: the device name a person would recognize + /// - `nearby`: true when the device is directly connected rather than reachable over the cloud + public WearableNode(String id, String displayName, boolean nearby) { + this.id = id; + this.displayName = displayName; + this.nearby = nearby; + } + + /// Returns the platform's opaque identifier for this device, stable for as long as the pairing + /// lasts. + /// + /// #### Returns + /// + /// the node id + public String getId() { + return id; + } + + /// Returns the device name a person would recognize, suitable for showing in a UI. + /// + /// #### Returns + /// + /// the display name + public String getDisplayName() { + return displayName; + } + + /// Returns true when the device is directly connected (Bluetooth or the same network) rather + /// than merely reachable through the cloud. Only a nearby node can receive a live message; + /// replicated data reaches both. + /// + /// #### Returns + /// + /// true if the node is directly connected + public boolean isNearby() { + return nearby; + } + + @Override + public String toString() { + return "WearableNode[" + displayName + (nearby ? ", nearby]" : "]"); + } +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableReplyHandler.java b/CodenameOne/src/com/codename1/wearable/WearableReplyHandler.java new file mode 100644 index 00000000000..ddf44cfb013 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableReplyHandler.java @@ -0,0 +1,44 @@ +/* + * 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.wearable; + +/// Receives the answer to a message that asked for one. +/// +/// Exactly one of the two methods is called, on the EDT. A reply is not guaranteed: the peer may be +/// asleep, out of range, or running a version of your app that does not know the path you sent. +public interface WearableReplyHandler { + + /// Called with the peer's answer. + /// + /// #### Parameters + /// + /// - `reply`: the peer's response, on the same path as the request + void replyReceived(WearableMessage reply); + + /// Called when no answer could be obtained. + /// + /// #### Parameters + /// + /// - `message`: a description of what went wrong, suitable for a log rather than a UI + void replyFailed(String message); +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableStateListener.java b/CodenameOne/src/com/codename1/wearable/WearableStateListener.java new file mode 100644 index 00000000000..770fccb95ed --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableStateListener.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. + */ +package com.codename1.wearable; + +/// Notified when the link to the peer app changes. +/// +/// Use it to enable or disable the parts of your UI that need a live peer -- a "send to watch" +/// button, say -- rather than polling [WearableConnection#isReachable()]. Callbacks arrive on the +/// EDT. +public interface WearableStateListener { + + /// Called when reachability, pairing or peer-app installation changes. Query + /// [WearableConnection#isReachable()], [WearableConnection#isPaired()] and + /// [WearableConnection#isCompanionAppInstalled()] for the new state. + void connectionStateChanged(); +} diff --git a/CodenameOne/src/com/codename1/wearable/package-info.java b/CodenameOne/src/com/codename1/wearable/package-info.java new file mode 100644 index 00000000000..90f0e95980e --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/package-info.java @@ -0,0 +1,71 @@ +/* + * 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. + */ + +/// Talking between a phone app and its watch app. +/// +/// A watch app and a phone app are two apps on two devices with two sandboxes. Nothing is shared +/// between them automatically: `Storage`, `Preferences` and the SQLite database are per-device, and +/// there is no cross-device container. This package is the channel between them, and it is the same +/// channel on Apple Watch (`WCSession`) and Wear OS (the Wearable Data Layer). +/// +/// #### Three ways to move information, and how to choose +/// +/// The platforms offer three transports because they answer three different questions. Picking the +/// wrong one is the usual source of "my watch app didn't get the update": +/// +/// | You need | Use | Delivered | +/// |---|---|---| +/// | An answer, now, while both apps are awake | [WearableConnection#sendMessage(WearableMessage,WearableReplyHandler)] | Immediately, or it fails | +/// | The peer to end up with the latest state, whenever it next looks | [WearableConnection#putData(WearableMessage)] | Eventually, survives sleep and relaunch | +/// | To move a file or a large blob | [WearableConnection#transferFile(String,String,byte[])] | In the background, possibly much later | +/// +/// A message is a phone call: it only works if someone picks up ([WearableConnection#isReachable()] +/// is true). Data is a shared noticeboard: you pin the current value at a path and the peer reads it +/// whenever it wakes, so it is what you want for "the watch should show my latest step count". Data +/// replaces the value at a path rather than queueing, so do not use it as a message queue. +/// +/// #### The dead-process rule +/// +/// The peer app may not be running when something arrives for it. The platform starts it, which +/// means your listener may not be registered yet. Callbacks that arrive before you register are +/// therefore queued and replayed to your first listener, on the EDT. Register listeners from your +/// `init()` rather than from a form, or you will race the platform and lose the callback that +/// launched you. +/// +/// #### Degrades instead of failing +/// +/// Where the platform has no wearable link at all -- a desktop build, or any platform without the +/// API -- there is no bridge, [WearableConnection#isSupported()] returns false and every call is an +/// inert no-op. Application code needs no platform conditionals. +/// +/// `isSupported()` answers a narrower question than it may appear to: whether the PLATFORM provides +/// the link, not whether a counterpart is there. An iPhone with no watch paired to it still reports +/// true. Use [WearableConnection#isPaired()] to ask whether a counterpart device exists and +/// [WearableConnection#isReachable()] to ask whether its app can receive something now -- offering +/// a wearable feature on the strength of `isSupported()` alone will show it on a phone with no +/// watch. +/// +/// Merely referencing this package makes the build wire the native plumbing (`WatchConnectivity` on +/// Apple, the `play-services-wearable` dependency and a `WearableListenerService` on Android); apps +/// that never use it pay nothing. See the "Wearables" chapter of the developer guide. +package com.codename1.wearable; diff --git a/CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java b/CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java new file mode 100644 index 00000000000..45bdbaf17c2 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java @@ -0,0 +1,154 @@ +/* + * 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.wearable.spi; + +/// Internal service-provider interface implemented by each platform port to carry the +/// `com.codename1.wearable` API onto the native phone-to-watch transport (Apple's `WCSession` or +/// Google's Wearable Data Layer). +/// +/// Application code never touches this interface -- it is obtained by the `com.codename1.wearable` +/// framework from `com.codename1.ui.Display#getWearableBridge()` and driven through the public +/// `com.codename1.wearable.WearableConnection` API. The base implementation returns `null`, which is +/// why the public API degrades to a harmless no-op on the simulator and on ports with no paired +/// device (so application code needs no platform `if` statements). +/// +/// Payloads cross this interface as the opaque bytes produced by +/// `com.codename1.wearable.WearableMessage#toByteArray()`, so a port only has to move bytes and +/// never has to understand the value model. Incoming traffic is pushed back the other way by calling +/// the static entry points on `com.codename1.wearable.WearableConnection` +/// (`deliverMessage`, `deliverReply`, `deliverDataChanged`, `deliverDataRemoved`, +/// `notifyStateChanged`), which take care of EDT dispatch and of queueing across a cold start. +public interface WearableBridge { + + /// Returns true when the PLATFORM provides the transport and this app may use it -- not whether + /// a counterpart device exists. + /// + /// An implementation should answer for the API, not for the pairing: Apple's bridge reports + /// `WCSession` availability whether or not a watch is paired, and an implementation that folded + /// pairing into this answer would make the whole public API inert on a phone whose watch merely + /// happens to be unpaired right now. Pairing is [#isPaired()]'s question and reachability is + /// [#isReachable()]'s. + /// + /// False makes the whole public API inert, so return false only when there is no transport. + /// + /// #### Returns + /// + /// true if the platform's wearable transport is available to this app + boolean isSupported(); + + /// Returns true when a counterpart device is paired with this one, whether or not it is + /// currently switched on or in range. + /// + /// #### Returns + /// + /// true if a counterpart device is paired + boolean isPaired(); + + /// Returns true when the peer app can receive a live message right now. This is the condition + /// `sendMessage` needs; replicated data does not. + /// + /// #### Returns + /// + /// true if the peer app is reachable + boolean isReachable(); + + /// Returns true when the counterpart app is actually installed on the paired device. A paired + /// watch with no watch app installed is the common case worth telling the user about. + /// + /// #### Returns + /// + /// true if the peer app is installed + boolean isCompanionAppInstalled(); + + /// Returns the currently connected counterpart devices, one entry per device, each formatted as + /// `id \t displayName \t 1|0` where the trailing flag is whether the device is nearby. The flat + /// string form keeps the interface to primitives so native ports do not have to construct Java + /// objects. + /// + /// #### Returns + /// + /// the connected nodes, never null; an empty array when nothing is connected + String[] getConnectedNodes(); + + /// Sends a live message to the peer app, delivered only if it is reachable. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on + /// - `payload`: the encoded payload + /// - `replyToken`: a positive token to answer with `WearableConnection.deliverReply` when the + /// sender wants a reply, or 0 when it does not + void sendMessage(String path, byte[] payload, int replyToken); + + /// Answers a message the peer sent with a reply token. + /// + /// #### Parameters + /// + /// - `replyToken`: the token that arrived with the request + /// - `payload`: the encoded reply payload + void sendReply(int replyToken, byte[] payload); + + /// Publishes or replaces the replicated value at a path. The value must survive this app being + /// killed and must reach the peer whenever it next runs. + /// + /// #### Parameters + /// + /// - `path`: the path to publish under + /// - `payload`: the encoded payload + void putData(String path, byte[] payload); + + /// Returns the replicated value at a path, as published by either side. + /// + /// #### Parameters + /// + /// - `path`: the path to read + /// + /// #### Returns + /// + /// the encoded payload, or null when nothing is published at that path + byte[] getData(String path); + + /// Removes the replicated value at a path. + /// + /// #### Parameters + /// + /// - `path`: the path to clear + void removeData(String path); + + /// Returns every path that currently holds a replicated value. + /// + /// #### Returns + /// + /// the published paths, never null + String[] getDataPaths(); + + /// Transfers a file to the peer in the background. Delivery may happen long after this returns, + /// including after this app has exited. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on + /// - `name`: the file name to present to the peer + /// - `contents`: the file bytes + void transferFile(String path, String name, byte[] contents); +} diff --git a/CodenameOne/src/com/codename1/wearable/spi/package-info.java b/CodenameOne/src/com/codename1/wearable/spi/package-info.java new file mode 100644 index 00000000000..99ccf01494a --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/spi/package-info.java @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/// Internal service-provider interface for the `com.codename1.wearable` phone-to-watch API. The +/// single `WearableBridge` interface is implemented by each platform port to carry payloads over the +/// native transport (Apple's `WCSession` / Google's Wearable Data Layer). Application code does not +/// use this package directly -- it drives the public `com.codename1.wearable` API, which obtains the +/// bridge from the platform implementation. +package com.codename1.wearable.spi; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index f29f75f5576..8b68f9f2c98 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -6271,6 +6271,14 @@ public boolean isCarConnected() { return b != null && b.isConnected(); } + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + // The Wearable Data Layer glue is injected by the builder only when the app references + // com.codename1.wearable; without it this is null and the API no-ops. + Context ctx = getContext(); + return ctx == null ? null : AndroidWearableSupport.getBridge(ctx); + } + private com.codename1.surfaces.spi.SurfaceBridge surfaceBridge; @Override diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java b/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java new file mode 100644 index 00000000000..f4e96e02a04 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java @@ -0,0 +1,79 @@ +/* + * 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 com.codename1.wearable.spi.WearableBridge; + +/// Registry that links the Android port to the Wearable Data Layer glue. +/// +/// The runtime Android port carries no compile-time dependency on +/// `com.google.android.gms:play-services-wearable` -- it is only on the classpath when the app +/// references `com.codename1.wearable`, at which point the build injects a typed `WearableBridge` +/// implementation plus a `WearableListenerService` into the generated project. The injected bridge +/// registers itself here and `AndroidImplementation#getWearableBridge()` reads it back. Without the +/// glue this stays null and the `com.codename1.wearable` API degrades to a no-op, exactly as it does +/// on a phone with no watch. +/// +/// This mirrors {@link AndroidCarSupport}, for the same reason: an optional Google dependency cannot +/// be referenced from the port itself. +/// +/// The injected glue lives in the maven-plugin / BuildDaemon resources under +/// `com/codename1/builders/wearable/`. +public final class AndroidWearableSupport { + private static volatile WearableBridge bridge; + private static boolean lookedUp; + + private AndroidWearableSupport() { + } + + /// Returns the injected bridge, or null when the app does not use the wearable API. + /// + /// Unlike the in-car glue -- which the system instantiates, so it can register itself -- nothing + /// creates the wearable bridge on our behalf, so it is looked up reflectively on first use. The + /// class only exists in the generated project when the build injected it, which is precisely the + /// condition under which play-services-wearable is on the classpath. + /// + /// #### Parameters + /// + /// - `context`: the Android context the bridge needs + /// + /// #### Returns + /// + /// the wearable bridge, or null + public static synchronized WearableBridge getBridge(android.content.Context context) { + if (!lookedUp) { + lookedUp = true; + try { + Class c = Class.forName("com.codename1.impl.android.CN1WearableBridge"); + bridge = (WearableBridge) c.getConstructor(android.content.Context.class) + .newInstance(context); + } catch (ClassNotFoundException notInjected) { + // The app never references com.codename1.wearable; the API stays inert. + } catch (Throwable err) { + com.codename1.io.Log.p("Wearable: the Data Layer glue is present but could not be " + + "created: " + err); + } + } + return bridge; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java index 1db1cd53088..eb81595c33e 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java @@ -1,885 +1,1008 @@ -/* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.impl.android; - -import android.app.Activity; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.util.Log; -import android.view.*; -import android.view.inputmethod.EditorInfo; -import android.os.Build; -import com.codename1.ui.Component; -import com.codename1.ui.Display; -import com.codename1.ui.Form; -import com.codename1.ui.PeerComponent; -import com.codename1.ui.Sheet; -import com.codename1.ui.TextArea; -import com.codename1.ui.events.ActionEvent; -import com.codename1.ui.events.ActionListener; -import java.lang.reflect.Method; - - -/** - * - * @author Chen - */ -public class CodenameOneView { - - int width = 1; - int height = 1; - Bitmap bitmap; - AndroidGraphics buffy = null; - private Canvas canvas; - private AndroidImplementation implementation = null; - private final Rect bounds = new Rect(); - private boolean fireKeyDown = false; - //private volatile boolean created = false; - private boolean drawing; - - private final Rect safeArea = new Rect(); - - private static final int VERSION_CODE_P = 28; - private static final int VERSION_CODE_M = 23; - - public CodenameOneView(Activity activity, View androidView, AndroidImplementation implementation, boolean drawing) { - - this.implementation = implementation; - this.drawing = drawing; - androidView.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.FILL_PARENT, - ViewGroup.LayoutParams.FILL_PARENT)); - androidView.setFocusable(true); - androidView.setFocusableInTouchMode(true); - androidView.setEnabled(true); - androidView.setClickable(true); - androidView.setLongClickable(false); - - /** - * tell the system that we do our own caching and it does not need to - * use an extra offscreen bitmap. - */ - if(!drawing) { - androidView.setWillNotCacheDrawing(false); - androidView.setWillNotDraw(true); - this.buffy = new AndroidGraphics(implementation, null, false); - } - - /** - * From the docs: "Change whether this view is one of the set of - * scrollable containers in its window. This will be used to determine - * whether the window can resize or must pan when a soft input area is - * open -- scrollable containers allow the window to use resize mode - * since the container will appropriately shrink. " - */ - androidView.setScrollContainer(true); - - android.view.Display androidDisplay = ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); - width = androidDisplay.getWidth(); - height = androidDisplay.getHeight(); - View rootView = activity.getWindow().getDecorView(); - rootView.post(new Runnable() { - public void run() { - updateSafeArea(); - } - }); - initBitmaps(width, height); - } - - public boolean isOpaque() { - return true; - } - - public void onSurfaceChanged(final int w, final int h) { - if(!Display.isInitialized()) { - return; - } - Display.getInstance().callSerially(new Runnable() { - - public void run() { - handleSizeChange(w, h); - } - }); - } - - public void onSurfaceCreated() { - this.visibilityChangedTo(true); - } - - public void onSurfaceDestroyed() { - this.visibilityChangedTo(false); - } - - private void initBitmaps(int w, int h) { - if(!drawing) { - this.bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - this.canvas = new Canvas(this.bitmap); - this.buffy.setCanvas(this.canvas); - } - } - - public void visibilityChangedTo(boolean visible) { - if (this.implementation.getCurrentForm() == null) { - return; - } - if (visible) { - this.implementation.showNotifyPublic(); - // request a full repaint as our surfaceview is most likely - // black if this app comes back from the background. - this.implementation.getCurrentForm().repaint(); - } else { - this.implementation.hideNotifyPublic(); - } - } - - private void updateSafeArea() { - final Activity activity = CodenameOneView.this.implementation.getActivity(); - final Rect rect = this.safeArea; - final View rootView = activity.getWindow().getDecorView(); - if (Build.VERSION.SDK_INT >= VERSION_CODE_P) { - try { - Method getRootWindowInsetsMethod = View.class.getMethod("getRootWindowInsets"); - Object insets = getRootWindowInsetsMethod.invoke(rootView); - if (insets != null) { - Class windowInsetsClass = Class.forName("android.view.WindowInsets"); - Method getDisplayCutoutMethod = windowInsetsClass.getMethod("getDisplayCutout"); - Object cutout = getDisplayCutoutMethod.invoke(insets); - - int left = 0; - int top = 0; - int right = 0; - int bottom = 0; - if (cutout != null) { - Class displayCutoutClass = Class.forName("android.view.DisplayCutout"); - Method getSafeInsetLeft = displayCutoutClass.getMethod("getSafeInsetLeft"); - Method getSafeInsetTop = displayCutoutClass.getMethod("getSafeInsetTop"); - Method getSafeInsetRight = displayCutoutClass.getMethod("getSafeInsetRight"); - Method getSafeInsetBottom = displayCutoutClass.getMethod("getSafeInsetBottom"); - left = ((Integer) getSafeInsetLeft.invoke(cutout)).intValue(); - top = ((Integer) getSafeInsetTop.invoke(cutout)).intValue(); - right = ((Integer) getSafeInsetRight.invoke(cutout)).intValue(); - bottom = ((Integer) getSafeInsetBottom.invoke(cutout)).intValue(); - } - - boolean imeVisible = false; - try { - Method isVisibleMethod = insets.getClass().getMethod("isVisible", int.class); - Class typeClass = Class.forName("android.view.WindowInsets$Type"); - int imeType = ((Integer) typeClass.getMethod("ime").invoke(null)).intValue(); - imeVisible = (Boolean) isVisibleMethod.invoke(insets, imeType); - } catch (Throwable t) { - // Fallback or log - } - - Rect systemBarInsets = AndroidImplementation.getSystemBarInsets(rootView); - top = Math.max(systemBarInsets.top, top); - if (imeVisible) { - // Avoid double-counting the bottom gesture bar - bottom = Math.max(bottom, 0); - } else { - bottom = Math.max(systemBarInsets.bottom, bottom); - } - left = Math.max(systemBarInsets.left, left); - right = Math.max(systemBarInsets.right, right); - - if (!AndroidImplementation.isImmersive()) { - top -= systemBarInsets.top; - if (!imeVisible) { - bottom -= systemBarInsets.bottom; - } - left -= systemBarInsets.left; - right -= systemBarInsets.right; - } - - // Only apply if at least one is non-zero - if (left != 0 || top != 0 || right != 0 || bottom != 0) { - boolean isChanged = rect.left != left - || rect.right != right - || rect.top != top - || rect.bottom != bottom; - rect.left = left; - rect.top = top; - rect.right = right; - rect.bottom = bottom; - - if (isChanged) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - AndroidImplementation.getInstance().revalidate(); - } - }); - } - } - } - } catch (Throwable e) { - rect.top = 0; - rect.left = 0; - rect.right = 0; - rect.bottom = 0; - } - - } else if (Build.VERSION.SDK_INT >= VERSION_CODE_M) { - rootView.post(new Runnable() { - public void run() { - WindowInsets insets = rootView.getRootWindowInsets(); - if (insets != null) { - rect.top = insets.getSystemWindowInsetTop(); - rect.left = insets.getSystemWindowInsetLeft();; - rect.right = insets.getSystemWindowInsetRight(); - rect.bottom = insets.getSystemWindowInsetBottom(); - } else { - rect.top = 0; - rect.left = 0; - rect.right = 0; - rect.bottom = 0; - } - } - }); - } else { - // For pre-Marshmallow (API < 23), assume full screen - rect.top = 0; - rect.left = 0; - rect.right = 0; - rect.bottom = 0; - } - } - - public void handleSizeChange(int w, int h) { - - if(!drawing) { - if ((this.width != w && (this.width < w || this.height < h)) - || (bitmap.getHeight() < h)) { - this.initBitmaps(w, h); - } - } - if (this.width == w && this.height == h) { - return; - } - this.width = w; - this.height = h; - - updateSafeArea(); - - Log.d("Codename One", "sizechanged: " + width + " " + height + " " + this); - if (this.implementation.getCurrentForm() == null) { - /** - * make sure a form has been set before we can send events to the - * EDT. if we send events before the form has been set we might - * deadlock! - */ - return; - } - - if (InPlaceEditView.isEditing()) { - final Form f = this.implementation.getCurrentForm(); - ActionListener sizeChanged = new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - CodenameOneView.this.implementation.getActivity().runOnUiThread(new Runnable() { - - @Override - public void run() { - InPlaceEditView.reLayoutEdit(); - } - }); - f.removeSizeChangedListener(this); - } - }; - f.addSizeChangedListener(sizeChanged); - } - Display.getInstance().sizeChanged(w, h); - } - - //@Override - protected void d(Canvas canvas) { - if(!drawing) { - boolean empty = canvas.getClipBounds(bounds); - if (empty) { - // ?? - canvas.drawBitmap(bitmap, 0, 0, null); - } else { - bounds.intersect(0, 0, width, height); - canvas.drawBitmap(bitmap, bounds, bounds, null); - } - } - } - - /** - * some info from the MIDP docs about keycodes: - * - * "Applications receive keystroke events in which the individual keys are - * named within a space of key codes. Every key for which events are - * reported to MIDP applications is assigned a key code. The key code values - * are unique for each hardware key unless two keys are obvious synonyms for - * each other. MIDP defines the following key codes: KEY_NUM0, KEY_NUM1, - * KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, KEY_NUM7, KEY_NUM8, - * KEY_NUM9, KEY_STAR, and KEY_POUND. (These key codes correspond to keys on - * a ITU-T standard telephone keypad.) Other keys may be present on the - * keyboard, and they will generally have key codes distinct from those list - * above. In order to guarantee portability, applications should use only - * the standard key codes. - * - * The standard key codes' values are equal to the Unicode encoding for the - * character that represents the key. If the device includes any other keys - * that have an obvious correspondence to a Unicode character, their key - * code values should equal the Unicode encoding for that character. For - * keys that have no corresponding Unicode character, the implementation - * must use negative values. Zero is defined to be an invalid key code." - * - * Because the MIDP implementation is our reference and that implementation - * does not interpret the given keycodes we behave alike and pass on the - * unicode values. - */ - final static int internalKeyCodeTranslate(int keyCode) { - /** - * make sure these important keys have a negative value when passed to - * Codename One or they might be interpreted as characters. - */ - switch (keyCode) { - case KeyEvent.KEYCODE_DPAD_DOWN: - return AndroidImplementation.DROID_IMPL_KEY_DOWN; - case KeyEvent.KEYCODE_DPAD_UP: - return AndroidImplementation.DROID_IMPL_KEY_UP; - case KeyEvent.KEYCODE_DPAD_LEFT: - return AndroidImplementation.DROID_IMPL_KEY_LEFT; - case KeyEvent.KEYCODE_DPAD_RIGHT: - return AndroidImplementation.DROID_IMPL_KEY_RIGHT; - case KeyEvent.KEYCODE_DPAD_CENTER: - return AndroidImplementation.DROID_IMPL_KEY_FIRE; - case KeyEvent.KEYCODE_MENU: - return AndroidImplementation.DROID_IMPL_KEY_MENU; - case KeyEvent.KEYCODE_CLEAR: - return AndroidImplementation.DROID_IMPL_KEY_CLEAR; - case KeyEvent.KEYCODE_DEL: - return AndroidImplementation.DROID_IMPL_KEY_BACKSPACE; - case KeyEvent.KEYCODE_BACK: - return AndroidImplementation.DROID_IMPL_KEY_BACK; - case KeyEvent.KEYCODE_ENTER: - case KeyEvent.KEYCODE_NUMPAD_ENTER: - return AndroidImplementation.DROID_IMPL_KEY_ENTER; - case KeyEvent.KEYCODE_TAB: - return AndroidImplementation.DROID_IMPL_KEY_TAB; - case KeyEvent.KEYCODE_ESCAPE: - return AndroidImplementation.DROID_IMPL_KEY_ESCAPE; - case KeyEvent.KEYCODE_MOVE_HOME: - return AndroidImplementation.DROID_IMPL_KEY_HOME; - case KeyEvent.KEYCODE_MOVE_END: - return AndroidImplementation.DROID_IMPL_KEY_END; - case KeyEvent.KEYCODE_PAGE_UP: - return AndroidImplementation.DROID_IMPL_KEY_PAGE_UP; - case KeyEvent.KEYCODE_PAGE_DOWN: - return AndroidImplementation.DROID_IMPL_KEY_PAGE_DOWN; - case KeyEvent.KEYCODE_INSERT: - return AndroidImplementation.DROID_IMPL_KEY_INSERT; - case KeyEvent.KEYCODE_FORWARD_DEL: - return AndroidImplementation.DROID_IMPL_KEY_FORWARD_DEL; - case KeyEvent.KEYCODE_F1: - return AndroidImplementation.DROID_IMPL_KEY_F1; - case KeyEvent.KEYCODE_F2: - return AndroidImplementation.DROID_IMPL_KEY_F2; - case KeyEvent.KEYCODE_F3: - return AndroidImplementation.DROID_IMPL_KEY_F3; - case KeyEvent.KEYCODE_F4: - return AndroidImplementation.DROID_IMPL_KEY_F4; - case KeyEvent.KEYCODE_F5: - return AndroidImplementation.DROID_IMPL_KEY_F5; - case KeyEvent.KEYCODE_F6: - return AndroidImplementation.DROID_IMPL_KEY_F6; - case KeyEvent.KEYCODE_F7: - return AndroidImplementation.DROID_IMPL_KEY_F7; - case KeyEvent.KEYCODE_F8: - return AndroidImplementation.DROID_IMPL_KEY_F8; - case KeyEvent.KEYCODE_F9: - return AndroidImplementation.DROID_IMPL_KEY_F9; - case KeyEvent.KEYCODE_F10: - return AndroidImplementation.DROID_IMPL_KEY_F10; - case KeyEvent.KEYCODE_F11: - return AndroidImplementation.DROID_IMPL_KEY_F11; - case KeyEvent.KEYCODE_F12: - return AndroidImplementation.DROID_IMPL_KEY_F12; - default: - return keyCode; - } - } - - public boolean onKeyUpDown(boolean down, int keyCode, KeyEvent event) { - // Capture the raw Android keycode before translation so we can ask the - // KeyEvent for the unicode mapping (event.getUnicodeChar expects the - // device's native keycode, not our negative sentinels). - final int rawKeyCode = keyCode; - keyCode = internalKeyCodeTranslate(keyCode); - - switch (rawKeyCode) { - case KeyEvent.KEYCODE_VOLUME_DOWN: - case KeyEvent.KEYCODE_VOLUME_UP: - case KeyEvent.KEYCODE_SEARCH: - case KeyEvent.KEYCODE_SHIFT_LEFT: - case KeyEvent.KEYCODE_SHIFT_RIGHT: - case KeyEvent.KEYCODE_ALT_LEFT: - case KeyEvent.KEYCODE_ALT_RIGHT: - case KeyEvent.KEYCODE_CTRL_LEFT: - case KeyEvent.KEYCODE_CTRL_RIGHT: - case KeyEvent.KEYCODE_META_LEFT: - case KeyEvent.KEYCODE_META_RIGHT: - case KeyEvent.KEYCODE_FUNCTION: - case KeyEvent.KEYCODE_CAPS_LOCK: - case KeyEvent.KEYCODE_NUM_LOCK: - case KeyEvent.KEYCODE_SCROLL_LOCK: - case KeyEvent.KEYCODE_SYM: - return false; - default: - } - - if (this.implementation.getCurrentForm() == null) { - /** - * make sure a form has been set before we can send events to the - * EDT. if we send events before the form has been set we might - * deadlock! - */ - return true; - } - - // Hardware (Bluetooth / Chromebook) keys bypass the IME and would otherwise be - // dropped while a pure-editor input session is bound (the editor's raw key path is - // disabled when the platform session is active). Route them through the same - // translation the IME-synthesized keys use. - if (AndroidImplementation.routeHardwareKeyToActiveClient(down, event)) { - return true; - } - - // ENTER is gated for back-compat: on touch keyboards Enter is the IME - // "done" action, so apps historically had to opt in via sendEnterKey. - // Default it on when a hardware (alpha) keyboard generated the event - // so BT/Chromebook keyboards just work. - if (keyCode == AndroidImplementation.DROID_IMPL_KEY_ENTER) { - boolean optIn = Display.getInstance().getProperty("sendEnterKey", "false").equals("true"); - if (!optIn && !isHardwareKeyboardEvent(event)) { - return false; - } - } - - if (event.getRepeatCount() > 0) { - // skip repeats - return true; - } - - if (keyCode == AndroidImplementation.DROID_IMPL_KEY_FIRE) { - this.fireKeyDown = down; - } else if (keyCode == AndroidImplementation.DROID_IMPL_KEY_DOWN - || keyCode == AndroidImplementation.DROID_IMPL_KEY_UP - || keyCode == AndroidImplementation.DROID_IMPL_KEY_LEFT - || keyCode == AndroidImplementation.DROID_IMPL_KEY_RIGHT) { - if (this.fireKeyDown) { - /** - * we keep track of trackball press/release. while it is pressed - * we drop directional movements. these movements are most - * likely not intended. if the device has no trackball i see no - * situation where this additional behavior could hurt. - */ - return true; - } - } - - // Any key our translator mapped to a negative CN1 sentinel is forwarded - // verbatim. The MENU sentinel still defers to the platform when native - // commands are enabled. - if (keyCode < 0) { - if (keyCode == AndroidImplementation.DROID_IMPL_KEY_MENU - && Display.getInstance().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE) { - return false; - } - if (down) { - Display.getInstance().keyPressed(keyCode); - } else { - Display.getInstance().keyReleased(keyCode); - } - return true; - } - - /** - * Codename One's TextField does not seem to work well if two - * keyup-keydown sequences of different keys are not strictly - * sequential. so we pass the up event of a character right - * after the down event. this is exactly the behavior of the - * BlackBerry implementation from this repository and has worked - * well for me. i guess this should be changed as soon as the - * TextField changes. - */ - // Use the KeyEvent's own device mapping rather than the cached - // BUILT_IN_KEYBOARD map: BT/USB keyboards on Android resolve their - // own layout through KeyEvent.getUnicodeChar, including the full - // meta state (SHIFT/ALT/CTRL/FN/CAPS). - final int nextchar = event.getUnicodeChar(event.getMetaState()); - if (nextchar == 0) { - // Non-printable key we don't translate (e.g. KEYCODE_BREAK, - // media keys). Consume it silently rather than firing keyPressed(0). - return true; - } - if (down) { - Display.getInstance().keyPressed(nextchar); - } else { - Display.getInstance().keyReleased(nextchar); - } - return true; - } - - private static boolean isHardwareKeyboardEvent(KeyEvent event) { - android.view.InputDevice device = event.getDevice(); - if (device != null) { - return device.getKeyboardType() == android.view.KeyCharacterMap.ALPHA; - } - return event.getDeviceId() != android.view.KeyCharacterMap.VIRTUAL_KEYBOARD; - } - - private boolean cn1GrabbedPointer = false; - //private boolean nativePeerGrabbedPointer = false; - - public boolean onTouchEvent(MotionEvent event) { - - if (this.implementation.getCurrentForm() == null) { - /** - * make sure a form has been set before we can send events to the - * EDT. if we send events before the form has been set we might - * deadlock! - */ - return true; - } - if (event.getAction() == MotionEvent.ACTION_UP) { - // EditText re-summons a dismissed keyboard on every tap; give the pure - // editors the same behavior while their input session is bound - AndroidImplementation.showSoftInputForActiveClient(); - } - - - - int[] x = null; - int[] y = null; - int size = event.getPointerCount(); - if (size > 1) { - x = new int[size]; - y = new int[size]; - for (int i = 0; i < size; i++) { - x[i] = (int) event.getX(i); - y[i] = (int) event.getY(i); - } - } - /* - if (!cn1GrabbedPointer) { - - if (x == null) { - Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); - if (componentAt != null && (componentAt instanceof PeerComponent)) { - - if (event.getAction() == MotionEvent.ACTION_DOWN) { - //nativePeerGrabbedPointer = true; - } else if (event.getAction() == MotionEvent.ACTION_UP) { - //nativePeerGrabbedPointer = false; - } - return false; - } - - } else { - Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); - if (componentAt != null && (componentAt instanceof PeerComponent)) { - if (event.getAction() == MotionEvent.ACTION_DOWN) { - nativePeerGrabbedPointer = true; - } else if (event.getAction() == MotionEvent.ACTION_UP) { - nativePeerGrabbedPointer = false; - } - return false; - } - } - } - */ - - //if (nativePeerGrabbedPointer) { - // return false; - //} - Component componentAt; - try { - if (x == null) { - componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); - } else { - componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); - } - } catch (Throwable t) { - // Since this is is an EDT violation, we may get an exception - // Just consume it - componentAt = null; - } - boolean isPeer = (componentAt instanceof PeerComponent); - if (isPeer) { - int primaryX = x == null ? (int) event.getX() : x[0]; - int primaryY = y == null ? (int) event.getY() : y[0]; - isPeer = !Sheet.isSheetVisibleAt(primaryX, primaryY); - } - boolean consumeEvent = !isPeer || cn1GrabbedPointer; - - updatePointerMetadata(event, false); - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - if (x == null) { - this.implementation.pointerPressed((int) event.getX(), (int) event.getY()); - } else { - this.implementation.pointerPressed(x, y); - } - if (!isPeer) cn1GrabbedPointer = true; - break; - case MotionEvent.ACTION_UP: - if (x == null) { - this.implementation.pointerReleased((int) event.getX(), (int) event.getY()); - } else { - this.implementation.pointerReleased(x, y); - } - cn1GrabbedPointer = false; - break; - case MotionEvent.ACTION_CANCEL: - cn1GrabbedPointer = false; - break; - case MotionEvent.ACTION_MOVE: - if (x == null) { - this.implementation.pointerDragged((int) event.getX(), (int) event.getY()); - } else { - this.implementation.pointerDragged(x, y); - } - break; - } - - return consumeEvent; - } - - /** - * Routes Android hover events (mouse / stylus moving over the surface - * without a button pressed) into Codename One's pointerHover pipeline so - * external pointing devices on Android (BT mouse, Chromebook trackpad, - * stylus) drive hover-aware components. - */ - public boolean onHoverEvent(MotionEvent event) { - if (this.implementation.getCurrentForm() == null) { - return false; - } - final int x = (int) event.getX(); - final int y = (int) event.getY(); - updatePointerMetadata(event, true); - switch (event.getActionMasked()) { - case MotionEvent.ACTION_HOVER_ENTER: - this.implementation.pointerHoverPressed(x, y); - return true; - case MotionEvent.ACTION_HOVER_MOVE: - this.implementation.pointerHover(x, y); - return true; - case MotionEvent.ACTION_HOVER_EXIT: - this.implementation.pointerHoverReleased(x, y); - return true; - } - return false; - } - - /** - * Routes Android generic motion events into Codename One. This captures the - * mouse wheel and trackpad scroll axes (vertical and horizontal) from - * external pointing devices (BT mouse, Chromebook trackpad, DeX) which are - * not delivered through onTouchEvent. - */ - public boolean onGenericMotionEvent(MotionEvent event) { - if (this.implementation.getCurrentForm() == null) { - return false; - } - if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { - float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL); - float hscroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL); - if (vscroll == 0 && hscroll == 0) { - return false; - } - int x = (int) event.getX(); - int y = (int) event.getY(); - // A positive scrollY reveals content above (drag down); Android reports a - // positive VSCROLL when scrolling away from the user, so negate to match. - int step = this.implementation.convertToPixels(20, true); - int scrollY = Math.round(-vscroll * step); - int scrollX = Math.round(-hscroll * step); - this.implementation.pointerWheelMoved(x, y, scrollX, scrollY, true, motionModifierMask(event)); - return true; - } - return false; - } - - /** - * Translates the Android MotionEvent tool type, pressure, contact size, tilt - * and button state into the cross-platform pointer metadata so the - * multi-button mouse and stylus APIs work on Android. When hovering is true - * the metadata is flagged as a hover (no contact). - */ - private void updatePointerMetadata(MotionEvent event, boolean hovering) { - int toolType; - try { - toolType = event.getToolType(0); - } catch (Throwable t) { - toolType = MotionEvent.TOOL_TYPE_UNKNOWN; - } - int type; - switch (toolType) { - case MotionEvent.TOOL_TYPE_STYLUS: - type = com.codename1.ui.events.PointerEvent.TYPE_STYLUS; - break; - case MotionEvent.TOOL_TYPE_ERASER: - type = com.codename1.ui.events.PointerEvent.TYPE_ERASER; - break; - case MotionEvent.TOOL_TYPE_MOUSE: - type = com.codename1.ui.events.PointerEvent.TYPE_MOUSE; - break; - case MotionEvent.TOOL_TYPE_FINGER: - type = com.codename1.ui.events.PointerEvent.TYPE_TOUCH; - break; - default: - type = com.codename1.ui.events.PointerEvent.TYPE_UNKNOWN; - break; - } - float pressure = event.getPressure(0); - if (pressure <= 0) { - pressure = 1f; - } - float contactSize = event.getSize(0); - float tiltX = (float) Math.toDegrees(event.getAxisValue(MotionEvent.AXIS_TILT, 0)); - - int buttonState = event.getButtonState(); - int mask = 0; - if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_PRIMARY; - } - if ((buttonState & MotionEvent.BUTTON_SECONDARY) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_SECONDARY; - } - if ((buttonState & MotionEvent.BUTTON_TERTIARY) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_MIDDLE; - } - if ((buttonState & MotionEvent.BUTTON_BACK) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_BACK; - } - if ((buttonState & MotionEvent.BUTTON_FORWARD) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_FORWARD; - } - int button = com.codename1.ui.events.PointerEvent.BUTTON_PRIMARY; - if ((mask & com.codename1.ui.events.PointerEvent.MASK_SECONDARY) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_SECONDARY; - } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_MIDDLE) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_MIDDLE; - } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_BACK) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_BACK; - } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_FORWARD) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_FORWARD; - } else if (mask == 0) { - mask = com.codename1.ui.events.PointerEvent.MASK_PRIMARY; - } - this.implementation.setPointerEventMetadata(button, mask, type, pressure, tiltX, 0, contactSize, - motionModifierMask(event), hovering); - } - - /** - * Builds the cross-platform keyboard modifier mask from an Android MotionEvent meta state. - */ - private int motionModifierMask(MotionEvent event) { - int meta = event.getMetaState(); - int modifiers = 0; - if ((meta & android.view.KeyEvent.META_SHIFT_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_SHIFT; - } - if ((meta & android.view.KeyEvent.META_CTRL_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_CONTROL; - } - if ((meta & android.view.KeyEvent.META_ALT_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_ALT; - } - if ((meta & android.view.KeyEvent.META_META_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_META; - } - return modifiers; - } - - public AndroidGraphics getGraphics() { - return buffy; - } - - public int getViewHeight() { - return height; - } - - public int getViewWidth() { - return width; - } - - public Rect getSafeArea() { - return safeArea; - } - - public void setInputType(EditorInfo editorInfo) { - - /** - * do not use the enter key to fire some kind of action! - */ -// editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; - Component txtCmp = Display.getInstance().getCurrent().getFocused(); - if (txtCmp != null && txtCmp instanceof TextArea) { - TextArea txt = (TextArea) txtCmp; - if (txt.isSingleLineTextArea()) { - editorInfo.imeOptions |= EditorInfo.IME_ACTION_DONE; - - } else { - editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; - } - int inputType = 0; - int constraint = txt.getConstraint(); - if ((constraint & TextArea.PASSWORD) == TextArea.PASSWORD) { - constraint = constraint ^ TextArea.PASSWORD; - } - switch (constraint) { - case TextArea.NUMERIC: - inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_SIGNED; - break; - case TextArea.DECIMAL: - inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_DECIMAL; - break; - case TextArea.PHONENUMBER: - inputType = EditorInfo.TYPE_CLASS_PHONE; - break; - case TextArea.EMAILADDR: - inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; - break; - case TextArea.URL: - inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_URI; - break; - default: - inputType = EditorInfo.TYPE_CLASS_TEXT; - break; - - } - - editorInfo.inputType = inputType; - } - } - - -} +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.util.Log; +import android.view.*; +import android.view.inputmethod.EditorInfo; +import android.os.Build; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.PeerComponent; +import com.codename1.ui.Sheet; +import com.codename1.ui.TextArea; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import java.lang.reflect.Method; + + +/** + * + * @author Chen + */ +public class CodenameOneView { + + int width = 1; + int height = 1; + Bitmap bitmap; + AndroidGraphics buffy = null; + private Canvas canvas; + private AndroidImplementation implementation = null; + private final Rect bounds = new Rect(); + private boolean fireKeyDown = false; + //private volatile boolean created = false; + private boolean drawing; + + private final Rect safeArea = new Rect(); + + private static final int VERSION_CODE_P = 28; + private static final int VERSION_CODE_M = 23; + + public CodenameOneView(Activity activity, View androidView, AndroidImplementation implementation, boolean drawing) { + + this.implementation = implementation; + this.drawing = drawing; + androidView.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.FILL_PARENT, + ViewGroup.LayoutParams.FILL_PARENT)); + androidView.setFocusable(true); + androidView.setFocusableInTouchMode(true); + androidView.setEnabled(true); + androidView.setClickable(true); + androidView.setLongClickable(false); + + /** + * tell the system that we do our own caching and it does not need to + * use an extra offscreen bitmap. + */ + if(!drawing) { + androidView.setWillNotCacheDrawing(false); + androidView.setWillNotDraw(true); + this.buffy = new AndroidGraphics(implementation, null, false); + } + + /** + * From the docs: "Change whether this view is one of the set of + * scrollable containers in its window. This will be used to determine + * whether the window can resize or must pan when a soft input area is + * open -- scrollable containers allow the window to use resize mode + * since the container will appropriately shrink. " + */ + androidView.setScrollContainer(true); + + android.view.Display androidDisplay = ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + width = androidDisplay.getWidth(); + height = androidDisplay.getHeight(); + View rootView = activity.getWindow().getDecorView(); + rootView.post(new Runnable() { + public void run() { + updateSafeArea(); + } + }); + initBitmaps(width, height); + } + + public boolean isOpaque() { + return true; + } + + public void onSurfaceChanged(final int w, final int h) { + if(!Display.isInitialized()) { + return; + } + Display.getInstance().callSerially(new Runnable() { + + public void run() { + handleSizeChange(w, h); + } + }); + } + + public void onSurfaceCreated() { + this.visibilityChangedTo(true); + } + + public void onSurfaceDestroyed() { + this.visibilityChangedTo(false); + } + + private void initBitmaps(int w, int h) { + if(!drawing) { + this.bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + this.canvas = new Canvas(this.bitmap); + this.buffy.setCanvas(this.canvas); + } + } + + public void visibilityChangedTo(boolean visible) { + if (this.implementation.getCurrentForm() == null) { + return; + } + if (visible) { + this.implementation.showNotifyPublic(); + // request a full repaint as our surfaceview is most likely + // black if this app comes back from the background. + this.implementation.getCurrentForm().repaint(); + } else { + this.implementation.hideNotifyPublic(); + } + } + + private void updateSafeArea() { + final Activity activity = CodenameOneView.this.implementation.getActivity(); + final Rect rect = this.safeArea; + final View rootView = activity.getWindow().getDecorView(); + if (Build.VERSION.SDK_INT >= VERSION_CODE_P) { + try { + Method getRootWindowInsetsMethod = View.class.getMethod("getRootWindowInsets"); + Object insets = getRootWindowInsetsMethod.invoke(rootView); + if (insets != null) { + Class windowInsetsClass = Class.forName("android.view.WindowInsets"); + Method getDisplayCutoutMethod = windowInsetsClass.getMethod("getDisplayCutout"); + Object cutout = getDisplayCutoutMethod.invoke(insets); + + int left = 0; + int top = 0; + int right = 0; + int bottom = 0; + if (cutout != null) { + Class displayCutoutClass = Class.forName("android.view.DisplayCutout"); + Method getSafeInsetLeft = displayCutoutClass.getMethod("getSafeInsetLeft"); + Method getSafeInsetTop = displayCutoutClass.getMethod("getSafeInsetTop"); + Method getSafeInsetRight = displayCutoutClass.getMethod("getSafeInsetRight"); + Method getSafeInsetBottom = displayCutoutClass.getMethod("getSafeInsetBottom"); + left = ((Integer) getSafeInsetLeft.invoke(cutout)).intValue(); + top = ((Integer) getSafeInsetTop.invoke(cutout)).intValue(); + right = ((Integer) getSafeInsetRight.invoke(cutout)).intValue(); + bottom = ((Integer) getSafeInsetBottom.invoke(cutout)).intValue(); + } + + boolean imeVisible = false; + try { + Method isVisibleMethod = insets.getClass().getMethod("isVisible", int.class); + Class typeClass = Class.forName("android.view.WindowInsets$Type"); + int imeType = ((Integer) typeClass.getMethod("ime").invoke(null)).intValue(); + imeVisible = (Boolean) isVisibleMethod.invoke(insets, imeType); + } catch (Throwable t) { + // Fallback or log + } + + Rect systemBarInsets = AndroidImplementation.getSystemBarInsets(rootView); + top = Math.max(systemBarInsets.top, top); + if (imeVisible) { + // Avoid double-counting the bottom gesture bar + bottom = Math.max(bottom, 0); + } else { + bottom = Math.max(systemBarInsets.bottom, bottom); + } + left = Math.max(systemBarInsets.left, left); + right = Math.max(systemBarInsets.right, right); + + if (!AndroidImplementation.isImmersive()) { + top -= systemBarInsets.top; + if (!imeVisible) { + bottom -= systemBarInsets.bottom; + } + left -= systemBarInsets.left; + right -= systemBarInsets.right; + } + + // Only apply if at least one is non-zero + if (left != 0 || top != 0 || right != 0 || bottom != 0) { + boolean isChanged = rect.left != left + || rect.right != right + || rect.top != top + || rect.bottom != bottom; + rect.left = left; + rect.top = top; + rect.right = right; + rect.bottom = bottom; + + if (isChanged) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + AndroidImplementation.getInstance().revalidate(); + } + }); + } + } + } + } catch (Throwable e) { + rect.top = 0; + rect.left = 0; + rect.right = 0; + rect.bottom = 0; + } + + } else if (Build.VERSION.SDK_INT >= VERSION_CODE_M) { + rootView.post(new Runnable() { + public void run() { + WindowInsets insets = rootView.getRootWindowInsets(); + if (insets != null) { + rect.top = insets.getSystemWindowInsetTop(); + rect.left = insets.getSystemWindowInsetLeft();; + rect.right = insets.getSystemWindowInsetRight(); + rect.bottom = insets.getSystemWindowInsetBottom(); + } else { + rect.top = 0; + rect.left = 0; + rect.right = 0; + rect.bottom = 0; + } + // This branch assigns asynchronously, so the round inset has to be reapplied + // here -- applying it at the end of updateSafeArea would run first and be + // overwritten by the four assignments above. + applyRoundScreenInset(rect); + } + }); + } else { + // For pre-Marshmallow (API < 23), assume full screen + rect.top = 0; + rect.left = 0; + rect.right = 0; + rect.bottom = 0; + } + applyRoundScreenInset(rect); + } + + /** + * Widens the safe area to clear the curve on a round Wear OS display. + * + * A round watch face reports no display cutout, so everything above leaves the safe area at + * zero and a layout drawn to the full rectangle has its corners cut off by the bezel. The + * largest rectangle that fits inside a circle of diameter d has side d/sqrt(2), so each edge + * loses about 14.6% -- that is what is reserved here, on top of whatever the system already + * asked for. + */ + private void applyRoundScreenInset(Rect rect) { + if (!isRoundScreen()) { + return; + } + int d = Math.min(this.width, this.height); + if (d <= 0) { + return; + } + int inset = (int) Math.ceil(d * (1 - 1 / Math.sqrt(2)) / 2); + rect.left = Math.max(rect.left, inset); + rect.top = Math.max(rect.top, inset); + rect.right = Math.max(rect.right, inset); + rect.bottom = Math.max(rect.bottom, inset); + } + + /** True on a circular watch face, which is most Wear OS hardware. */ + private boolean isRoundScreen() { + try { + return this.implementation.getActivity().getResources() + .getConfiguration().isScreenRound(); + } catch (Throwable preApi23) { + return false; + } + } + + public void handleSizeChange(int w, int h) { + + if(!drawing) { + if ((this.width != w && (this.width < w || this.height < h)) + || (bitmap.getHeight() < h)) { + this.initBitmaps(w, h); + } + } + if (this.width == w && this.height == h) { + return; + } + this.width = w; + this.height = h; + + updateSafeArea(); + + Log.d("Codename One", "sizechanged: " + width + " " + height + " " + this); + if (this.implementation.getCurrentForm() == null) { + /** + * make sure a form has been set before we can send events to the + * EDT. if we send events before the form has been set we might + * deadlock! + */ + return; + } + + if (InPlaceEditView.isEditing()) { + final Form f = this.implementation.getCurrentForm(); + ActionListener sizeChanged = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + CodenameOneView.this.implementation.getActivity().runOnUiThread(new Runnable() { + + @Override + public void run() { + InPlaceEditView.reLayoutEdit(); + } + }); + f.removeSizeChangedListener(this); + } + }; + f.addSizeChangedListener(sizeChanged); + } + Display.getInstance().sizeChanged(w, h); + } + + //@Override + protected void d(Canvas canvas) { + if(!drawing) { + boolean empty = canvas.getClipBounds(bounds); + if (empty) { + // ?? + canvas.drawBitmap(bitmap, 0, 0, null); + } else { + bounds.intersect(0, 0, width, height); + canvas.drawBitmap(bitmap, bounds, bounds, null); + } + } + } + + /** + * some info from the MIDP docs about keycodes: + * + * "Applications receive keystroke events in which the individual keys are + * named within a space of key codes. Every key for which events are + * reported to MIDP applications is assigned a key code. The key code values + * are unique for each hardware key unless two keys are obvious synonyms for + * each other. MIDP defines the following key codes: KEY_NUM0, KEY_NUM1, + * KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, KEY_NUM7, KEY_NUM8, + * KEY_NUM9, KEY_STAR, and KEY_POUND. (These key codes correspond to keys on + * a ITU-T standard telephone keypad.) Other keys may be present on the + * keyboard, and they will generally have key codes distinct from those list + * above. In order to guarantee portability, applications should use only + * the standard key codes. + * + * The standard key codes' values are equal to the Unicode encoding for the + * character that represents the key. If the device includes any other keys + * that have an obvious correspondence to a Unicode character, their key + * code values should equal the Unicode encoding for that character. For + * keys that have no corresponding Unicode character, the implementation + * must use negative values. Zero is defined to be an invalid key code." + * + * Because the MIDP implementation is our reference and that implementation + * does not interpret the given keycodes we behave alike and pass on the + * unicode values. + */ + final static int internalKeyCodeTranslate(int keyCode) { + /** + * make sure these important keys have a negative value when passed to + * Codename One or they might be interpreted as characters. + */ + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_DOWN: + return AndroidImplementation.DROID_IMPL_KEY_DOWN; + case KeyEvent.KEYCODE_DPAD_UP: + return AndroidImplementation.DROID_IMPL_KEY_UP; + case KeyEvent.KEYCODE_DPAD_LEFT: + return AndroidImplementation.DROID_IMPL_KEY_LEFT; + case KeyEvent.KEYCODE_DPAD_RIGHT: + return AndroidImplementation.DROID_IMPL_KEY_RIGHT; + case KeyEvent.KEYCODE_DPAD_CENTER: + return AndroidImplementation.DROID_IMPL_KEY_FIRE; + case KeyEvent.KEYCODE_MENU: + return AndroidImplementation.DROID_IMPL_KEY_MENU; + case KeyEvent.KEYCODE_CLEAR: + return AndroidImplementation.DROID_IMPL_KEY_CLEAR; + case KeyEvent.KEYCODE_DEL: + return AndroidImplementation.DROID_IMPL_KEY_BACKSPACE; + case KeyEvent.KEYCODE_BACK: + return AndroidImplementation.DROID_IMPL_KEY_BACK; + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_NUMPAD_ENTER: + return AndroidImplementation.DROID_IMPL_KEY_ENTER; + case KeyEvent.KEYCODE_TAB: + return AndroidImplementation.DROID_IMPL_KEY_TAB; + case KeyEvent.KEYCODE_ESCAPE: + return AndroidImplementation.DROID_IMPL_KEY_ESCAPE; + case KeyEvent.KEYCODE_MOVE_HOME: + return AndroidImplementation.DROID_IMPL_KEY_HOME; + case KeyEvent.KEYCODE_MOVE_END: + return AndroidImplementation.DROID_IMPL_KEY_END; + case KeyEvent.KEYCODE_PAGE_UP: + return AndroidImplementation.DROID_IMPL_KEY_PAGE_UP; + case KeyEvent.KEYCODE_PAGE_DOWN: + return AndroidImplementation.DROID_IMPL_KEY_PAGE_DOWN; + case KeyEvent.KEYCODE_INSERT: + return AndroidImplementation.DROID_IMPL_KEY_INSERT; + case KeyEvent.KEYCODE_FORWARD_DEL: + return AndroidImplementation.DROID_IMPL_KEY_FORWARD_DEL; + case KeyEvent.KEYCODE_F1: + return AndroidImplementation.DROID_IMPL_KEY_F1; + case KeyEvent.KEYCODE_F2: + return AndroidImplementation.DROID_IMPL_KEY_F2; + case KeyEvent.KEYCODE_F3: + return AndroidImplementation.DROID_IMPL_KEY_F3; + case KeyEvent.KEYCODE_F4: + return AndroidImplementation.DROID_IMPL_KEY_F4; + case KeyEvent.KEYCODE_F5: + return AndroidImplementation.DROID_IMPL_KEY_F5; + case KeyEvent.KEYCODE_F6: + return AndroidImplementation.DROID_IMPL_KEY_F6; + case KeyEvent.KEYCODE_F7: + return AndroidImplementation.DROID_IMPL_KEY_F7; + case KeyEvent.KEYCODE_F8: + return AndroidImplementation.DROID_IMPL_KEY_F8; + case KeyEvent.KEYCODE_F9: + return AndroidImplementation.DROID_IMPL_KEY_F9; + case KeyEvent.KEYCODE_F10: + return AndroidImplementation.DROID_IMPL_KEY_F10; + case KeyEvent.KEYCODE_F11: + return AndroidImplementation.DROID_IMPL_KEY_F11; + case KeyEvent.KEYCODE_F12: + return AndroidImplementation.DROID_IMPL_KEY_F12; + default: + return keyCode; + } + } + + public boolean onKeyUpDown(boolean down, int keyCode, KeyEvent event) { + // Capture the raw Android keycode before translation so we can ask the + // KeyEvent for the unicode mapping (event.getUnicodeChar expects the + // device's native keycode, not our negative sentinels). + final int rawKeyCode = keyCode; + keyCode = internalKeyCodeTranslate(keyCode); + + switch (rawKeyCode) { + case KeyEvent.KEYCODE_VOLUME_DOWN: + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_SEARCH: + case KeyEvent.KEYCODE_SHIFT_LEFT: + case KeyEvent.KEYCODE_SHIFT_RIGHT: + case KeyEvent.KEYCODE_ALT_LEFT: + case KeyEvent.KEYCODE_ALT_RIGHT: + case KeyEvent.KEYCODE_CTRL_LEFT: + case KeyEvent.KEYCODE_CTRL_RIGHT: + case KeyEvent.KEYCODE_META_LEFT: + case KeyEvent.KEYCODE_META_RIGHT: + case KeyEvent.KEYCODE_FUNCTION: + case KeyEvent.KEYCODE_CAPS_LOCK: + case KeyEvent.KEYCODE_NUM_LOCK: + case KeyEvent.KEYCODE_SCROLL_LOCK: + case KeyEvent.KEYCODE_SYM: + return false; + default: + } + + if (this.implementation.getCurrentForm() == null) { + /** + * make sure a form has been set before we can send events to the + * EDT. if we send events before the form has been set we might + * deadlock! + */ + return true; + } + + // Hardware (Bluetooth / Chromebook) keys bypass the IME and would otherwise be + // dropped while a pure-editor input session is bound (the editor's raw key path is + // disabled when the platform session is active). Route them through the same + // translation the IME-synthesized keys use. + if (AndroidImplementation.routeHardwareKeyToActiveClient(down, event)) { + return true; + } + + // ENTER is gated for back-compat: on touch keyboards Enter is the IME + // "done" action, so apps historically had to opt in via sendEnterKey. + // Default it on when a hardware (alpha) keyboard generated the event + // so BT/Chromebook keyboards just work. + if (keyCode == AndroidImplementation.DROID_IMPL_KEY_ENTER) { + boolean optIn = Display.getInstance().getProperty("sendEnterKey", "false").equals("true"); + if (!optIn && !isHardwareKeyboardEvent(event)) { + return false; + } + } + + if (event.getRepeatCount() > 0) { + // skip repeats + return true; + } + + if (keyCode == AndroidImplementation.DROID_IMPL_KEY_FIRE) { + this.fireKeyDown = down; + } else if (keyCode == AndroidImplementation.DROID_IMPL_KEY_DOWN + || keyCode == AndroidImplementation.DROID_IMPL_KEY_UP + || keyCode == AndroidImplementation.DROID_IMPL_KEY_LEFT + || keyCode == AndroidImplementation.DROID_IMPL_KEY_RIGHT) { + if (this.fireKeyDown) { + /** + * we keep track of trackball press/release. while it is pressed + * we drop directional movements. these movements are most + * likely not intended. if the device has no trackball i see no + * situation where this additional behavior could hurt. + */ + return true; + } + } + + // Any key our translator mapped to a negative CN1 sentinel is forwarded + // verbatim. The MENU sentinel still defers to the platform when native + // commands are enabled. + if (keyCode < 0) { + if (keyCode == AndroidImplementation.DROID_IMPL_KEY_MENU + && Display.getInstance().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE) { + return false; + } + if (down) { + Display.getInstance().keyPressed(keyCode); + } else { + Display.getInstance().keyReleased(keyCode); + } + return true; + } + + /** + * Codename One's TextField does not seem to work well if two + * keyup-keydown sequences of different keys are not strictly + * sequential. so we pass the up event of a character right + * after the down event. this is exactly the behavior of the + * BlackBerry implementation from this repository and has worked + * well for me. i guess this should be changed as soon as the + * TextField changes. + */ + // Use the KeyEvent's own device mapping rather than the cached + // BUILT_IN_KEYBOARD map: BT/USB keyboards on Android resolve their + // own layout through KeyEvent.getUnicodeChar, including the full + // meta state (SHIFT/ALT/CTRL/FN/CAPS). + final int nextchar = event.getUnicodeChar(event.getMetaState()); + if (nextchar == 0) { + // Non-printable key we don't translate (e.g. KEYCODE_BREAK, + // media keys). Consume it silently rather than firing keyPressed(0). + return true; + } + if (down) { + Display.getInstance().keyPressed(nextchar); + } else { + Display.getInstance().keyReleased(nextchar); + } + return true; + } + + private static boolean isHardwareKeyboardEvent(KeyEvent event) { + android.view.InputDevice device = event.getDevice(); + if (device != null) { + return device.getKeyboardType() == android.view.KeyCharacterMap.ALPHA; + } + return event.getDeviceId() != android.view.KeyCharacterMap.VIRTUAL_KEYBOARD; + } + + private boolean cn1GrabbedPointer = false; + //private boolean nativePeerGrabbedPointer = false; + + public boolean onTouchEvent(MotionEvent event) { + + if (this.implementation.getCurrentForm() == null) { + /** + * make sure a form has been set before we can send events to the + * EDT. if we send events before the form has been set we might + * deadlock! + */ + return true; + } + if (event.getAction() == MotionEvent.ACTION_UP) { + // EditText re-summons a dismissed keyboard on every tap; give the pure + // editors the same behavior while their input session is bound + AndroidImplementation.showSoftInputForActiveClient(); + } + + + + int[] x = null; + int[] y = null; + int size = event.getPointerCount(); + if (size > 1) { + x = new int[size]; + y = new int[size]; + for (int i = 0; i < size; i++) { + x[i] = (int) event.getX(i); + y[i] = (int) event.getY(i); + } + } + /* + if (!cn1GrabbedPointer) { + + if (x == null) { + Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); + if (componentAt != null && (componentAt instanceof PeerComponent)) { + + if (event.getAction() == MotionEvent.ACTION_DOWN) { + //nativePeerGrabbedPointer = true; + } else if (event.getAction() == MotionEvent.ACTION_UP) { + //nativePeerGrabbedPointer = false; + } + return false; + } + + } else { + Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); + if (componentAt != null && (componentAt instanceof PeerComponent)) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + nativePeerGrabbedPointer = true; + } else if (event.getAction() == MotionEvent.ACTION_UP) { + nativePeerGrabbedPointer = false; + } + return false; + } + } + } + */ + + //if (nativePeerGrabbedPointer) { + // return false; + //} + Component componentAt; + try { + if (x == null) { + componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); + } else { + componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); + } + } catch (Throwable t) { + // Since this is is an EDT violation, we may get an exception + // Just consume it + componentAt = null; + } + boolean isPeer = (componentAt instanceof PeerComponent); + if (isPeer) { + int primaryX = x == null ? (int) event.getX() : x[0]; + int primaryY = y == null ? (int) event.getY() : y[0]; + isPeer = !Sheet.isSheetVisibleAt(primaryX, primaryY); + } + boolean consumeEvent = !isPeer || cn1GrabbedPointer; + + updatePointerMetadata(event, false); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (x == null) { + this.implementation.pointerPressed((int) event.getX(), (int) event.getY()); + } else { + this.implementation.pointerPressed(x, y); + } + if (!isPeer) cn1GrabbedPointer = true; + break; + case MotionEvent.ACTION_UP: + if (x == null) { + this.implementation.pointerReleased((int) event.getX(), (int) event.getY()); + } else { + this.implementation.pointerReleased(x, y); + } + cn1GrabbedPointer = false; + break; + case MotionEvent.ACTION_CANCEL: + cn1GrabbedPointer = false; + break; + case MotionEvent.ACTION_MOVE: + if (x == null) { + this.implementation.pointerDragged((int) event.getX(), (int) event.getY()); + } else { + this.implementation.pointerDragged(x, y); + } + break; + } + + return consumeEvent; + } + + /** + * Routes Android hover events (mouse / stylus moving over the surface + * without a button pressed) into Codename One's pointerHover pipeline so + * external pointing devices on Android (BT mouse, Chromebook trackpad, + * stylus) drive hover-aware components. + */ + public boolean onHoverEvent(MotionEvent event) { + if (this.implementation.getCurrentForm() == null) { + return false; + } + final int x = (int) event.getX(); + final int y = (int) event.getY(); + updatePointerMetadata(event, true); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_HOVER_ENTER: + this.implementation.pointerHoverPressed(x, y); + return true; + case MotionEvent.ACTION_HOVER_MOVE: + this.implementation.pointerHover(x, y); + return true; + case MotionEvent.ACTION_HOVER_EXIT: + this.implementation.pointerHoverReleased(x, y); + return true; + } + return false; + } + + /** + * Routes Android generic motion events into Codename One. This captures the + * mouse wheel and trackpad scroll axes (vertical and horizontal) from + * external pointing devices (BT mouse, Chromebook trackpad, DeX) which are + * not delivered through onTouchEvent, and the Wear OS rotary input (the + * rotating side button / bezel) which reports on a different axis again. + */ + public boolean onGenericMotionEvent(MotionEvent event) { + if (this.implementation.getCurrentForm() == null) { + return false; + } + if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { + int x = (int) event.getX(); + int y = (int) event.getY(); + int step = this.implementation.convertToPixels(20, true); + + // Wear OS rotary input arrives from SOURCE_ROTARY_ENCODER on AXIS_SCROLL, not on the + // mouse axes below -- a watch app that only handled those could not scroll at all. It + // is the Digital Crown's counterpart, so it feeds the same wheel path, and Android + // scales it by the device's own scroll factor rather than a fixed step. + if (isRotaryEncoder(event)) { + float rotary = event.getAxisValue(MotionEvent.AXIS_SCROLL); + if (rotary == 0) { + return false; + } + int scrollY = Math.round(-rotary * rotaryScrollFactor(step)); + // NOT event.getX()/getY(). A rotary event is not a pointer event: it carries no + // meaningful position and in practice reports (0,0), so feeding those coordinates + // to pointerWheelMoved synthesized a drag over whatever occupies the top-left -- + // usually the title bar -- and the crown scrolled nothing on most screens. + // + // The crown scrolls what has focus, so aim at the focused component's scrollable + // ancestor instead, falling back to the content pane when nothing is focused. + int[] target = rotaryTarget(); + this.implementation.pointerWheelMoved(target[0], target[1], 0, scrollY, true, + motionModifierMask(event)); + return true; + } + + float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + float hscroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL); + if (vscroll == 0 && hscroll == 0) { + return false; + } + // A positive scrollY reveals content above (drag down); Android reports a + // positive VSCROLL when scrolling away from the user, so negate to match. + int scrollY = Math.round(-vscroll * step); + int scrollX = Math.round(-hscroll * step); + this.implementation.pointerWheelMoved(x, y, scrollX, scrollY, true, motionModifierMask(event)); + return true; + } + return false; + } + + /** + * A point inside whatever the crown should scroll. + * + *

Walks up from the focused component to the nearest vertically scrollable ancestor and + * returns its centre; without a focus, the content pane's centre. Any point inside the right + * container will do -- the wheel path only uses it to decide which component receives the + * scroll -- so the centre is chosen because it cannot land on a border or a child that happens + * to sit at the container's origin.

+ */ + private int[] rotaryTarget() { + com.codename1.ui.Form f = this.implementation.getCurrentForm(); + com.codename1.ui.Component anchor = null; + if (f != null) { + com.codename1.ui.Component focused = f.getFocused(); + com.codename1.ui.Container c = focused == null ? null : focused.getParent(); + while (c != null && !c.isScrollableY()) { + c = c.getParent(); + } + anchor = c != null ? (com.codename1.ui.Component) c : f.getContentPane(); + } + if (anchor == null) { + return new int[] {0, 0}; + } + return new int[] { + anchor.getAbsoluteX() + anchor.getWidth() / 2, + anchor.getAbsoluteY() + anchor.getHeight() / 2 + }; + } + + /** + * True when the event came from the Wear OS rotary input. SOURCE_ROTARY_ENCODER and AXIS_SCROLL + * both arrived in API 23, which is also the Wear OS standalone baseline, so older devices + * simply never match. + */ + private static boolean isRotaryEncoder(MotionEvent event) { + if (android.os.Build.VERSION.SDK_INT < 23) { + return false; + } + return (event.getSource() & InputDevice.SOURCE_ROTARY_ENCODER) == InputDevice.SOURCE_ROTARY_ENCODER; + } + + /** + * How many pixels one detent of rotary travel should scroll. Android publishes a per-device + * factor for exactly this; fall back to the shared wheel step when it is unavailable so the + * gesture still does something sensible. + */ + private float rotaryScrollFactor(int fallbackStep) { + try { + float f = ViewConfiguration.get(this.implementation.getActivity()) + .getScaledVerticalScrollFactor(); + if (f > 0) { + return f; + } + } catch (Throwable notAvailable) { + // Pre-API-26 or an unusual device configuration. + } + return fallbackStep; + } + + /** + * Translates the Android MotionEvent tool type, pressure, contact size, tilt + * and button state into the cross-platform pointer metadata so the + * multi-button mouse and stylus APIs work on Android. When hovering is true + * the metadata is flagged as a hover (no contact). + */ + private void updatePointerMetadata(MotionEvent event, boolean hovering) { + int toolType; + try { + toolType = event.getToolType(0); + } catch (Throwable t) { + toolType = MotionEvent.TOOL_TYPE_UNKNOWN; + } + int type; + switch (toolType) { + case MotionEvent.TOOL_TYPE_STYLUS: + type = com.codename1.ui.events.PointerEvent.TYPE_STYLUS; + break; + case MotionEvent.TOOL_TYPE_ERASER: + type = com.codename1.ui.events.PointerEvent.TYPE_ERASER; + break; + case MotionEvent.TOOL_TYPE_MOUSE: + type = com.codename1.ui.events.PointerEvent.TYPE_MOUSE; + break; + case MotionEvent.TOOL_TYPE_FINGER: + type = com.codename1.ui.events.PointerEvent.TYPE_TOUCH; + break; + default: + type = com.codename1.ui.events.PointerEvent.TYPE_UNKNOWN; + break; + } + float pressure = event.getPressure(0); + if (pressure <= 0) { + pressure = 1f; + } + float contactSize = event.getSize(0); + float tiltX = (float) Math.toDegrees(event.getAxisValue(MotionEvent.AXIS_TILT, 0)); + + int buttonState = event.getButtonState(); + int mask = 0; + if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_PRIMARY; + } + if ((buttonState & MotionEvent.BUTTON_SECONDARY) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_SECONDARY; + } + if ((buttonState & MotionEvent.BUTTON_TERTIARY) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_MIDDLE; + } + if ((buttonState & MotionEvent.BUTTON_BACK) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_BACK; + } + if ((buttonState & MotionEvent.BUTTON_FORWARD) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_FORWARD; + } + int button = com.codename1.ui.events.PointerEvent.BUTTON_PRIMARY; + if ((mask & com.codename1.ui.events.PointerEvent.MASK_SECONDARY) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_SECONDARY; + } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_MIDDLE) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_MIDDLE; + } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_BACK) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_BACK; + } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_FORWARD) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_FORWARD; + } else if (mask == 0) { + mask = com.codename1.ui.events.PointerEvent.MASK_PRIMARY; + } + this.implementation.setPointerEventMetadata(button, mask, type, pressure, tiltX, 0, contactSize, + motionModifierMask(event), hovering); + } + + /** + * Builds the cross-platform keyboard modifier mask from an Android MotionEvent meta state. + */ + private int motionModifierMask(MotionEvent event) { + int meta = event.getMetaState(); + int modifiers = 0; + if ((meta & android.view.KeyEvent.META_SHIFT_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_SHIFT; + } + if ((meta & android.view.KeyEvent.META_CTRL_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_CONTROL; + } + if ((meta & android.view.KeyEvent.META_ALT_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_ALT; + } + if ((meta & android.view.KeyEvent.META_META_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_META; + } + return modifiers; + } + + public AndroidGraphics getGraphics() { + return buffy; + } + + public int getViewHeight() { + return height; + } + + public int getViewWidth() { + return width; + } + + public Rect getSafeArea() { + return safeArea; + } + + public void setInputType(EditorInfo editorInfo) { + + /** + * do not use the enter key to fire some kind of action! + */ +// editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; + Component txtCmp = Display.getInstance().getCurrent().getFocused(); + if (txtCmp != null && txtCmp instanceof TextArea) { + TextArea txt = (TextArea) txtCmp; + if (txt.isSingleLineTextArea()) { + editorInfo.imeOptions |= EditorInfo.IME_ACTION_DONE; + + } else { + editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; + } + int inputType = 0; + int constraint = txt.getConstraint(); + if ((constraint & TextArea.PASSWORD) == TextArea.PASSWORD) { + constraint = constraint ^ TextArea.PASSWORD; + } + switch (constraint) { + case TextArea.NUMERIC: + inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_SIGNED; + break; + case TextArea.DECIMAL: + inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_DECIMAL; + break; + case TextArea.PHONENUMBER: + inputType = EditorInfo.TYPE_CLASS_PHONE; + break; + case TextArea.EMAILADDR: + inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + break; + case TextArea.URL: + inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_URI; + break; + default: + inputType = EditorInfo.TYPE_CLASS_TEXT; + break; + + } + + editorInfo.inputType = inputType; + } + } + + +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 6b35cb182ae..3af9567ca1f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -1,13 +1,26 @@ /* * 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. Oracle designates this + * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ + package com.codename1.impl.javase; /** @@ -95,75 +108,12 @@ static void register() { + "Android theme. (Deprecated alias: cn1.androidTheme; " + "and.hololight=true is also accepted for back-compat.)"); - // watchOS native build (Apple Watch). Adds a watchOS app target to the - // iOS Xcode project, rendering the CN1 UI via the Core Graphics backend. - set("{{@watchNative}}.label", "Apple Watch (watchOS)"); - set("{{@watchNative}}.description", - "Builds an Apple Watch app from the same project, rendering the " - + "Codename One UI on watchOS via the Core Graphics backend. The " - + "watch app is a separate arm64_32 target; in the default " - + "companion mode it is embedded in the iOS .ipa and installs " - + "with the phone app."); - - set("{{#watchNative#watchNative.enabled}}.label", "Enable watchOS target"); - set("{{#watchNative#watchNative.enabled}}.type", "Select"); - set("{{#watchNative#watchNative.enabled}}.values", "false,true"); - set("{{#watchNative#watchNative.enabled}}.description", - "When true, adds an Apple Watch app target to the generated " - + "Xcode project. Also auto-enabled whenever codename1.watchMain " - + "is declared next to codename1.mainName in " - + "codenameone_settings.properties, so the double app is produced " - + "as part of the regular iPhone build. Requires the Ruby " - + "xcodeproj gem (bundled with CocoaPods)."); - - set("{{#watchNative#watchNative.mainClass}}.label", "Watch lifecycle class"); - set("{{#watchNative#watchNative.mainClass}}.type", "String"); - set("{{#watchNative#watchNative.mainClass}}.description", - "Fully-qualified watch entry/lifecycle class. Normally set via " - + "codename1.watchMain; this hint is an override. May equal the " - + "phone main class - a distinct class lets the watch slice " - + "tree-shake from its own root. Defaults to the phone main class " - + "when watchNative.enabled=true without a watch entry."); - - set("{{#watchNative#watchNative.distribution}}.label", "Distribution"); - set("{{#watchNative#watchNative.distribution}}.type", "Select"); - set("{{#watchNative#watchNative.distribution}}.values", "companion,standalone"); - set("{{#watchNative#watchNative.distribution}}.description", - "companion = the watch app is embedded in the iOS app and " - + "installs with it (WKCompanionAppBundleIdentifier pinned to " - + "the iOS bundle). standalone = an independent watch-only app."); - - set("{{#watchNative#watchNative.bundleId}}.label", "Watch bundle identifier"); - set("{{#watchNative#watchNative.bundleId}}.type", "String"); - set("{{#watchNative#watchNative.bundleId}}.description", - "Bundle id of the watch app. Defaults to .watchkitapp."); - - set("{{#watchNative#watchNative.minDeploymentTarget}}.label", "Minimum watchOS version"); - set("{{#watchNative#watchNative.minDeploymentTarget}}.type", "String"); - set("{{#watchNative#watchNative.minDeploymentTarget}}.description", - "WATCHOS_DEPLOYMENT_TARGET for the watch target. Defaults to 10.0 " - + "(single-target WKApplication apps + WidgetKit complications)."); - - set("{{#watchNative#watchNative.teamId}}.label", "Apple team id"); - set("{{#watchNative#watchNative.teamId}}.type", "String"); - set("{{#watchNative#watchNative.teamId}}.description", - "Development team for signing the watch target. Defaults to the " - + "iOS team id (ios.teamId / ios.release.teamId)."); - - set("{{#watchNative#watchNative.displayName}}.label", "Watch app name"); - set("{{#watchNative#watchNative.displayName}}.type", "String"); - set("{{#watchNative#watchNative.displayName}}.description", - "Name shown under the watch app icon. Defaults to the app display " - + "name (codename1.displayName), then the main class name."); - - set("{{#watchNative#watchNative.embedCompanion}}.label", "Embed in iOS app"); - set("{{#watchNative#watchNative.embedCompanion}}.type", "Select"); - set("{{#watchNative#watchNative.embedCompanion}}.values", "false,true"); - set("{{#watchNative#watchNative.embedCompanion}}.description", - "When true (companion distribution), adds the watch app as a build " - + "dependency of the iOS app so the pair archives together. Off by " - + "default so the iOS build is unaffected; enable it for a packaged " - + "companion submission."); + // The wearable build has no build hints: a project declares the watch + // lifecycle class as codename1.watchMain next to codename1.mainName and + // both the Apple Watch and the Wear OS app are built from that root. + // codename1.watchStandalone says the watch app ships on its own. Both + // are entry-point settings rather than build hints, so they are edited + // on the Basic page of the settings tool. // Apple TV native build (tvOS). tvOS has UIKit + Metal but no OpenGL ES, // so it is handled like the Mac Catalyst slice: Metal renderer + GL stub @@ -214,36 +164,6 @@ static void register() { "Name shown under the tvOS app icon. Defaults to the app display " + "name (codename1.displayName), then the main class name."); - // Wear OS native build (Android). A Wear OS app is a regular Android app - // that declares the watch hardware feature; the CN1 UI renders through - // the normal Android pipeline (no separate backend, unlike watchOS). - set("{{@androidWear}}.label", "Wear OS (Android)"); - set("{{@androidWear}}.description", - "Builds the Android app as a Wear OS app: declares the watch " - + "hardware feature, marks the app standalone (runs without a " - + "paired phone app) and raises the minimum SDK to the Wear OS 2.0 " - + "baseline (API 23). CN.isWatch() returns true at runtime via " - + "PackageManager.FEATURE_WATCH. Independent of the Apple Watch " - + "build; enable both to target both wearables."); - - set("{{#androidWear#android.wear}}.label", "Enable Wear OS build"); - set("{{#androidWear#android.wear}}.type", "Select"); - set("{{#androidWear#android.wear}}.values", "false,true"); - set("{{#androidWear#android.wear}}.description", - "When true, marks the Android build as a Wear OS app (manifest " - + "uses-feature android.hardware.type.watch, standalone meta-data, " - + "minimum SDK floor API 23). With the hint off the manifest is " - + "unchanged."); - - set("{{#androidWear#android.wear.standalone}}.label", "Standalone Wear app"); - set("{{#androidWear#android.wear.standalone}}.type", "Select"); - set("{{#androidWear#android.wear.standalone}}.values", "true,false"); - set("{{#androidWear#android.wear.standalone}}.description", - "Declares the Wear app standalone (com.google.android.wearable." - + "standalone), so it installs and runs directly on the watch " - + "without a companion phone app. Defaults to true. Only applies " - + "when android.wear=true."); - // Android TV / Google TV: the same APK plus manifest metadata (Leanback // launcher category + leanback feature + optional touchscreen) and a // generated 320x180 banner. CN.isTV() returns true at runtime. diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index fbe717e5e41..ce41b1e051f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -386,6 +386,53 @@ void disconnectSimulatedCar() { } } + /// Returns the JavaSE phone-to-watch bridge, created lazily on first use. + /// + /// The bridge is live only when the project actually declares a watch app + /// (`codename1.watchMain`); without one there is nothing to pair with, so the whole + /// `com.codename1.wearable` API stays inert exactly as it would on a phone with no watch. + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + if (wearableBridge == null) { + // Deliberately the *shared* home, not this process's sandbox: the two halves have + // separate storage (see watchSandbox) but must rendezvous in one directory, which is the + // desktop stand-in for a transport the OS would provide. + // + // setAppHomeDir() is called with an absolute path in some flows (CNPanelUtil passes + // getAbsolutePath()), so prefixing user.home unconditionally would build a path like + // "$HOME//abs/path" and the two processes would never find each other. + File configured = new File(getSharedHomeDir()); + File home = configured.isAbsolute() ? configured + : new File(System.getProperty("user.home"), getSharedHomeDir()); + wearableBridge = new JavaSEWearableBridge(home, isWatchCompanionProcess(), + getWatchMainClass() != null); + } + return wearableBridge; + } + + /// Returns the project's declared watch lifecycle class, or null when it declares none. Read + /// from the same `codename1.watchMain` setting the device builds use, which the simulator + /// launcher exposes as a system property. + static String getWatchMainClass() { + // The system property is how the companion process is told what to run. A normal `mvn + // cn1:run` sets no such property, so fall back to the project settings on disk -- otherwise + // the whole watch feature would be invisible under the standard simulator launch. + String s = System.getProperty("codename1.watchMain"); + if (s == null || s.trim().length() == 0) { + Properties cnop = loadCodenameOneSettings(); + s = cnop == null ? null : cnop.getProperty("codename1.watchMain"); + } + if (s == null || s.trim().length() == 0) { + return null; + } + return s.trim(); + } + + /// True when this JVM is the watch half of a simulated pair rather than the phone half. + static boolean isWatchCompanionProcess() { + return "watch".equals(System.getProperty("cn1.wearable.side")); + } + /// Returns the JavaSE external-surfaces bridge, created lazily on first use. In simulator mode /// published widget timelines render in the Widgets preview window (Widgets menu); in desktop /// mode they render in frameless always-on-top floating windows that persist across runs. @@ -676,9 +723,52 @@ public static void setInvokePointerHover(boolean aInvokePointerHover) { private static File baseResourceDir; private static final String DEFAULT_SKIN = "/iPhoneX.skin"; + /// Skin the watch half of a simulated pair comes up on. The other shipped watch skins + /// (AppleWatch41mm, WearRound, WearSquare) are selectable from the skin menu once it is running; + /// WearRound in particular is worth checking a layout against, because a round face is where a + /// design that assumes a rectangle falls apart. + private static final String WATCH_COMPANION_SKIN = "/AppleWatch45mm.skin"; private static final String DEFAULT_SKINS = DEFAULT_SKIN+";"; private static String appHomeDir = ".cn1"; - + /// The app home both halves of a simulated pair resolve to, without the watch suffix below. The + /// watch gets its own storage sandbox, but the two still have to meet somewhere to exchange data. + private static String sharedHomeDir = ".cn1"; + + /// The companion watch process, so "Launch Watch App" runs one rather than one per click. + private static Process watchProcess; + /// The shutdown hook that kills the launched watch is installed once, not once per launch. + private static boolean watchShutdownHookInstalled; + private static final Object WATCH_PROCESS_LOCK = new Object(); + + static { + // A launched watch inherits the phone's shared home through this property. Without it the + // child falls back to ".cn1" while a phone started from a generated desktop stub uses + // ".", and the pair rendezvous in two different directories -- paired-looking, + // never reachable. Applied before watchSandbox so the watch still gets its own storage + // sandbox off the shared root. + String inherited = System.getProperty("cn1.app.home"); + if (inherited != null && inherited.length() > 0) { + sharedHomeDir = inherited; + appHomeDir = inherited; + } + appHomeDir = watchSandbox(appHomeDir); + } + + /// The storage sandbox for this process. On a device the phone app and the watch app are two apps + /// with two containers: `Storage`, the databases and `FileSystemStorage` are per-app, and there is + /// no shared container. Letting the two simulator processes share one home would hide exactly the + /// bugs this pairing exists to surface -- a watch reading a value only the phone ever wrote, or + /// either side overwriting the other's state -- so the watch half is given its own. + /// + /// @param base the project's app home directory name or path + /// @return the same value on the phone side, a sibling on the watch side + private static String watchSandbox(String base) { + if (base == null || !"watch".equals(System.getProperty("cn1.wearable.side"))) { + return base; + } + return base + "-watch"; + } + /** * Allowed video extensions for the gallery. */ @@ -779,7 +869,16 @@ public static String getAppHomeDir() { * @param aAppHomeDir the appHomeDir to set */ public static void setAppHomeDir(String aAppHomeDir) { - appHomeDir = aAppHomeDir; + sharedHomeDir = aAppHomeDir; + appHomeDir = watchSandbox(aAppHomeDir); + } + + /// The app home shared by both halves of a simulated pair, which is where the wearable bridge + /// rendezvous lives. Distinct from {@link #getAppHomeDir()}, which is this process's own sandbox. + /// + /// @return the unsuffixed app home directory + static String getSharedHomeDir() { + return sharedHomeDir; } protected TestRecorder testRecorder; private Hashtable contacts; @@ -892,6 +991,10 @@ public static void setShowEDTViolationStacks(boolean aShowEDTViolationStacks) { private static String currentSimulatorNativeTheme; private static int softkeyCount = 1; private static boolean tablet; + /// True when the loaded skin declares `watch=true`, which is how an Apple Watch or Wear OS skin + /// identifies itself. Drives `isWatch()` and the `"watch"` resource/CSS override layer, so a + /// watch layout can be developed here rather than only on a device. + private static boolean watch; private static String DEFAULT_FONT = "Arial-plain-11"; private static EventDispatcher formChangeListener; private static boolean autoAdjustFontSize = true; @@ -967,6 +1070,10 @@ private static boolean computeUseAppFrame() { // simulator mode and the desktop floating widget windows in desktop mode. Created lazily so // apps that never touch the surfaces API pay nothing. private JavaSEWidgetBridge surfaceBridge; + // Phone-to-watch link (com.codename1.wearable). Both halves of a paired pair run their own + // simulator process and meet through the shared app home; created lazily so apps that never + // touch the wearable API pay nothing. + private JavaSEWearableBridge wearableBridge; // Desktop floating widget windows manager, created beside the bridge in desktop mode only. private JavaSEWidgetWindows widgetWindows; // Application frame used for simulator @@ -4701,6 +4808,7 @@ private void loadSkinFile(InputStream skin, final JFrame frm) { Integer.parseInt(props.getProperty("smallFontSize", "" + sm)), Integer.parseInt(props.getProperty("largeFontSize", "" + la))); tablet = props.getProperty("tablet", "false").equalsIgnoreCase("true"); + watch = props.getProperty("watch", "false").equalsIgnoreCase("true"); rotateTouchKeysOnLandscape = props.getProperty("rotateKeys", "false").equalsIgnoreCase("true"); touchDevice = props.getProperty("touch", "true").equalsIgnoreCase("true"); keyboardType = Integer.parseInt(props.getProperty("keyboardType", "0")); @@ -5582,6 +5690,117 @@ public void actionPerformed(ActionEvent e) { return carMenu; } + /// Builds the simulator "Watch" menu, which launches the project's watch app beside the phone + /// app so the pair can be developed together. + /// + /// The watch app runs in its own JVM rather than in another window of this one. A watch app and + /// a phone app are two apps in two sandboxes on a device; sharing a `Display` here would let + /// bugs through that only appear once the pair is real. The two processes find each other + /// through the shared app home (see {@link JavaSEWearableBridge}), so `sendMessage` and + /// `putData` genuinely round-trip on the desktop. + private JMenu buildWatchMenu() { + JMenu watchMenu = new JMenu("Watch"); + registerMenuWithBlit(watchMenu); + JMenuItem launch = new JMenuItem("Launch Watch App"); + launch.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + launchWatchCompanion(); + } + }); + watchMenu.add(launch); + return watchMenu; + } + + /// Starts the watch app in a second simulator process, on a watch skin, wired to this one. + void launchWatchCompanion() { + String watchMain = getWatchMainClass(); + if (watchMain == null) { + javax.swing.JOptionPane.showMessageDialog(window, + "This project declares no watch app.\n\n" + + "Add codename1.watchMain= to\n" + + "codenameone_settings.properties and run again. That one setting builds the\n" + + "watch app on both Apple Watch and Wear OS.", + "Watch App", javax.swing.JOptionPane.INFORMATION_MESSAGE); + return; + } + if (JavaSEPort.class.getResource(WATCH_COMPANION_SKIN) == null) { + // Without the skin the companion comes up on a phone skin, CN.isWatch() stays false and + // the whole point of the window is lost -- say so rather than launching something + // misleading. + javax.swing.JOptionPane.showMessageDialog(window, + "The watch skin " + WATCH_COMPANION_SKIN + " is not on the classpath.\n\n" + + "It ships with the Codename One JavaSE port; a stale or partial build of that\n" + + "port is the usual cause. Rebuild it and try again.", + "Watch App", javax.swing.JOptionPane.ERROR_MESSAGE); + return; + } + try { + List cmd = new ArrayList(); + cmd.add(new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath()); + cmd.add("-cp"); + cmd.add(System.getProperty("java.class.path")); + // The watch half needs to know which side it is, which class to start, and to come up on + // a watch skin so CN.isWatch() is true and the "watch" override layer applies. + cmd.add("-Dcn1.wearable.side=watch"); + // The child must inherit THIS process's shared home. A generated desktop stub calls + // setAppHomeDir("."), and without passing it on the watch would fall back to + // the default ".cn1" -- so the phone bridge rendezvous under /wearable while + // the watch waits under .cn1/wearable. Both windows then show a paired project that + // never becomes reachable and never exchanges anything, with nothing to indicate why. + cmd.add("-Dcn1.app.home=" + getSharedHomeDir()); + cmd.add("-Dcodename1.watchMain=" + watchMain); + cmd.add("-Dskin=" + WATCH_COMPANION_SKIN); + cmd.add("-Ddskin=" + WATCH_COMPANION_SKIN); + if (System.getProperty("cn1.class.path") != null) { + cmd.add("-Dcn1.class.path=" + System.getProperty("cn1.class.path")); + } + cmd.add(Simulator.class.getName()); + cmd.add(watchMain); + // Without -force the launcher ignores this argument whenever the project configures a + // package name, and starts codename1.packageName + codename1.mainName instead -- which + // would put the phone lifecycle on a watch skin and look like the watch app. + cmd.add("-force"); + synchronized (WATCH_PROCESS_LOCK) { + if (watchProcess != null && watchProcess.isAlive()) { + // One companion, not one per click. The rendezvous server services a single + // accepted socket synchronously, so a second watch connects into the backlog, + // reports itself reachable and is then never read until the first exits -- and + // depending on startup order two watches can even pair with each other before + // the phone. Re-selecting the action is a request for the watch, not for + // another watch. + javax.swing.JOptionPane.showMessageDialog(window, + "The watch app is already running.", + "Watch App", javax.swing.JOptionPane.INFORMATION_MESSAGE); + return; + } + watchProcess = new ProcessBuilder(cmd).inheritIO().start(); + // The child does NOT outlive the phone. An orphaned watch keeps the rendezvous + // port, pairs with the next phone run, and then the watch launched from THAT run + // cannot connect -- so the developer is talking to the previous build's watch + // while looking at the new one. Registered once, on first launch. + if (!watchShutdownHookInstalled) { + watchShutdownHookInstalled = true; + Runtime.getRuntime().addShutdownHook(new Thread() { + public void run() { + Process p; + synchronized (WATCH_PROCESS_LOCK) { + p = watchProcess; + } + if (p != null && p.isAlive()) { + p.destroy(); + } + } + }); + } + } + } catch (Exception err) { + javax.swing.JOptionPane.showMessageDialog(window, + "Could not launch the watch app:\n" + err, + "Watch App", javax.swing.JOptionPane.ERROR_MESSAGE); + } + } + /// Builds the simulator "Widgets" menu, which opens the Widgets preview window rendering the /// app's published `com.codename1.surfaces` timelines and live activities locally -- kind list, /// size selector, light/dark toggle, timeline auto-advance and a mock Dynamic Island. @@ -7101,6 +7320,10 @@ public void actionPerformed(ActionEvent e) { bar.add(extensionMenu); } bar.add(buildCarMenu()); + // Only offered on the phone half of a pair: the watch app has nothing to launch. + if (!isWatchCompanionProcess()) { + bar.add(buildWatchMenu()); + } bar.add(buildWidgetsMenu()); bar.add(MCPDesktopMenu.build("Codename One Simulator", window)); bar.add(helpMenu); @@ -14245,6 +14468,13 @@ public boolean isTablet() { return tablet || isDesktop(); } + /// A watch skin makes the simulator report the watch form factor, so `CN.isWatch()` branches and + /// the `"watch"` theme/CSS override layer can be exercised on the desktop instead of only on a + /// device. + public boolean isWatch() { + return watch; + } + public boolean isDesktop() { return portraitSkin == null; } @@ -15174,6 +15404,16 @@ public Simd createSimd() { * @inheritDoc */ public String[] getPlatformOverrides() { + if(isWatch()) { + // "watch" leads, matching the iOS and Android ports, so a resource or + // CSS override written for a device also applies here. The skin's own + // overrideNames follow, which is where "applewatch" / "android-watch" + // come from. + String[] out = new String[platformOverrides.length + 1]; + out[0] = "watch"; + System.arraycopy(platformOverrides, 0, out, 1, platformOverrides.length); + return out; + } if(isDesktop()) { return new String[] {"desktop", "tablet"}; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java new file mode 100644 index 00000000000..7de611bcf82 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java @@ -0,0 +1,1630 @@ +/* + * 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.javase; + +import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.WearableMessage; +import com.codename1.wearable.spi.WearableBridge; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The desktop stand-in for `WCSession` / the Wearable Data Layer, so the phone-to-watch API can be +/// developed and debugged without a device. +/// +/// The phone app and the watch app run as two separate JVMs -- they are two apps with two sandboxes +/// on a device, and pretending otherwise in the simulator would let bugs through. Each side creates +/// one of these, and the two halves find each other through a directory both resolve to (the app +/// home, which is per-project and therefore shared by the pair): +/// +/// - **Replicated data** is files under `wearable/data`. Both sides read and write the same +/// directory, so a value published while the peer was not running is simply there when it starts, +/// which is exactly the guarantee the real transports make. A poller notices the peer's writes. +/// - **Live messages** need a live peer, so they go over a loopback socket on a port derived from +/// that same directory. Whichever side starts first binds it and the other connects; if nobody is +/// on the other end, [#isReachable()] is false and messages are dropped -- again matching the +/// device behavior rather than papering over it. +/// - **File transfers** are modelled as data writes carrying the bytes, since the desktop has no +/// background-transfer scheduler worth simulating. +class JavaSEWearableBridge implements WearableBridge { + /// Frame kinds on the loopback socket. + private static final int FRAME_MESSAGE = 1; + private static final int FRAME_REPLY = 2; + private static final int FRAME_HELLO = 3; + /// How long a freshly accepted socket has to identify itself before it is dropped. + private static final int HELLO_TIMEOUT_MILLIS = 5000; + /// Ceiling on a single frame. Generous for any real payload, small enough that a corrupt length + /// cannot exhaust the heap. + private static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; + + private final File dataDir; + private final File portFile; + private final boolean watchSide; + /// True when the project declares a watch app at all. Without one there is nothing to pair with, + /// which is what a phone with no watch looks like. + private final boolean paired; + + private volatile Socket peer; + private volatile DataOutputStream peerOut; + private volatile boolean closed; + + /// Last-seen modification time per data file, so the poller reports only genuine changes. + private final Map seenData = new HashMap(); + + /// Creates the bridge and starts the rendezvous and data-watching threads. + /// + /// @param home the per-project app home directory both sides resolve to + /// @param watchSide true when this JVM is running the watch app + /// @param paired true when the project declares a watch app + JavaSEWearableBridge(File home, boolean watchSide, boolean paired) { + // A delivery the pending-delivery cap discarded is offered again by forgetting that this + // scan ever saw the file: the next 500ms pass then treats it as new. Nothing else would -- + // the seen-marker is written before the delivery is queued. + WearableConnection.setDroppedDeliveryHandler( + new WearableConnection.DroppedDeliveryHandler() { + public void deliveryDropped(String path) { + synchronized (seenData) { + if (path == null) { + // More was discarded than could be named: forget every file this + // scan has seen, so the next pass re-offers the whole directory. + seenData.clear(); + return; + } + seenData.remove(encodePath(path)); + seenData.remove(encodePath(path) + TOMB_SUFFIX); + } + } + }); + this.watchSide = watchSide; + this.paired = paired; + File root = new File(home, "wearable"); + this.dataDir = new File(root, "data"); + this.portFile = new File(root, "port"); + dataDir.mkdirs(); + primeSeenData(); + if (paired) { + startRendezvous(); + startDataWatcher(); + } + } + + // --- state -------------------------------------------------------------- + + public boolean isSupported() { + return paired; + } + + public boolean isPaired() { + return paired; + } + + public boolean isReachable() { + return peerOut != null; + } + + public boolean isCompanionAppInstalled() { + return paired; + } + + public String[] getConnectedNodes() { + if (!isReachable()) { + return new String[0]; + } + // Mirrors the id \t displayName \t nearby form the device ports produce. + String name = watchSide ? "Simulated Phone" : "Simulated Watch"; + return new String[] {(watchSide ? "phone" : "watch") + "\t" + name + "\t1"}; + } + + // --- messages ----------------------------------------------------------- + + public void sendMessage(String path, byte[] payload, int replyToken) { + DataOutputStream out = peerOut; + if (out == null) { + if (replyToken != 0) { + WearableConnection.deliverReply(replyToken, null, + "The " + (watchSide ? "phone" : "watch") + " app is not running"); + } + return; + } + try { + writeFrame(out, FRAME_MESSAGE, path, payload, replyToken); + if (replyToken != 0) { + // The write succeeding is not the answer arriving. If the peer quits before + // replying, or never registers a listener for this path, nothing else would ever + // complete the handler -- and the API promises it runs exactly once. + scheduleReplyTimeout(replyToken); + } + } catch (IOException err) { + dropPeer(out); + if (replyToken != 0) { + WearableConnection.deliverReply(replyToken, null, "Link lost: " + err); + } + } + } + + /** How long an accepted request may go unanswered before the handler is failed. */ + private static final int REPLY_TIMEOUT_MILLIS = 30000; + /** One timer for every deadline in the process, as on the device ports. */ + private static final java.util.Timer replyTimer = + new java.util.Timer("cn1-wearable-sim-replies", true); + private static final Map replyTimeouts = + new HashMap(); + + private static void scheduleReplyTimeout(final int replyToken) { + java.util.TimerTask task = new java.util.TimerTask() { + public void run() { + synchronized (replyTimeouts) { + replyTimeouts.remove(Integer.valueOf(replyToken)); + } + WearableConnection.deliverReply(replyToken, null, + "The peer did not answer within " + (REPLY_TIMEOUT_MILLIS / 1000) + + " seconds"); + } + }; + synchronized (replyTimeouts) { + replyTimeouts.put(Integer.valueOf(replyToken), task); + } + replyTimer.schedule(task, REPLY_TIMEOUT_MILLIS); + } + + private static void cancelReplyTimeout(int replyToken) { + java.util.TimerTask task; + synchronized (replyTimeouts) { + task = replyTimeouts.remove(Integer.valueOf(replyToken)); + } + if (task != null) { + task.cancel(); + } + } + + public void sendReply(int replyToken, byte[] payload) { + DataOutputStream out = peerOut; + if (out == null) { + return; + } + try { + writeFrame(out, FRAME_REPLY, "", payload, replyToken); + } catch (IOException err) { + dropPeer(out); + } + } + + // --- replicated data ---------------------------------------------------- + + public void putData(String path, byte[] payload) { + // Under writeLock, so the tombstone housekeeping cannot delete a record this call is in + // the middle of replacing. See pruneOwnTombstones. + synchronized (writeLock) { + putDataLocked(path, payload); + } + } + + private void putDataLocked(String path, byte[] payload) { + // The tombstone goes FIRST, before the value it is being replaced by. + // + // Removing it afterwards leaves an interval in which both records exist, and the peer scans + // every 500ms with no ordering guarantee from listFiles() -- so it could deliver the + // removal after the new value and leave the listener showing a path as deleted while + // getData returns the replacement. A failed delete would make that permanent. Removing it + // first leaves only a window in which neither exists, and the next scan finds the value: + // every observable ordering converges on the replacement. + File tomb = new File(dataDir, encodePath(path) + TOMB_SUFFIX); + boolean hadTombstone = tomb.isFile(); + if (hadTombstone && !tomb.delete()) { + // A delete that FAILED is not the same as a tombstone that was not there, and the + // previous boolean could not tell them apart. Publishing anyway would leave both + // records durable, and a peer scanning them in the order listFiles() happens to give + // could deliver the tombstone last and settle in the removed state while getData + // returns live data -- permanently, since neither record changes again. + com.codename1.io.Log.p("Wearable simulator: could not clear the tombstone for " + path + + "; not publishing over it"); + return; + } + // The acknowledgement goes only once the tombstone it describes is actually gone, and + // never on the abort path above. Deleting it first stranded the tombstone for good when the + // publication then failed -- Windows holding the file, say: the peer has already marked the + // unchanged tombstone as seen and will not acknowledge it a second time, so once the + // filesystem recovers, housekeeping under the cap has no acknowledgement to retire it with. + // + // Best-effort in the other direction, deliberately: acknowledges() compares stamps and + // accepts nothing it cannot parse, so a leftover file confirms no tombstone and the next + // scan discards it. + new File(dataDir, tomb.getName() + ACK_SUFFIX).delete(); + if (hadTombstone) { + synchronized (seenData) { + seenData.remove(tomb.getName()); + } + } + if (!writeValue(dataFile(path), payload, path) && hadTombstone) { + // The replacement did not land -- a full disk, a failed move -- and the tombstone that + // recorded the removal has already gone. A peer that was offline would then see + // neither the new value nor the removal and keep its pre-removal value for good. Put + // the tombstone back: the removal is still the last thing that actually happened. + writeValue(tomb, new byte[0], path); + return; + } + // The peer may have written a tombstone between our delete of theirs and this write, which + // leaves both records durable. Same reconciliation removeData does, from the other side. + resolveValueAgainstTombstone(path); + } + + /// @return true when the value was published; false when it could not be written, so a caller + /// that has already discarded state on the strength of this publication can put it back + private boolean writeValue(File f, byte[] payload, String path) { + try { + f.getParentFile().mkdirs(); + // Write-then-rename: the peer polls this directory every 500ms, and writing in place + // would let it read a truncated payload mid-write and report a malformed value. + // + // The staging name is unique per writer, not per path. The phone and the watch are two + // JVMs sharing this directory, and both may publish the same path at once: a shared + // ".tmp" lets each truncate the other's staging file, and the delete-then-rename + // fallback below can then destroy the winner's file outright. + File tmp = new File(f.getParentFile(), f.getName() + stagingSuffix()); + // Chosen BEFORE the bytes are written, because it now travels inside them. It still + // depends only on the target file's current stamp, so moving it earlier changes + // nothing about the value it picks. + long stamp = nextStamp(f); + FileOutputStream out = new FileOutputStream(tmp); + try { + // The author travels INSIDE the value. It used to live in a ".author" sidecar, and + // two files are two operations however they are ordered: A could write its author, + // B could overwrite that author, and A could then publish its value -- leaving A's + // bytes permanently labelled as B's, so B suppressed the genuine peer callback and + // A reported its own write as remote. Prefixing the payload makes the label and the + // bytes one object, and the rename below publishes both or neither. + out.write(VALUE_MAGIC); + out.write(watchSide ? 'w' : 'p'); + for (int i = 7; i >= 0; i--) { + out.write((int) ((stamp >>> (i * 8)) & 0xff)); + } + out.write(payload == null ? new byte[0] : payload); + out.flush(); + } finally { + out.close(); + } + // Stamp the staging file, then publish by rename. Stamping AFTER the rename was a race: + // writer A could rename, writer B could replace A's file, and A would then set the + // modification time on B's file and record that time as its own -- so A's watcher would + // skip B's winning value forever. Renaming an already-stamped file makes publication a + // single atomic step that can only ever touch this writer's own bytes. + tmp.setLastModified(stamp); + // Read from the STAGING file, before it is published. Reading f.lastModified() after + // the move could see a peer's replacement -- both JVMs publish into this directory -- + // and this writer would then record the PEER's timestamp as its own, so the scan would + // skip that value as locally authored and the peer's publication would never be + // announced. The staging file is ours alone until the rename. + long staged = tmp.lastModified(); + try { + // An atomic replace, not delete-then-rename. The old fallback deleted whatever was + // published before retrying, so a peer publishing the same path in that gap had its + // newer value destroyed and replaced by this side's older staging file -- a lost + // write, or a momentary removal seen by the peer's watcher. + java.nio.file.Files.move(tmp.toPath(), f.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (java.io.IOException | RuntimeException noAtomicMove) { + // Some filesystems cannot do it. Keep the old path as a fallback rather than + // failing the publish, but it carries the race described above. + if (!tmp.renameTo(f)) { + f.delete(); + if (!tmp.renameTo(f)) { + throw new IOException("could not replace " + f, noAtomicMove); + } + } + } + // Record what the filesystem actually stored, not what we asked for. setLastModified + // can be refused outright or quantized (FAT to 2s, some network mounts coarser), and + // recording the requested value then left the real mtime unseen -- so the next scan + // read this side's own publication as a peer update and invoked its own data listener. + // A rename preserves the mtime, so the staged value is what lands on disk. + // Coarse timestamps can also erase the phone/watch side bit, which is encoded in the + // stamp's low bit. + long recorded = staged; + if (recorded <= 0) { + recorded = stamp; + } + // Our own write must not come back to us as a peer change. Stored in the same + // encoding the scan compares against, and always as locally authored -- this IS our + // publication. + synchronized (seenData) { + seenData.put(f.getName(), Long.valueOf(seenKey(stamp, recorded, true))); + } + return true; + } catch (IOException err) { + com.codename1.io.Log.p("Wearable simulator: failed to publish " + path + ": " + err); + return false; + } + } + + public byte[] getData(String path) { + File f = dataFile(path); + if (!f.exists()) { + return null; + } + try { + return readPayload(f); + } catch (IOException err) { + return null; + } + } + + public void removeData(String path) { + // Under writeLock, so the tombstone housekeeping cannot delete a record this call is in + // the middle of replacing. See pruneOwnTombstones. + synchronized (writeLock) { + removeDataLocked(path); + } + } + + private void removeDataLocked(String path) { + File f = dataFile(path); + if (f.isFile() && !f.delete()) { + // The value is still there. Publishing the tombstone anyway would leave BOTH durable, + // and a peer reading them in whatever order listFiles() gives could settle in the + // removed state while getData still returns the value -- and once the tombstone + // expires, the value has long been marked seen and is never re-delivered. A delete + // that failed is not a removal, so nothing is recorded as one. + com.codename1.io.Log.p("Wearable simulator: could not delete " + path + + "; the removal was not published"); + return; + } + synchronized (seenData) { + seenData.remove(f.getName()); + } + // A durable tombstone, because deleting the file says nothing to a peer that is not + // running. That peer starts with an empty seenData, sees only a path that is not there, and + // never learns the value was removed -- so an app that persisted it stays stale forever. + // The other two platforms both keep a tombstone for exactly this; the simulator was the + // odd one out. + writeValue(new File(dataDir, encodePath(path) + TOMB_SUFFIX), new byte[0], path); + // The other JVM may have published a replacement between the delete above and this write, + // in which case BOTH records now exist durably. writeLock cannot prevent that -- it is + // process-local and the peer is a separate process -- so the records are reconciled instead + // of pretended away. + resolveValueAgainstTombstone(path); + } + + /// Keeps at most one of the two records for a path, the newer by embedded stamp. + /// + /// A value and a tombstone for the same path can both end up on disk: two simulator processes + /// share this directory, and a publish landing between the value delete and the tombstone write + /// -- or the reverse -- leaves one of each. Nothing later is guaranteed to touch that path + /// again, so without reconciliation the peer's scan can settle in the removed state while + /// getData keeps returning the value, permanently. + /// + /// The stamps are a Lamport clock with a wall-clock floor and disjoint residues per side, so + /// "newer" is meaningful across processes and never a tie. Either side may resolve: unlike the + /// Data Layer there is no replication here, just one directory, so deleting the loser cannot + /// propagate and rob anyone. + private void resolveValueAgainstTombstone(String path) { + File value = dataFile(path); + File tomb = new File(dataDir, encodePath(path) + TOMB_SUFFIX); + if (!value.isFile() || !tomb.isFile()) { + return; + } + long valueStamp = versionOf(value); + long tombStamp = versionOf(tomb); + boolean tombLost = valueStamp > tombStamp; + File loser = tombLost ? tomb : value; + // Only while the loser is still the version this decision was made about. The other process + // can replace it at the same file name between the read above and the delete -- the value + // loses to a tombstone and is then republished with a newer stamp -- and deleting on the + // strength of the old reading would discard the actual winner. retireTombstone already + // works this way; this path was written without it. + if (deleteIfVersionUnchanged(loser, tombLost ? tombStamp : valueStamp)) { + synchronized (seenData) { + seenData.remove(loser.getName()); + } + if (tombLost) { + // The tombstone lost, so its acknowledgement describes nothing. + new File(dataDir, tomb.getName() + ACK_SUFFIX).delete(); + } + } + } + + /// Deletes a record only while it still carries the version the caller decided about. + /// + /// Not atomic -- POSIX offers no compare-and-delete -- but it narrows the window from "since + /// the enumeration" to "between this check and the unlink", and it is the same guard the + /// tombstone retirement uses. A file that has already vanished counts as deleted, so a + /// concurrent removal does not leave the caller thinking its record survived. + private boolean deleteIfVersionUnchanged(File f, long expected) { + if (versionOf(f) != expected) { + return false; + } + return f.delete() || !f.isFile(); + } + + /// A record's version: the stamp inside it, falling back to its mtime when it carries no frame. + private static long versionOf(File f) { + try { + return tombstoneVersion(readFully(f), f); + } catch (IOException vanished) { + return Long.MIN_VALUE; + } + } + + /// The acknowledgement payload for a tombstone: the stamp of the tombstone being + /// acknowledged, in decimal. + /// + /// A tombstone born before this framing existed reports {@link Long#MIN_VALUE}, and that is + /// written out as-is: it still identifies that tombstone as distinct from a later one, which is + /// all the comparison needs. + private static byte[] acknowledgementFor(byte[] tombstone) { + try { + return Long.toString(framedStamp(tombstone)).getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException everyJvmHasUtf8) { + throw new IllegalStateException(everyJvmHasUtf8); + } + } + + /// Whether this acknowledgement is for THIS tombstone rather than an earlier one at the same + /// path. + /// + /// The file name alone is not enough: putData deletes the tombstone it publishes over but the + /// acknowledgement outlived it, so a path removed, republished and removed again found the + /// first removal's acknowledgement waiting for the second. The author then retired a tombstone + /// no peer had ever seen, which is the exact failure the tombstone exists to prevent. + /// + /// A stale acknowledgement is deleted rather than merely ignored: leaving it would have the + /// peer's next scan see an acknowledged tombstone again the moment the stamps happened to line + /// up, and it is dead weight in a directory that is also size-capped. + private boolean acknowledges(File ack, long tombstoneStamp) { + if (!ack.isFile()) { + return false; + } + byte[] all; + try { + all = readFully(ack); + } catch (IOException unreadable) { + // Unreadable is not acknowledged. The tombstone stays, which is the safe direction: + // the cost is one file kept past its window, against a peer never learning of a + // removal. + return false; + } + String recorded = new String(payloadOf(all), java.nio.charset.Charset.forName("UTF-8")).trim(); + long acknowledged; + try { + acknowledged = Long.parseLong(recorded); + } catch (NumberFormatException notAStamp) { + // Refused outright, NOT folded into a sentinel value. Includes the EMPTY payload the + // previous build wrote, which acknowledged by existence alone -- accepting that would + // mean an acknowledgement matching every tombstone at its path forever, the failure + // this whole change is about. + // + // Not by assigning Long.MIN_VALUE either, which is what this did: that is also what + // framedStamp reports for a tombstone written before the frame existed, so an + // unreadable acknowledgement compared EQUAL to an unframed tombstone and retired it. + // Two different unknowns are not the same value. + // + // The cost of refusing is bounded and small: a tombstone the peer already saw under + // the old build is kept until MAX_TOMBS evicts it, in a sandbox shared by two + // processes of one simulator run. Against that, an unacknowledged tombstone erroneously + // retired is a peer that never learns of a removal at all. + forgetStaleAcknowledgement(ack); + return false; + } + if (acknowledged == tombstoneStamp) { + return true; + } + forgetStaleAcknowledgement(ack); + return false; + } + + /// Discards an acknowledgement that confirms nothing -- stale, unreadable, or from the previous + /// format -- rather than leaving it to be re-examined on every scan. The peer writes a real one + /// the next time it sees the tombstone undelivered. + private void forgetStaleAcknowledgement(File ack) { + ack.delete(); + synchronized (seenData) { + seenData.remove(ack.getName()); + } + } + + /// Marks a tombstone file. A removal's durable record, consumed by the peer's scan. + private static final String TOMB_SUFFIX = ".tomb"; + + /// How long a tombstone is kept before its author drops it. Long enough for a peer that was + /// closed to be reopened; the alternative -- keeping it forever -- fills the shared directory. + private static final long TOMB_TTL_MILLIS = 24 * 60 * 60 * 1000L; + + private static boolean isTombstone(String storageName) { + return storageName.endsWith(TOMB_SUFFIX); + } + + /// Marks a peer's acknowledgement of a tombstone. Bookkeeping between the two simulators: + /// never delivered to a listener and never enumerated as a path. + private static final String ACK_SUFFIX = ".ack"; + + private static boolean isTombstoneAck(String storageName) { + return storageName.endsWith(ACK_SUFFIX); + } + + /// How many tombstones this side may keep while waiting for a peer that never runs. + private static final int MAX_TOMBS = 256; + + /// Tombstones currently in the shared directory, the cap's input. + private int ownTombstones() { + File[] files = dataDir.listFiles(); + if (files == null) { + return 0; + } + int n = 0; + for (File f : files) { + if (isTombstone(f.getName())) { + n++; + } + } + return n; + } + + /// The logical path a tombstone stands for. + private static String tombstonePath(String storageName) { + return decodePath(storageName.substring(0, storageName.length() - TOMB_SUFFIX.length())); + } + + public String[] getDataPaths() { + File[] files = dataDir.listFiles(); + if (files == null) { + return new String[0]; + } + List out = new ArrayList(); + for (File f : files) { + // A transfer is not a readable replicated path -- getData() on its storage name is not + // part of the API -- so it is left out, matching the device ports. + if (f.isFile() && !f.getName().endsWith(".tmp") && !isTransfer(f.getName()) + && !isTombstone(f.getName()) && !isTombstoneAck(f.getName())) { + out.add(decodePath(f.getName())); + } + } + return out.toArray(new String[out.size()]); + } + + public void transferFile(String path, String name, byte[] contents) { + // The desktop has no background-transfer scheduler worth simulating, and a transfer that + // arrives eventually is indistinguishable from a data write that arrives eventually. The + // bytes still have to be encoded as a payload, though: the receiving side decodes every + // value as one, and raw file bytes would arrive as a malformed message with no name. + String fileName = name == null ? "file" : name; + WearableMessage wrapper = new WearableMessage(path) + .put("name", fileName) + .put("contents", contents == null ? new byte[0] : contents); + // Two files sent to the same logical path must not overwrite each other, so the file name is + // part of the storage name -- but it must not become the *delivered* path: a listener routes + // on the path the sender passed to transferFile. The marker keeps the two recoverable, and + // is a character encodePath can never emit. + // + // The sequence is what makes each transfer its own file. A transfer is one-shot, so sending + // twice to the same path and name before the 500ms watcher has consumed the first -- or at + // any time while the peer is offline -- must queue two deliveries, not silently replace one + // with the other. (A replicated value is the opposite: putData deliberately overwrites.) + // The sender's side is part of the name. Both halves scan this one directory, so without it + // a sender cannot tell its own pending transfer from an inbound one: after a restart + // primeSeenData() records nothing, and the sender's first scan would consume and delete the + // very transfer it is waiting to hand over. + writeValue(new File(dataDir, encodePath(path) + TRANSFER_MARKER + encodePath(fileName) + + TRANSFER_MARKER + sideTag() + + TRANSFER_MARKER + Long.toHexString(nextTransferSequence())), + wrapper.toByteArray(), path); + } + + /** Identifies which half wrote a file: transfers are only consumed by the other side. */ + private String sideTag() { + return watchSide ? "w" : "p"; + } + + /** True when this transfer was written by the other half, and so is ours to consume. */ + private boolean isInboundTransfer(String storageName) { + if (!isTransfer(storageName)) { + return false; + } + String[] parts = storageName.split(TRANSFER_MARKER); + // XXX; anything shorter predates the side tag, so treat it as inbound + // rather than stranding it. + return parts.length < 4 || !parts[2].equals(sideTag()); + } + + /** Distinguishes successive transfers so neither overwrites the other on disk. */ + private static synchronized long nextTransferSequence() { + long now = System.currentTimeMillis(); + lastTransferSequence = now > lastTransferSequence ? now : lastTransferSequence + 1; + return lastTransferSequence; + } + + private static long lastTransferSequence; + + + /** + * Separates the logical path from the file name in a transfer's storage name. Uppercase, which + * {@link #encodePath} never produces, so it cannot occur inside either half. + */ + private static final String TRANSFER_MARKER = "X"; + + /// The path a stored value is delivered on: for a transfer, the path its sender passed to + /// {@code transferFile} rather than the filename-suffixed name it is stored under. + private static String deliveryPath(String storageName) { + int marker = storageName.indexOf(TRANSFER_MARKER); + return decodePath(marker < 0 ? storageName : storageName.substring(0, marker)); + } + + private static boolean isTransfer(String storageName) { + return storageName.indexOf(TRANSFER_MARKER) >= 0; + } + + // --- rendezvous --------------------------------------------------------- + + /// Both sides race to bind the loopback port; the winner listens, the loser connects and retries + /// until the winner exists. Which side wins does not matter, which means the phone and the watch + /// can be started in either order. + private void startRendezvous() { + Thread t = new Thread(new Runnable() { + public void run() { + // Re-run for as long as the bridge lives, because the role is not permanent. A + // bind that fails only because an unrelated process momentarily held the port used + // to make this half a connector for good: once that process let go, neither half + // would bind again, both kept dialling a server that did not exist, and the pair + // stayed unreachable until a restart. Losing the election is a fact about right + // now, not about the run. + while (!closed) { + ServerSocket server = null; + try { + server = new ServerSocket(port(), 1, InetAddress.getByName("127.0.0.1")); + } catch (IOException alreadyBound) { + server = null; + } + if (server != null) { + acceptLoop(server); + } else { + connectOnce(); + } + } + } + }, "CN1 wearable link"); + t.setDaemon(true); + t.start(); + } + + private void acceptLoop(ServerSocket server) { + try { + while (!closed) { + try { + Socket s = server.accept(); + readLoop(s, adoptPeer(s)); + } catch (IOException err) { + if (closed) { + return; + } + } + } + } finally { + // Released on the way out, so the election that follows can bind again rather than + // losing to this thread's own abandoned socket. + try { + server.close(); + } catch (IOException ignored) { + } + } + } + + /// ONE attempt to reach the server, then returns so the election can run again. + /// + /// It used to loop internally for the bridge's lifetime, which is what made a lost election + /// permanent -- the caller never got the chance to try binding again. + private void connectOnce() { + try { + Socket s = new Socket(InetAddress.getByName("127.0.0.1"), port()); + readLoop(s, adoptPeer(s)); + } catch (IOException notUpYet) { + // The peer app is not running. The caller waits and re-elects -- the user may open it + // at any point, and by then this half may be the one that can bind. + } + if (closed) { + return; + } + try { + Thread.sleep(1000); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + /// Connects and says hello, returning the output stream WITHOUT publishing it. + /// + /// The stream stays private until the peer's own hello checks out. Assigning `peerOut` here + /// made `isReachable()` true and let `sendMessage()` write application traffic into whatever + /// held the derived port -- an unrelated local service, or a colliding project -- for the whole + /// five seconds before the identity check timed out. The hello itself is the one thing written + /// before verification, which is what verification is for. + private DataOutputStream adoptPeer(Socket s) throws IOException { + s.setTcpNoDelay(true); + // A read deadline, because a service that merely ACCEPTS on our derived port and then says + // nothing would otherwise leave the reader blocked in readByte() forever -- with the + // simulator reporting itself reachable the whole time. + s.setSoTimeout(HELLO_TIMEOUT_MILLIS); + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + // The hello carries the project identity. The port is derived from a hash of the shared + // directory truncated to 10,000 values, so two unrelated projects whose paths collide -- or + // any other local service already sitting on that port -- would otherwise connect, both + // report reachable, and exchange live messages and replies between unrelated apps. A + // successful connection to a small shared port range proves nothing about who is on it. + writeFrame(out, FRAME_HELLO, projectIdentity(), new byte[0], 0); + // `peer` and `peerOut` are both assigned by the reader once the peer's own hello is + // verified, NOT here. Publishing either on a bare accepted socket meant an unrelated local + // service holding the port made isReachable() true and live messages went into it. + return out; + } + + private void readLoop(Socket s, DataOutputStream unverified) { + boolean helloVerified = false; + try { + DataInputStream in = new DataInputStream(s.getInputStream()); + while (!closed) { + int kind = in.readByte(); + String path = in.readUTF(); + int token = in.readInt(); + int length = in.readInt(); + if (length < 0 || length > MAX_FRAME_BYTES) { + // A corrupt or mismatched peer stream. Allocating on this would throw + // NegativeArraySizeException or OutOfMemoryError, neither of which the + // accept/connect loop catches -- it would take the link's thread with it. + throw new IOException("Implausible frame length " + length); + } + byte[] payload = new byte[length]; + in.readFully(payload); + switch (kind) { + case FRAME_HELLO: + // Validate the identity before anything else is honoured. A peer on a + // colliding port -- another project, or an unrelated local service that + // happens to speak enough of this to get here -- is dropped rather than + // treated as the pair. + if (!expectedPeerIdentity().equals(path)) { + throw new IOException("Wearable simulator: refusing a peer that is not " + + "this project's counterpart (" + path + ")"); + } + helloVerified = true; + // Only now is this a peer. Reachability, the writable stream and the state + // change all follow the identity, not the connection. + peer = s; + peerOut = unverified; + s.setSoTimeout(0); + WearableConnection.notifyStateChanged(); + break; + case FRAME_MESSAGE: + if (!helloVerified) { + throw new IOException("Wearable simulator: traffic before a verified hello"); + } + WearableConnection.deliverMessage(path, payload, token); + break; + case FRAME_REPLY: + if (!helloVerified) { + throw new IOException("Wearable simulator: traffic before a verified hello"); + } + cancelReplyTimeout(token); + WearableConnection.deliverReply(token, payload, null); + break; + default: + break; + } + } + } catch (IOException disconnected) { + // Falls through to dropPeer: the peer app exited or the link broke. + } finally { + dropPeer(); + // And close THIS socket, which dropPeer does not: it clears the globally verified peer, + // and a socket rejected for a failed or timed-out hello never became one. The connector + // retries every second, so leaking one descriptor per attempt against an unrelated + // service on the derived port would eventually exhaust the simulator's file handles. + try { + s.close(); + } catch (IOException ignored) { + } + } + } + + /// Drops the CURRENT peer, whatever it is. For the reader, which owns the connection it is + /// reading, and for shutdown. + private void dropPeer() { + dropPeer(null); + } + + /// Drops the peer only if `failed` is still the stream in use, or unconditionally when null. + /// + /// A write captures `peerOut` before it blocks, so its IOException can surface after the + /// rendezvous thread has already verified a REPLACEMENT connection. Tearing down on that stale + /// failure closed a link that was working, and the next election had to run before anything + /// could be sent again -- a self-inflicted outage caused by the previous connection's death. + private void dropPeer(DataOutputStream failed) { + if (failed != null && failed != peerOut) { + // A newer connection has taken over; the failure describes one already gone. + return; + } + Socket s = peer; + peer = null; + peerOut = null; + if (s != null) { + try { + s.close(); + } catch (IOException ignored) { + } + WearableConnection.notifyStateChanged(); + } + } + + private static void writeFrame(DataOutputStream out, int kind, String path, + byte[] payload, int token) throws IOException { + byte[] body = payload == null ? new byte[0] : payload; + synchronized (out) { + out.writeByte(kind); + out.writeUTF(path == null ? "" : path); + out.writeInt(token); + out.writeInt(body.length); + out.write(body); + out.flush(); + } + } + + /// Identifies the project on the wire, so a port collision cannot be mistaken for a peer. + /// + /// The absolute shared directory is the identity: it is what "the same project" means here, and + /// it is exactly what the port hash throws away. + private String projectIdentity() { + // The ROLE travels with the identity. The project alone does not identify a counterpart: + // two phone simulators, or an orphaned watch process beside a freshly launched one, both + // pass a project-only check -- and then each reports the link reachable and exchanges live + // traffic with something that is not its pair. A NUL separates the two because a path + // cannot contain one. + return dataDir.getAbsolutePath() + "\u0000" + (watchSide ? "watch" : "phone"); + } + + /// The identity this side requires of its peer: the same project, the OTHER role. + private String expectedPeerIdentity() { + return dataDir.getAbsolutePath() + "\u0000" + (watchSide ? "phone" : "watch"); + } + + /// Derives a stable loopback port from the shared directory, so two JVMs of the same project + /// meet and two different projects do not. Kept in the ephemeral range. + private int port() { + int h = dataDir.getAbsolutePath().hashCode(); + return 49152 + Math.abs(h % 10000); + } + + // --- data watching ------------------------------------------------------ + + /// Notices values the peer published. Polling is enough here: the peer writes rarely, the + /// directory is tiny, and this stays honest about replicated data being eventually consistent. + private void startDataWatcher() { + Thread t = new Thread(new Runnable() { + public void run() { + while (!closed) { + scanData(); + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + return; + } + } + } + }, "CN1 wearable data"); + t.setDaemon(true); + t.start(); + } + + /// Leaves what is already on disk unrecorded, so the first watcher pass replays it. + /// + /// A value the peer published while this side was stopped is exactly what a starting app needs + /// to see -- that is the guarantee replicated data makes, and recording the files as already + /// seen would silently break it. The cost is that a value this app published itself last run is + /// replayed to it too, which listeners handle the same way they handle any republish. + private void primeSeenData() { + // Deliberately empty: see above. Kept as a named step so the reasoning has somewhere to + // live rather than being an absence. + } + + /// Retires this side's own tombstones: the acknowledged ones past their window, and the + /// oldest ones once the directory cap is exceeded. + /// + /// A pass of its own, over every tombstone, rather than a branch inside the delivery loop. That + /// loop only reaches a file whose recorded stamp has changed, and a tombstone never changes + /// after it is written -- {@link #writeValue} records the stamp as it creates the file, and an + /// acknowledgement lands as a separate file that leaves the tombstone's marker alone. So the + /// housekeeping ran exactly once, at creation, when nothing was yet eligible: no tombstone + /// written during a run was ever retired and the cap did nothing until a restart. + /// + /// Throttled, because it reads each tombstone to find its birth stamp and the scan it hangs off + /// runs twice a second. Retirement is not latency-sensitive; being a few seconds late costs + /// nothing. + private void pruneOwnTombstones(File[] files) { + if (files == null) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastTombPrune < TOMB_PRUNE_INTERVAL_MILLIS) { + return; + } + lastTombPrune = now; + synchronized (writeLock) { + pruneOwnTombstones(files, now); + } + } + + /// Caller holds {@link #writeLock}, so this side cannot publish or remove underneath the pass. + private void pruneOwnTombstones(File[] files, long now) { + // Birth stamp alongside the file, because BOTH decisions here are about age and neither + // can be answered from the name. Decoded from the frame: the stamp encodes + // wallMillis * 2 + sideBit, and comparing it undecoded reads as about minus fifty-seven + // years, which is why this cleanup once never fired at all. A file with no frame falls + // back to its mtime -- setLastModified can be refused outright, so this is a guess, but it + // is the only one available for a file this build did not write. + List own = new ArrayList(); + final Map bornAt = new HashMap(); + for (File f : files) { + if (!f.isFile() || !isTombstone(f.getName())) { + continue; + } + byte[] snapshot; + try { + snapshot = readFully(f); + } catch (IOException vanished) { + continue; + } + if (!authoredLocallyFor(snapshot, f.lastModified())) { + continue; + } + long born = framedStamp(snapshot); + own.add(f); + bornAt.put(f.getName(), Long.valueOf(tombstoneVersion(snapshot, f))); + // Acknowledged AND past its window: the peer has seen this removal, so keeping the + // record no longer protects anyone. + if (acknowledges(new File(dataDir, f.getName() + ACK_SUFFIX), born) + && now - bornAt.get(f.getName()).longValue() > TOMB_TTL_MILLIS) { + retireTombstone(f, bornAt.get(f.getName()).longValue()); + own.remove(own.size() - 1); + } + } + if (own.size() <= MAX_TOMBS) { + return; + } + // Past the cap the OLDEST go, by birth stamp. listFiles() has no ordering guarantee, so + // deleting whichever came back first discarded a removal made seconds ago while removals + // from hours earlier stayed -- and it is the recent one an offline peer most needs. + Collections.sort(own, new java.util.Comparator() { + public int compare(File a, File b) { + long x = bornAt.get(a.getName()).longValue(); + long y = bornAt.get(b.getName()).longValue(); + return x < y ? -1 : (x > y ? 1 : a.getName().compareTo(b.getName())); + } + }); + for (int i = 0; i < own.size() - MAX_TOMBS; i++) { + retireTombstone(own.get(i), bornAt.get(own.get(i).getName()).longValue()); + } + } + + /// Deletes a tombstone and its acknowledgement, and forgets both markers -- but only while the + /// file is still the VERSION this pass examined. + /// + /// A tombstone is identified by its path, so a path removed, republished and removed again + /// reuses the same file name. Between reading a tombstone here and deleting it, those two calls + /// can replace it, and an unguarded delete then destroyed a removal made moments ago while + /// vouching for the one it had actually inspected. The offline peer the tombstone exists for + /// would have missed that second removal entirely. + /// + /// {@link #writeLock} serialises this against this JVM's own putData and removeData. The peer + /// is a different process and no lock reaches it, which is why the version is re-read here as + /// well: it cannot make the delete atomic, but it closes the window to the interval between + /// this check and the delete itself, rather than the whole pass. + private void retireTombstone(File tomb, long expected) { + byte[] current; + try { + current = readFully(tomb); + } catch (IOException alreadyGone) { + return; + } + if (tombstoneVersion(current, tomb) != expected) { + return; + } + File ack = new File(dataDir, tomb.getName() + ACK_SUFFIX); + if (!tomb.delete() && tomb.isFile()) { + // The tombstone is STILL THERE -- a read-only directory, or another process holding + // the file. Everything else stays with it. Dropping the acknowledgement here stranded + // the tombstone permanently: the peer has already marked the unchanged file as seen and + // will not acknowledge it a second time, so once the directory falls back under the cap + // no later pass can retire it either. + return; + } + ack.delete(); + synchronized (seenData) { + seenData.remove(tomb.getName()); + seenData.remove(ack.getName()); + } + } + + /// A tombstone's birth time in wall-clock millis, from the stamp inside it. + /// + /// Falls back to the mtime for a file with no frame -- setLastModified can be refused outright, + /// so that is a guess, but it is the only answer available for a file this build did not write. + private static long tombstoneVersion(byte[] snapshot, File f) { + long born = framedStamp(snapshot); + return born == Long.MIN_VALUE ? f.lastModified() : born / 2; + } + + /// Serialises this side's tombstone housekeeping against its own publications and removals. + /// + /// The pass reads a tombstone, decides it is retirable and then deletes it, and putData / + /// removeData run on application threads with no relation to the scanner. Without this a + /// republish-and-remove landing in that gap had its FRESH tombstone deleted on the strength of + /// the old one's acknowledgement. It does not reach the peer JVM -- see retireTombstone, which + /// re-reads the version for that. + private final Object writeLock = new Object(); + + /// When the tombstone pass last ran. It reads every tombstone, and the scan it hangs off runs + /// twice a second. + private long lastTombPrune; + + private static final long TOMB_PRUNE_INTERVAL_MILLIS = 5000L; + + private void scanData() { + File[] files = dataDir.listFiles(); + pruneOwnTombstones(files); + List gone; + synchronized (seenData) { + gone = new ArrayList(seenData.keySet()); + } + if (files != null) { + for (File f : files) { + if (!f.isFile() || f.getName().endsWith(".tmp")) { + continue; + } + gone.remove(f.getName()); + Long previous; + synchronized (seenData) { + previous = seenData.get(f.getName()); + } + long stamp = f.lastModified(); + // The AUTHOR is part of what makes a file "already seen", not the timestamp alone. + // On a filesystem with coarse mtime granularity a local publication and a peer + // replacement can land in the same tick: the local write has already stored that + // timestamp, so the peer's file matched and was skipped without being looked at, + // and nothing delivered it until some later write happened to move the stamp. A + // cheap header peek separates them -- the two sides never write the same author. + long seenKey = peekSeenKey(f, stamp); + if (previous != null && previous.longValue() == seenKey) { + continue; + } + synchronized (seenData) { + seenData.put(f.getName(), Long.valueOf(seenKey)); + } + if (isTransfer(f.getName()) && !isInboundTransfer(f.getName())) { + // Our own outbound transfer, seen again because primeSeenData() deliberately + // records nothing at startup. It is not an inbound delivery: reporting it would + // hand the sender its own file through its own data listener. + continue; + } + // ONE read, used for both the author test and the payload. Classifying from the + // file and then reading it again let this JVM's own putData land in between: the + // author check inspected the peer's file, the read returned our freshly published + // bytes, and the peer-only listener was handed this device's own publication -- + // with writeValue having already recorded the local stamp, so no later scan + // corrected it. + byte[] snapshot; + try { + snapshot = readFully(f); + } catch (IOException stillBeingWritten) { + synchronized (seenData) { + seenData.remove(f.getName()); + } + continue; + } + // The DELIVERED snapshot decides what was seen, not the earlier peek. A peer can + // replace the file between the two with one carrying the same coarse timestamp, so + // the peek's classification could be recorded while the peer's bytes were handed + // over -- and the next scan, computing the key from that same peer file, would see + // a different key and deliver it a second time. + synchronized (seenData) { + seenData.put(f.getName(), + Long.valueOf(seenKey(framedStamp(snapshot), stamp, + authoredLocallyFor(snapshot, stamp)))); + } + if (f.lastModified() != stamp) { + // Replaced while we were reading it, so this snapshot belongs to neither the + // file we classified nor the one now on disk. Forget the marker and let the + // next scan see the winner whole. + // + // Unless the file is GONE. This scan has already taken the name off `gone`, so + // dropping the marker too would leave the next scan with nothing to notice the + // disappearance by -- a peer that republished and then removed a path would + // never produce dataRemoved, and the listener would hold the old value for + // good. Restoring the previous marker puts the name back in the next scan's + // `gone` list, which is what reports the removal. + synchronized (seenData) { + if (!f.exists() && previous != null) { + seenData.put(f.getName(), previous); + } else { + seenData.remove(f.getName()); + } + } + continue; + } + if (isTombstoneAck(f.getName())) { + // Bookkeeping between the two simulators. Delivering it would announce a path + // ending in ".tomb.ack" that no app ever published. + continue; + } + if (isTombstone(f.getName()) + && dataFile(tombstonePath(f.getName())).isFile()) { + // BOTH records exist for this path -- the two processes interleaved a publish + // and a removal. Reconcile before delivering either, or the listener settles on + // whichever the enumeration happened to reach first while getData answers from + // the other, and nothing is guaranteed to touch that path again. + resolveValueAgainstTombstone(tombstonePath(f.getName())); + if (!f.isFile()) { + continue; + } + } + if (isTombstone(f.getName())) { + // A peer's removal. Delivered once -- the seen-marker above has already been + // updated, so a tombstone that does not change is not re-announced. + if (authoredLocallyFor(snapshot, stamp)) { + // Ours. Keep it for the peer to find, and drop it once it is old enough + // that any peer which was going to see it has had every chance. + // Kept for the peer to find. Retiring it is housekeeping and does NOT + // belong here: this loop only reaches a file whose stamp changed, and a + // tombstone never changes after it is written -- writeValue records its + // stamp as it creates it, so the very next scan skips it as already seen. + // An acknowledgement arrives as a SEPARATE file and does not disturb the + // tombstone's marker, so nothing here would ever run again. See + // pruneOwnTombstones, which runs over all of them regardless of what the + // delivery loop has seen. + continue; + } + WearableConnection.deliverDataRemoved(tombstonePath(f.getName())); + // Acknowledged, so the author can retire it. Without this the author has no way + // to know the removal was ever seen and can only guess by age -- and a peer + // that was closed for longer than the window comes back to find neither the + // value nor the tombstone, so it never learns of the removal at all. + // + // Carrying the stamp of the tombstone it acknowledges, not merely existing. A + // path can be removed, republished and removed again inside the window, and a + // name-only check read the FIRST removal's acknowledgement as confirmation of + // the second -- so the author retired a tombstone no peer had seen, and an + // offline peer came back to neither the value nor the removal. + writeValue(new File(dataDir, f.getName() + ACK_SUFFIX), + acknowledgementFor(snapshot), tombstonePath(f.getName())); + continue; + } + if (!isTransfer(f.getName()) && authoredLocallyFor(snapshot, stamp)) { + // Our own VALUE, for the same reason. primeSeenData() records nothing so that a + // value published while this side was down still replays on startup -- but that + // also replayed values THIS side published before it restarted, reporting them + // through WearableDataListener, whose contract is peer changes only. + // + // The author is already in the stamp: nextStamp puts the two JVMs in disjoint + // residue classes (base * 2 + sideBit) so they cannot collide, and that bit + // says which side wrote the file. No extra bookkeeping needed, and it survives + // a restart because it lives in the file's own timestamp. + continue; + } + { + final File delivered = f; + final boolean inbound = isInboundTransfer(f.getName()); + // Deleted from INSIDE the delivery, not beside it. This file is the only durable + // copy of a one-shot transfer: deleting it as soon as the delivery was queued + // lost it outright if the simulator closed before the listener ran, or if the + // delivery was merely parked because no listener had registered yet. + WearableConnection.deliverDataChangedTracked(deliveryPath(f.getName()), + payloadOf(snapshot), inbound ? new Runnable() { + public void run() { + // The marker goes only if the file actually did. A delete that + // fails -- a read-only directory, a Windows handle still open + // -- would otherwise leave an unchanged one-shot file that the + // next 500ms scan reads as new, delivering it again and again + // for as long as the deletion keeps failing. + if (delivered.delete()) { + synchronized (seenData) { + seenData.remove(delivered.getName()); + } + } + } + } : null, inbound ? new Runnable() { + public void run() { + // Evicted from the pending queue before a listener existed. The + // file is still on disk, but this scan already recorded it as + // seen, so nothing would offer it again until a restart. + // Forgetting it puts it back in front of the next scan. + synchronized (seenData) { + seenData.remove(delivered.getName()); + } + } + } : null); + // The deletion that used to live here now runs inside the delivery callback + // above. A transfer is one-shot, so the delivered file goes -- leaving it would + // make every restart of the receiving simulator replay it, since + // primeSeenData() deliberately records nothing so that offline VALUES do + // replay. Only an INBOUND transfer is deleted: removing our own would destroy + // one still waiting for the peer to start. + } + } + } + for (String name : gone) { + synchronized (seenData) { + seenData.remove(name); + } + if (isTransfer(name)) { + // A transfer disappearing means the peer consumed it, which is the transport doing + // its job -- not the logical path being removed. Reporting dataRemoved here would + // tell the sender's own listeners that a path it never removed had gone, and that + // path may well still hold an unrelated replicated value. + continue; + } + if (isTombstone(name) || isTombstoneAck(name)) { + // A tombstone or its acknowledgement expiring is housekeeping, not a removal. + continue; + } + if (new File(dataDir, name + TOMB_SUFFIX).isFile()) { + // A tombstone covers this disappearance and announces it exactly once -- for a live + // peer AND for one that was closed when the removal happened. Announcing here too + // would deliver the same removal twice to a peer that happened to be running. + continue; + } + WearableConnection.deliverDataRemoved(deliveryPath(name)); + } + } + + // --- helpers ------------------------------------------------------------ + + private File dataFile(String path) { + return new File(dataDir, encodePath(path)); + } + + /** + * A modification stamp strictly newer than the one this file already carries, and than any this + * process has written for it. The file system's own granularity can be as coarse as a second, so + * "now" is not on its own enough to mark a value as new. + */ + /** + * A staging-file suffix unique to this process and call. Still ends in {@code .tmp} so the + * watcher's existing skip rule keeps ignoring staging files. + */ + private static synchronized String stagingSuffix() { + return "." + PROCESS_TAG + "." + (stagingCounter++) + ".tmp"; + } + + private static int stagingCounter; + /** Identifies this JVM among the pair; the two sides share a directory but not a process. */ + private static final String PROCESS_TAG = + Integer.toHexString(java.lang.management.ManagementFactory.getRuntimeMXBean() + .getName().hashCode()); + + private long nextStamp(File f) { + synchronized (JavaSEWearableBridge.class) { + long now = System.currentTimeMillis(); + // The file already carries an ENCODED stamp (base * 2 + sideBit), so decode it before + // using it as a floor. Feeding the encoded value straight back in doubled the base on + // every publish, which runs away exponentially within a few dozen writes. + long floor = Math.max(f.lastModified() / 2, lastStamp); + long base = now > floor ? now : floor + 1; + // Put the two JVMs in disjoint residue classes. lastStamp and this lock are process + // local, so both halves publishing the same path in the same millisecond could otherwise + // compute the SAME stamp from the same lastModified() -- and each would then record the + // other's published stamp as its own and never deliver the peer's value. Doubling and + // adding a side bit makes a collision arithmetically impossible while keeping the + // strictly-increasing property the watcher relies on. + lastStamp = base; + return base * 2 + (watchSide ? 1 : 0); + } + } + + /** + * Whether a published stamp was written by THIS side of the pair. + * + *

Reads the side bit {@link #nextStamp} encodes. Only meaningful for stamps this bridge + * wrote; a file whose modification time the filesystem quantized may answer either way, which + * is why publication records the value the filesystem actually stored rather than the one it + * was asked for.

+ */ + private boolean authoredLocally(long stamp) { + return (stamp & 1L) == (watchSide ? 1L : 0L); + } + + /** + * Author identity that does not depend on the filesystem preserving a single bit. + * + *

The side bit rides in the stamp's low bit, and a filesystem that rounds modification times + * -- FAT to two seconds, some network mounts coarser -- erases it. The phone then reads every + * watch publication as locally authored and suppresses its callback, which is the pairing + * silently not working rather than failing. + * + *

So the author is also written into the value's own header, which the filesystem cannot + * round. The stamp stays authoritative when no header is present (a value left by an older + * build), because a wrong-but-present answer is worse than the previous behaviour only if it + * disagrees, and the header is published by the same rename as the bytes it describes.

+ */ + /// The writer's own stamp out of the frame, or Long.MIN_VALUE when the bytes carry none. + private static long framedStamp(byte[] all) { + // Only the current framing carries one; a v1 file falls back to mtime folded with author. + if (headerLength(all) != VALUE_HEADER_LENGTH) { + return Long.MIN_VALUE; + } + long v = 0; + for (int i = 0; i < 8; i++) { + v = (v << 8) | (all[VALUE_MAGIC.length + 1 + i] & 0xffL); + } + return v; + } + + /// Identifies a version of a file. + /// + /// The writer's embedded stamp when there is one, because it is unique per write and cannot be + /// blurred by a filesystem that rounds timestamps -- two writes by the same side within one + /// coarse tick were otherwise identical here, so the second was skipped as already-seen and the + /// listener kept the older value indefinitely. + /// + /// Falls back to mtime folded with the author for bytes that carry no frame, which is the best + /// available answer for a file this build did not write. + private static long seenKey(long framedStamp, long mtime, boolean authoredLocally) { + if (framedStamp != Long.MIN_VALUE) { + return framedStamp; + } + return mtime * 2 + (authoredLocally ? 1 : 0); + } + + /// Reads just the frame header to decide authorship, so a 500ms scan does not pull whole + /// transfer payloads into memory. The delivery path re-derives this from the full snapshot it + /// actually hands over, so a file replaced between the two is still caught there. + /// The version marker from the header alone, so a 500ms scan does not pull whole transfer + /// payloads into memory. The delivery path recomputes this from the full snapshot it hands + /// over, so a file replaced between the two is recorded as the file actually delivered. + private long peekSeenKey(File f, long mtime) { + byte[] head = readHead(f); + if (head == null) { + return seenKey(Long.MIN_VALUE, mtime, authoredLocally(mtime)); + } + return seenKey(framedStamp(head), mtime, authoredLocallyFor(head, mtime)); + } + + private boolean peekAuthoredLocally(File f, long stamp) { + byte[] head = readHead(f); + return head == null ? authoredLocally(stamp) : authoredLocallyFor(head, stamp); + } + + /// The frame header, or null when the file is shorter than one or unreadable. + private static byte[] readHead(File f) { + byte[] head = new byte[VALUE_HEADER_LENGTH]; + FileInputStream in = null; + try { + in = new FileInputStream(f); + int off = 0; + while (off < head.length) { + int r = in.read(head, off, head.length - off); + if (r < 0) { + break; + } + off += r; + } + if (off < VALUE_HEADER_V1_LENGTH) { + return null; + } + if (off < head.length) { + // A v1 file, or a v2 file truncated: hand back exactly what was read so + // headerLength can judge it rather than guessing from a padded buffer. + byte[] shorter = new byte[off]; + System.arraycopy(head, 0, shorter, 0, off); + return shorter; + } + } catch (IOException unreadable) { + return null; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + } + } + } + return head; + } + + private boolean authoredLocallyFor(byte[] snapshot, long stamp) { + Boolean recorded = authorOf(snapshot); + if (recorded != null) { + return recorded.booleanValue() == watchSide; + } + return authoredLocally(stamp); + } + + /// Convenience for callers holding a File rather than its bytes. + private boolean authoredLocallyFor(File f, long stamp) { + try { + return authoredLocallyFor(readFully(f), stamp); + } catch (IOException unreadable) { + return authoredLocally(stamp); + } + } + + /// The author recorded in the value's own header: TRUE for the watch, FALSE for the phone, + /// null when the file predates the header or is too short to carry one. + private static Boolean authorOf(byte[] snapshot) { + if (headerLength(snapshot) == 0) { + return null; + } + // Same offset in both framings. + byte who = snapshot[VALUE_MAGIC.length]; + if (who == 'w') { + return Boolean.TRUE; + } + if (who == 'p') { + return Boolean.FALSE; + } + return null; + } + + /// True when these bytes carry the author frame. + private static boolean framed(byte[] all) { + return headerLength(all) > 0; + } + + /// The frame length these bytes carry: 0 when unframed, {@link #VALUE_HEADER_V1_LENGTH} for the + /// first framing, {@link #VALUE_HEADER_LENGTH} for the current one. + private static int headerLength(byte[] all) { + if (matches(all, VALUE_MAGIC) && all.length >= VALUE_HEADER_LENGTH) { + return VALUE_HEADER_LENGTH; + } + if (matches(all, VALUE_MAGIC_V1) && all.length >= VALUE_HEADER_V1_LENGTH) { + return VALUE_HEADER_V1_LENGTH; + } + return 0; + } + + private static boolean matches(byte[] all, byte[] magic) { + if (all == null || all.length < magic.length) { + return false; + } + for (int i = 0; i < magic.length; i++) { + if (all[i] != magic[i]) { + return false; + } + } + return true; + } + + /// The value's bytes with the author frame removed, from an in-memory snapshot. + private static byte[] payloadOf(byte[] all) { + int skip = headerLength(all); + if (skip == 0) { + return all; + } + byte[] body = new byte[all.length - skip]; + System.arraycopy(all, skip, body, 0, body.length); + return body; + } + + /// Marks a framed value file. Chosen so a stale sandbox written before the header existed still + /// reads correctly: no magic simply means "fall back to the stamp's side bit". + private static final byte[] VALUE_MAGIC = {'C', 'N', '1', 'W', 'A', '2'}; + + /// The first framing, still read. It carries the author but no stamp, so a sandbox written by + /// the immediately preceding build keeps working: rejecting it outright would hand the app its + /// own six magic bytes as payload, and WearableMessage would read the 'C' as a wire version and + /// decode an empty value. + private static final byte[] VALUE_MAGIC_V1 = {'C', 'N', '1', 'W', 'A', '1'}; + private static final int VALUE_HEADER_V1_LENGTH = VALUE_MAGIC_V1.length + 1; + + /// magic + author + the writer's own 8-byte stamp. + /// + /// The stamp is in the FILE because the filesystem cannot be trusted to keep it. mtime is what + /// the scan used to identify a version, and on a coarse-granularity filesystem two writes by + /// the same side within one tick are indistinguishable by mtime and author alike -- so the + /// second value was skipped as already-seen and the listener sat on the older one until some + /// unrelated write moved the clock. nextStamp already produces a value that is unique per write + /// and monotonic per side; writing it down makes the identity exact and independent of what the + /// filesystem chose to record. + private static final int VALUE_HEADER_LENGTH = VALUE_MAGIC.length + 1 + 8; + + + /// The value's bytes with the author frame removed. An unframed file is returned whole, so a + /// value left by an older build still reads as itself rather than losing its first seven bytes. + private static byte[] readPayload(File f) throws IOException { + // One read, then inspect the prefix in memory. Reading the file twice -- once for the + // header, once for the bytes -- could straddle a republication and return one file's + // header with another file's payload. + return payloadOf(readFully(f)); + } + + private static long lastStamp; + + /// Paths are URL-ish (`/workout/start`) and must survive a round trip through a file name on a + /// case-insensitive file system, so everything outside a conservative set is percent-escaped. + private static String encodePath(String path) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + // '.' is deliberately NOT in this set, which fixes two problems at once and makes + // both structural rather than pattern matches. + // + // Staging files are named "...tmp". While an encoded path could + // itself contain a dot, a published path of "/sync/state.tmp" was indistinguishable + // from a staging file, so its peer callback was skipped forever and getDataPaths() + // hid it even though getData() could read it. With dots escaped, a literal dot in a + // file name can only have come from the staging suffix. + // + // It also stops "." and ".." being filesystem references: they encoded to themselves, + // so dataFile(".") resolved to the data directory, putData(".") could never replace + // it, and removeData(".") could delete the directory when empty. + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { + sb.append(c); + } else { + sb.append('%').append(Integer.toHexString(0x10000 | c).substring(1)); + } + } + return sb.toString(); + } + + private static String decodePath(String name) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '%' && i + 4 < name.length()) { + sb.append((char) Integer.parseInt(name.substring(i + 1, i + 5), 16)); + i += 4; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static byte[] readFully(File f) throws IOException { + FileInputStream in = new FileInputStream(f); + try { + byte[] out = new byte[(int) f.length()]; + int read = 0; + while (read < out.length) { + int n = in.read(out, read, out.length - read); + if (n < 0) { + throw new IOException("Truncated while reading " + f); + } + read += n; + } + return out; + } finally { + in.close(); + } + } + + /// Stops the link. Called when the simulator shuts down. + void close() { + closed = true; + dropPeer(); + } +} diff --git a/Ports/iOSPort/nativeSources/CN1AudioUnit.m b/Ports/iOSPort/nativeSources/CN1AudioUnit.m index 6947988db60..9f91220dc92 100644 --- a/Ports/iOSPort/nativeSources/CN1AudioUnit.m +++ b/Ports/iOSPort/nativeSources/CN1AudioUnit.m @@ -196,5 +196,9 @@ -(void)dealloc { } @end +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1audiounit_unused_on_watch; #endif // !TARGET_OS_WATCH - diff --git a/Ports/iOSPort/nativeSources/CN1ES1compat.m b/Ports/iOSPort/nativeSources/CN1ES1compat.m index f822de0b5a3..9b8ab70185f 100644 --- a/Ports/iOSPort/nativeSources/CN1ES1compat.m +++ b/Ports/iOSPort/nativeSources/CN1ES1compat.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CN1ES2compat.h" #ifndef USE_ES2 void glEnableCN1StateES1(enum CN1GLenum state){ @@ -33,3 +36,10 @@ void glAlphaMaskTexCoordPointerES1( GLint size , GLenum type, GLsizei stride, co } #endif + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1es1compat_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1ES2compat.m b/Ports/iOSPort/nativeSources/CN1ES2compat.m index ed2e9830075..a009caa0659 100644 --- a/Ports/iOSPort/nativeSources/CN1ES2compat.m +++ b/Ports/iOSPort/nativeSources/CN1ES2compat.m @@ -1,3 +1,28 @@ +/* + * Copyright (c) 2014, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CN1ES2compat.h" #import "CodenameOne_GLViewController.h" #ifdef USE_ES2 @@ -805,3 +830,10 @@ void glDisableCN1StateES2(enum CN1GLenum state){ #endif + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1es2compat_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1GL3D.m b/Ports/iOSPort/nativeSources/CN1GL3D.m index dfca18f5985..def6bf941cd 100644 --- a/Ports/iOSPort/nativeSources/CN1GL3D.m +++ b/Ports/iOSPort/nativeSources/CN1GL3D.m @@ -6,8 +6,24 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CN1GL3D.h" #import "xmlvm.h" @@ -653,3 +669,10 @@ void com_codename1_impl_ios_IOSNative_gl3dDrawArrays___long_long_long_int_int_in JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap) {} #endif /* CN1_USE_METAL */ + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1gl3d_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m index b3709ed8f85..cba3a170764 100644 --- a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m +++ b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ // CN1MetalGlyphAtlas.m // // Phase 4 implementation. See header for design rationale. @@ -11,6 +33,9 @@ // i.e. right-side-up in raster memory order, ready for V=0-at-top // sampling. +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CN1ES2compat.h" #ifdef CN1_USE_METAL #import "CN1MetalGlyphAtlas.h" @@ -374,3 +399,10 @@ void CN1MetalGlyphAtlasReleaseAll(void) { } #endif // CN1_USE_METAL + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1metalglyphatlas_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m b/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m index 574c1d801f5..83e6bce2d10 100644 --- a/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m +++ b/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m @@ -1,7 +1,28 @@ /* * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CN1ES2compat.h" #ifdef CN1_USE_METAL #import "CN1MetalPipelineCache.h" @@ -158,3 +179,10 @@ static void configureStencilWriteOnly(MTLRenderPipelineColorAttachmentDescriptor @end #endif /* CN1_USE_METAL */ + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1metalpipelinecache_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index 5f67e0e2b28..db0604da3aa 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CN1ES2compat.h" #ifdef CN1_USE_METAL #import "CN1Metalcompat.h" @@ -2026,3 +2029,10 @@ void CN1MetalReleaseCaches(void) { } #endif /* CN1_USE_METAL */ + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1metalcompat_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1TapGestureRecognizer.m b/Ports/iOSPort/nativeSources/CN1TapGestureRecognizer.m index f30f309fb0d..e3e3a1217fc 100644 --- a/Ports/iOSPort/nativeSources/CN1TapGestureRecognizer.m +++ b/Ports/iOSPort/nativeSources/CN1TapGestureRecognizer.m @@ -323,4 +323,9 @@ - (void) ignoreTouch:(UITouch *)touch forEvent:(UIEvent *)event // not being called after moving a certain threshold } @end +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1tapgesturerecognizer_unused_on_watch; #endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1UITextField.m b/Ports/iOSPort/nativeSources/CN1UITextField.m index 6dc51176fc5..3cec1126f66 100644 --- a/Ports/iOSPort/nativeSources/CN1UITextField.m +++ b/Ports/iOSPort/nativeSources/CN1UITextField.m @@ -39,4 +39,9 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return [super canPerformAction:action withSender:sender]; } @end -#endif // !TARGET_OS_WATCH \ No newline at end of file +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1uitextfield_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1UITextView.m b/Ports/iOSPort/nativeSources/CN1UITextView.m index bc3f1e4f3a7..4577cb881e5 100644 --- a/Ports/iOSPort/nativeSources/CN1UITextView.m +++ b/Ports/iOSPort/nativeSources/CN1UITextView.m @@ -39,4 +39,9 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return [super canPerformAction:action withSender:sender]; } @end -#endif // !TARGET_OS_WATCH \ No newline at end of file +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_cn1uitextview_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h new file mode 100644 index 00000000000..2b74a46aa6b --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h @@ -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. + */ + +// WatchConnectivity glue backing com.codename1.wearable on Apple. +// +// The same file compiles into BOTH the phone target and the watch target: WCSession is symmetric, +// so the phone half and the watch half of a pair run identical code and the Java API is identical +// on both ends. WatchConnectivity is unavailable on tvOS and Mac Catalyst, and the whole file is +// additionally gated on CN1_USE_WATCHCONNECTIVITY, which the build defines only when the app +// references com.codename1.wearable -- apps that do not pay nothing and link no framework. +// +// Everything below moves opaque byte payloads; the value model lives in Java +// (com.codename1.wearable.WearableMessage), so this layer never has to understand it. + +#ifndef CN1WatchConnectivity_h +#define CN1WatchConnectivity_h + +#include "TargetConditionals.h" +// CN1_USE_WATCHCONNECTIVITY lives in the central header the builder edits. Every translation unit +// that tests it has to see that definition, so import it here rather than in the .m: without this +// the guard below is always false, the implementation compiles away, and the app fails to link +// against a class the natives call. +#import "CodenameOne_GLViewController.h" + +#if defined(CN1_USE_WATCHCONNECTIVITY) && !TARGET_OS_TV && !TARGET_OS_MACCATALYST + +#import +#import + +@interface CN1WatchConnectivity : NSObject + +/// Returns the shared instance, activating the WCSession on first use. ++ (CN1WatchConnectivity *)shared; + +/// True when this device supports the link at all. False on an iPad, and on an iPhone whose +/// WCSession is not supported. +- (BOOL)isSupported; + +/// True when a counterpart device is paired. Always true from the watch side, which by definition +/// has a phone. +- (BOOL)isPaired; + +/// True when the peer app can receive a live message right now. +- (BOOL)isReachable; + +/// True when the counterpart app is installed on the paired device. +- (BOOL)isCompanionInstalled; + +/// Sends a live message. A non-zero replyToken asks the peer for an answer, which comes back through +/// cn1_wearable_deliverReply. +- (void)sendMessage:(NSString *)path payload:(NSData *)payload replyToken:(int)replyToken; + +/// Answers a message that arrived carrying a reply token. +- (void)sendReply:(int)replyToken payload:(NSData *)payload; + +/// Publishes or replaces the replicated value at a path. +- (void)putData:(NSString *)path payload:(NSData *)payload; + +/// Returns the replicated value at a path, or nil. +- (NSData *)getData:(NSString *)path; + +/// Removes the replicated value at a path. +- (void)removeData:(NSString *)path; + +/// Returns every path currently holding a replicated value. +- (NSArray *)dataPaths; + +/// Queues a file transfer to the peer. +- (void)transferFile:(NSString *)path name:(NSString *)name contents:(NSData *)contents; + +/// Drops a path's received marker so the next whole-context update delivers it again. +- (void)forgetReceivedPath:(NSString *)path; + +/// Drops every received marker, for the overflow rescan. +- (void)forgetAllReceived; + +/// Re-runs the received-context delivery once, after any number of paths have been forgotten. +- (void)scheduleReceivedContextReplay; + +@end + +#endif // CN1_USE_WATCHCONNECTIVITY + +// Entry points into the Java side, implemented in IOSNative.m so this file needs no knowledge of +// the VM. No-ops when the feature is compiled out. +void cn1_wearable_deliverMessage(const char *path, const void *payload, int payloadLength, int replyToken); +void cn1_wearable_deliverReply(int replyToken, const void *payload, int payloadLength, const char *error); +void cn1_wearable_deliverDataChanged(const char *path, const void *payload, int payloadLength); +/// Same delivery, but the inbox entry named by inboxToken is retired only once the EDT has actually +/// consumed the payload. Renaming at queue time marked a transfer delivered that a process death +/// could still lose, and the ".done" sweep would then drop it without ever replaying it. +void cn1_wearable_deliverDataChangedTracked(const char *path, const void *payload, int payloadLength, + const char *inboxToken); +/// Retires an inbox entry. Called from Java once the delivery has run on the EDT. +void cn1_wearable_confirmInbox(const char *inboxToken); +/// Gives up an undelivered entry, keeping the file but clearing its in-flight mark so a later +/// activation can replay it. +void cn1_wearable_releaseInbox(const char *inboxToken); +/// Re-offers everything still parked in the inbox. Called once a listener exists. +void cn1_wearable_replayInbox(void); +/// Forgets that a path's value was received, so the next context update delivers it again. +void cn1_wearable_forgetReceived(const char *path); +void cn1_wearable_deliverDataRemoved(const char *path); +void cn1_wearable_notifyStateChanged(void); + +#endif /* CN1WatchConnectivity_h */ diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m new file mode 100644 index 00000000000..d93a6f9484c --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -0,0 +1,1519 @@ +/* + * 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 "CN1WatchConnectivity.h" + +#if defined(CN1_USE_WATCHCONNECTIVITY) && !TARGET_OS_TV && !TARGET_OS_MACCATALYST + +// Keys inside the dictionaries WCSession carries. WCSession moves property lists, and the Java +// payload is opaque bytes, so every transfer is a two-entry dictionary: the path it is addressed to +// and the bytes themselves. +static NSString *const kPathKey = @"cn1.path"; +static NSString *const kBodyKey = @"cn1.body"; +static NSString *const kTokenKey = @"cn1.token"; +static NSString *const kReplyKey = @"cn1.reply"; +/// The application context is a flat dictionary shared with the peer, and it has to hold both the +/// published values and the bookkeeping that orders them. Reserving a top-level key for the +/// bookkeeping would collide with an application that publishes a path of the same name -- and since +/// values are NSData and the bookkeeping is a dictionary, that collision is an unrecognized-selector +/// crash rather than a wrong answer. +/// +/// So every application path is prefixed instead. The namespaces are then disjoint by construction: +/// no caller's path can land on a metadata key, whatever it is called. +/// +/// - `v.` the published bytes +/// - `s.` the sequence the bytes were published at +/// - `t.` the sequence a removal happened at (a tombstone, with no `v.` entry) +static NSString *const kValuePrefix = @"v."; +static NSString *const kStampPrefix = @"s."; +static NSString *const kTombPrefix = @"t."; + +/// A monotonic publication stamp. Wall-clock millis order correctly against the peer's stamps (both +/// devices are time-synced far more tightly than a context replication takes), the counter breaks +/// ties between two publishes inside the same millisecond on this device, and -- because wall time +/// alone is not enough when the peer runs ahead or this clock is corrected backwards -- it is also +/// raised past every stamp the peer has shown us. +/// +/// Where that floor is kept so it survives a relaunch. Without persistence the counter restarts at +/// wall time on every launch and a peer that ran ahead wins all over again. +static NSString *const kCn1WearableClockKey = @"cn1.wearable.clock"; + +static NSLock *cn1WearableClockLock(void) { + static dispatch_once_t once; + static NSLock *lock = nil; + dispatch_once(&once, ^{ + lock = [[NSLock alloc] init]; + }); + return lock; +} + +/// The high-water mark: wall time, our own prior writes, AND every stamp a peer has shown us. +/// The highest value this process has PERSISTED, which is what a new write has to beat. +/// +/// Seeded from the stored floor on first use and raised on every write. Re-reading only the value +/// restored at startup was wrong the moment a second observation arrived: after persisting 1000, a +/// later stamp of 900 still exceeded the ORIGINAL floor and overwrote the stored value with the +/// lower one -- so a process that exited before its next local publication came back with a floor +/// beneath the peer's existing entry, and published values that lost to it until wall time caught +/// up. Guarded by the same lock as cn1WearableLast. +static int64_t cn1WearablePersistedFloor = 0; + +static int64_t cn1WearableClockFloor(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + cn1WearablePersistedFloor = + (int64_t) [[NSUserDefaults standardUserDefaults] doubleForKey:kCn1WearableClockKey]; + }); + return cn1WearablePersistedFloor; +} + +static int64_t cn1WearableLast = 0; + +/// Raises the clock past a stamp we have just seen from the peer. +/// +/// Without this the counter only ever observed local time and local writes, so a peer publishing +/// while its clock ran ahead -- or this device's clock being corrected backwards -- left every +/// subsequent local putData/removeData carrying a LOWER stamp than the peer's existing entry. The +/// peer's older value then keeps winning, and getData() keeps returning it, until wall time +/// catches up. This is the same Lamport rule the Android side applies in sequenceOf(). +static void cn1WearableObserveSequence(int64_t seen) { + if (seen <= 0) { + return; + } + NSLock *lock = cn1WearableClockLock(); + [lock lock]; + int64_t floorValue = cn1WearableClockFloor(); + if (seen > cn1WearableLast) { + cn1WearableLast = seen; + } + if (seen > floorValue) { + // Persist so the floor outlives this process; a relaunch that forgot it would hand the + // peer the advantage back. The high-water mark moves with it, so a lower later stamp + // cannot overwrite this one. + cn1WearablePersistedFloor = seen; + [[NSUserDefaults standardUserDefaults] setDouble:(double) seen forKey:kCn1WearableClockKey]; + } + [lock unlock]; +} + +/// How long an unanswered inbound reply block is kept. Comfortably past the sender's own reply +/// deadline: by the time this fires the peer has already given up, so the block can only be +/// discarded, never usefully invoked. +static const NSTimeInterval kCN1ReplyExpirySeconds = 120.0; + +/// Stand-in stamp meaning "this path's removal has already been announced". A real stamp is a +/// wall-clock millisecond value, so a negative sentinel cannot collide with one. +static const int64_t kCN1RemovalAnnounced = -1; + +/// Drops reply blocks the app never answered. Caller holds the _pendingReplies monitor. +static void cn1WearableExpireReplies(NSMutableDictionary *replies, NSMutableDictionary *arrivedAt) { + if (replies.count == 0) { + return; + } + NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; + NSMutableArray *stale = [NSMutableArray array]; + for (NSNumber *key in arrivedAt.allKeys) { + NSNumber *at = arrivedAt[key]; + if (at == nil || now - at.doubleValue > kCN1ReplyExpirySeconds) { + [stale addObject:key]; + } + } + for (NSNumber *key in stale) { + [replies removeObjectForKey:key]; + [arrivedAt removeObjectForKey:key]; + } +} + +/// Where received transfers are parked so a process death cannot lose them. +static NSString *cn1WearableInboxDir(void) { + NSArray *dirs = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, + NSUserDomainMask, YES); + NSString *base = dirs.count > 0 ? dirs[0] : NSTemporaryDirectory(); + NSString *dir = [base stringByAppendingPathComponent:@"cn1-wearable-inbox"]; + [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES + attributes:nil error:NULL]; + return dir; +} + +/// Writes the encoded transfer to the inbox and returns its file name, or nil. +static NSString *cn1WearableStashInbox(NSString *path, NSData *wrapped) { + if (wrapped == nil) { + return nil; + } + NSString *name = [[NSUUID UUID] UUIDString]; + NSString *full = [cn1WearableInboxDir() stringByAppendingPathComponent:name]; + NSMutableDictionary *entry = [NSMutableDictionary dictionary]; + entry[@"p"] = path == nil ? @"" : path; + entry[@"b"] = wrapped; + if (![NSKeyedArchiver respondsToSelector:@selector(archivedDataWithRootObject:requiringSecureCoding:error:)]) { + return nil; + } + NSData *blob = [NSKeyedArchiver archivedDataWithRootObject:entry + requiringSecureCoding:NO error:NULL]; + if (blob == nil || ![blob writeToFile:full atomically:YES]) { + return nil; + } + return name; +} + +/// Inbox entries this process has already handed to the runtime and is still waiting to have +/// confirmed. +/// +/// Delivery is asynchronous -- the payload sits in WearableConnection's queue until the EDT runs +/// it -- and cn1WearableDrainInbox runs on every session activation, not only the first. A session +/// that deactivates and reactivates while a delivery is still queued (the watch-switch flow does +/// exactly this) would otherwise find the same entry on disk and replay it, so the app would +/// receive one one-shot transfer twice. Entries leave this set when they are confirmed, which is +/// also when the file goes. +static NSMutableSet *cn1WearableInFlight(void) { + static NSMutableSet *inFlight = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + inFlight = [[NSMutableSet alloc] init]; + }); + return inFlight; +} + +/// Serializes access to the in-flight set: deliveries are handed over from the WCSession delegate +/// queue while confirmations arrive from the EDT. +static NSLock *cn1WearableInFlightLock(void) { + static NSLock *lock = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + lock = [[NSLock alloc] init]; + }); + return lock; +} + +/// Marks an entry as handed to the runtime. Returns NO when it already was. +static BOOL cn1WearableMarkInFlight(NSString *name) { + if (name == nil) { + return NO; + } + NSLock *lock = cn1WearableInFlightLock(); + [lock lock]; + NSMutableSet *inFlight = cn1WearableInFlight(); + BOOL fresh = ![inFlight containsObject:name]; + if (fresh) { + [inFlight addObject:name]; + } + [lock unlock]; + return fresh; +} + +/// Inbox entries this process has consumed but could not delete. +/// +/// The in-flight set is memory only, which is right for "a delivery is in progress" and wrong for +/// "this file has already been handed over". A delete can be refused -- data protection while the +/// device is locked -- and after a restart the set is empty, so the drain saw a consumed one-shot +/// transfer as new and delivered it again. Recorded durably instead, and dropped the moment the +/// file finally goes. +static NSString *const kCN1ConsumedInboxKey = @"cn1.wearable.consumedInbox"; + +/// Serialises the read-modify-write of the consumed list. +/// +/// NSUserDefaults is thread-safe per ACCESS, which is not the same as per update: note and forget +/// each read the array, change it and write it back, and the activation drain runs those on a +/// different thread from the EDT confirmation. Whichever wrote last silently discarded the other's +/// change -- and losing a note means a file that is still on disk has no durable claim, so the next +/// launch delivers it again. +static NSLock *cn1WearableConsumedLock(void) { + static dispatch_once_t once; + static NSLock *lock = nil; + dispatch_once(&once, ^{ + lock = [[NSLock alloc] init]; + }); + return lock; +} + +/// Marks an inbox entry as consumed across process restarts. +static void cn1WearableNoteConsumed(NSString *name) { + if (name.length == 0) { + return; + } + NSLock *lock = cn1WearableConsumedLock(); + [lock lock]; + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + NSMutableArray *consumed = [[d arrayForKey:kCN1ConsumedInboxKey] mutableCopy]; + if (consumed == nil) { + consumed = [[NSMutableArray alloc] init]; + } + if (![consumed containsObject:name]) { + [consumed addObject:name]; + // Bounded like every other cache here. Past the bound the OLDEST goes, and the worst that + // costs is one redelivery of a file whose delete has been failing for 256 transfers. + while (consumed.count > 256) { + [consumed removeObjectAtIndex:0]; + } + [d setObject:consumed forKey:kCN1ConsumedInboxKey]; + } + [consumed release]; + [lock unlock]; +} + +/// Whether this entry was already consumed by some earlier run. +static BOOL cn1WearableWasConsumed(NSString *name) { + NSLock *lock = cn1WearableConsumedLock(); + [lock lock]; + NSArray *consumed = [[NSUserDefaults standardUserDefaults] arrayForKey:kCN1ConsumedInboxKey]; + BOOL was = consumed != nil && [consumed containsObject:name]; + [lock unlock]; + return was; +} + +/// Forgets the consumed record once the file it protects is gone. +static void cn1WearableForgetConsumed(NSString *name) { + NSLock *lock = cn1WearableConsumedLock(); + [lock lock]; + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + NSArray *consumed = [d arrayForKey:kCN1ConsumedInboxKey]; + if (consumed != nil && [consumed containsObject:name]) { + NSMutableArray *rest = [consumed mutableCopy]; + [rest removeObject:name]; + [d setObject:rest forKey:kCN1ConsumedInboxKey]; + [rest release]; + } + [lock unlock]; +} + +static void cn1WearableClearInFlight(NSString *name) { + if (name == nil) { + return; + } + NSLock *lock = cn1WearableInFlightLock(); + [lock lock]; + [cn1WearableInFlight() removeObject:name]; + [lock unlock]; +} + +/// Redelivers anything left in the inbox by a previous run, then clears it. +/// +/// Called on session activation: reaching that point means this process is alive and the CN1 +/// runtime is up, so anything still parked was written by a run that did not get that far. +static void cn1WearableDrainInbox(void) { + NSString *dir = cn1WearableInboxDir(); + NSArray *names = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dir + error:NULL]; + for (NSString *name in names) { + NSString *full = [dir stringByAppendingPathComponent:name]; + if ([name hasSuffix:@".done"]) { + // Legacy marker: entries are now retired by cn1_wearable_confirmInbox once the EDT has + // consumed them, so nothing writes these any more. An app updated across that change + // can still find one parked here, and it was already delivered. + [[NSFileManager defaultManager] removeItemAtPath:full error:NULL]; + continue; + } + if (cn1WearableWasConsumed(name)) { + // Consumed by an EARLIER RUN whose delete was refused. The in-flight set does not + // survive a restart, so without this durable record the file looked new again and the + // one-shot transfer was delivered a second time. Retry the delete instead; the record + // goes with it. + if ([[NSFileManager defaultManager] removeItemAtPath:full error:NULL] + || ![[NSFileManager defaultManager] fileExistsAtPath:full]) { + cn1WearableForgetConsumed(name); + cn1WearableClearInFlight(name); + } + continue; + } + if (!cn1WearableMarkInFlight(name)) { + // Already handed to the runtime by this process and still awaiting confirmation. + // Replaying it now would deliver one one-shot transfer twice. + continue; + } + NSData *blob = [NSData dataWithContentsOfFile:full]; + if (blob != nil) { + NSSet *classes = [NSSet setWithObjects:[NSDictionary class], [NSString class], + [NSData class], nil]; + NSDictionary *entry = [NSKeyedUnarchiver unarchivedObjectOfClasses:classes + fromData:blob + error:NULL]; + NSString *path = entry[@"p"]; + NSData *body = entry[@"b"]; + if ([path isKindOfClass:[NSString class]] && [body isKindOfClass:[NSData class]]) { + // Tracked: the entry is retired from the EDT, after the app has the payload. + // Marking it here instead would retire it the moment the replay was QUEUED, and a + // process death before the EDT ran it would lose the file for good -- the exact + // failure this inbox exists to prevent. + cn1_wearable_deliverDataChangedTracked(path.UTF8String, body.bytes, + (int) body.length, name.UTF8String); + continue; + } + } + // Unreadable or malformed: nothing to deliver and replaying it forever helps nobody. + cn1WearableClearInFlight(name); + [[NSFileManager defaultManager] removeItemAtPath:full error:NULL]; + } +} + +/// Gives up a delivery without retiring it. +/// +/// The pending-delivery queue evicted the payload before any listener saw it. The file must STAY +/// on disk -- it is still the only copy -- but the in-flight mark has to go, or every later drain +/// in this process skips the entry it is protecting and the transfer is never offered again. +void cn1_wearable_releaseInbox(const char *inboxToken) { + if (inboxToken == NULL) { + return; + } + NSString *name = [NSString stringWithUTF8String:inboxToken]; + if (name.length == 0) { + return; + } + cn1WearableClearInFlight(name); +} + +/// Re-offers everything still parked in the inbox. +/// +/// Called from Java once a data listener exists, after a delivery was evicted from the pending +/// queue. Clearing the in-flight mark only makes the entry ELIGIBLE again; without this nothing +/// would look at it until the next session activation or a restart. +void cn1_wearable_replayInbox(void) { + cn1WearableDrainInbox(); +} + +/// Forgets that a path's current value was received, so the next whole-context update delivers it +/// again. +/// +/// For a delivery the pending-delivery cap had to discard: the entry is already recorded in +/// _lastReceived, so every later context replace treats it as unchanged and the app never sees it. +/// Dropping the record makes the very next update look new. +void cn1_wearable_forgetReceived(const char *path) { + CN1WatchConnectivity *shared = [CN1WatchConnectivity shared]; + if (shared == nil) { + return; + } + NSString *p = path == NULL ? nil : [NSString stringWithUTF8String:path]; + @synchronized (shared) { + if (p == nil || p.length == 0) { + // The rescan request: more was discarded than could be named, so every received marker + // goes and the replay below re-offers the whole held context. + [shared forgetAllReceived]; + } else { + [shared forgetReceivedPath:p]; + } + } + // Forgetting alone recovers nothing: it makes the path eligible again, and then waits for a + // context update that the peer may never send -- it has already published this value. The + // context currently held IS the value, so re-run the delivery over it. + // + // Coalesced onto one pass: core hands paths back one at a time, and re-processing the whole + // context per path would deliver every other path in it that many times over. + [shared scheduleReceivedContextReplay]; +} + +/// Retires a delivered inbox entry. +/// +/// Invoked from Java on the EDT after the payload has been handed to the application, which is the +/// only moment at which losing the durable copy is safe. +void cn1_wearable_confirmInbox(const char *inboxToken) { + if (inboxToken == NULL) { + return; + } + NSString *name = [NSString stringWithUTF8String:inboxToken]; + if (name.length == 0) { + return; + } + NSString *full = [cn1WearableInboxDir() stringByAppendingPathComponent:name]; + NSFileManager *files = [NSFileManager defaultManager]; + [files removeItemAtPath:full error:NULL]; + // The claim is released only once the file is actually GONE. A delete can be refused -- data + // protection while the device is locked, a transient filesystem error -- and clearing the + // in-flight marker anyway left a consumed one-shot transfer looking new to the next inbox + // replay, so it was delivered again on every activation for as long as the file survived. + // + // Keeping the claim is the safe direction: the marker is what says "already handed over", and + // the next confirm for this token retries the delete. + if (![files fileExistsAtPath:full]) { + cn1WearableClearInFlight(name); + cn1WearableForgetConsumed(name); + } else { + // The file survived the delete. The in-memory claim keeps this process from re-offering it, + // but that set dies with the process -- so record it durably as well, or the next launch + // treats a file the app has already received as a new transfer. + cn1WearableNoteConsumed(name); + } +} + +/// Serializes the whole applicationContext read-modify-write. +/// +/// WCSession has no merge: updateApplicationContext REPLACES the dictionary. putData and removeData +/// therefore copy it, change one path, and write the whole thing back, and two of those running +/// concurrently both copy the SAME starting dictionary -- the second write then discards the first +/// caller's path entirely. Nothing about it is atomic, and the loss is silent: the publish +/// "succeeds" and the value simply is not there. +/// +/// Both methods take this lock across copy, mutate AND update, because holding it for only part of +/// the cycle leaves exactly the same window. +static NSLock *cn1WearableContextLock(void) { + static dispatch_once_t once; + static NSLock *lock = nil; + dispatch_once(&once, ^{ + lock = [[NSLock alloc] init]; + }); + return lock; +} + +static int64_t cn1WearableNextSequence(void) { + NSLock *lock = cn1WearableClockLock(); + [lock lock]; + int64_t now = (int64_t) ([[NSDate date] timeIntervalSince1970] * 1000.0); + int64_t floorValue = cn1WearableClockFloor(); + if (floorValue > cn1WearableLast) { + cn1WearableLast = floorValue; + } + cn1WearableLast = now > cn1WearableLast ? now : cn1WearableLast + 1; + int64_t result = cn1WearableLast; + // This write must move the high-water mark too. Leaving it behind would let a later, LOWER + // observation still look like a rise against a stale mark and overwrite this value -- the same + // regression cn1WearableObserveSequence guards against, reintroduced through the other writer. + // The sequence is monotonic, so result is always the highest value yet persisted. + cn1WearablePersistedFloor = result; + [[NSUserDefaults standardUserDefaults] setDouble:(double) result forKey:kCn1WearableClockKey]; + [lock unlock]; + return result; +} + +static NSString *cn1WearableValueKey(NSString *path) { + return [kValuePrefix stringByAppendingString:path]; +} + +static NSString *cn1WearableStampKey(NSString *path) { + return [kStampPrefix stringByAppendingString:path]; +} + +static NSString *cn1WearableTombKey(NSString *path) { + return [kTombPrefix stringByAppendingString:path]; +} + +/// One side's knowledge of a path: the bytes (nil when removed or absent), the sequence it happened +/// at, and whether the newest thing that side knows is a removal. +typedef struct { + NSData *data; + int64_t stamp; + BOOL known; + BOOL removed; +} CN1WearableEntry; + +static CN1WearableEntry cn1WearableEntryFor(NSDictionary *ctx, NSString *path) { + CN1WearableEntry e; + e.data = nil; + e.stamp = 0; + e.known = NO; + e.removed = NO; + if (ctx == nil) { + return e; + } + id value = ctx[cn1WearableValueKey(path)]; + id stamp = ctx[cn1WearableStampKey(path)]; + id tomb = ctx[cn1WearableTombKey(path)]; + if ([value isKindOfClass:[NSData class]]) { + e.data = value; + e.known = YES; + e.stamp = [stamp isKindOfClass:[NSNumber class]] ? [stamp longLongValue] : 0; + // Every stamp we read raises our clock, wherever it came from. Done here rather than in the + // receive callback because this is the ONE place entries are parsed -- getData, dataPaths + // and didReceiveApplicationContext all funnel through it, so no read path can forget to. + // Reading our own entry is a no-op: it can never exceed our own counter. + cn1WearableObserveSequence(e.stamp); + } + if ([tomb isKindOfClass:[NSNumber class]]) { + int64_t t = [tomb longLongValue]; + cn1WearableObserveSequence(t); + // A removal published after the value wins over it -- that is the whole point of keeping the + // tombstone rather than deleting the entry outright. + if (!e.known || t > e.stamp) { + e.data = nil; + e.stamp = t; + e.known = YES; + e.removed = YES; + } + } + return e; +} + +/// Which side's knowledge of a path is authoritative. +/// +/// Ties are broken by ROLE, not by ownership. "Ours wins" is the one answer that cannot work here: +/// both devices run this same function, so on an equal stamp each would keep its own value, each +/// would suppress the other's callback, and the pair would sit permanently disagreeing about the +/// path with no event left to resolve it. Equal stamps are not exotic either -- two sides +/// publishing inside the same millisecond, or entries that predate stamping, both land here. +/// +/// The phone wins. The rule is arbitrary but it is *stable and shared*: each side knows which half +/// it is at compile time, so both compute the same winner without exchanging anything. That is the +/// same property the Android side gets from comparing node ids in outranks(). +static BOOL cn1WearableLocalWins(CN1WearableEntry mine, CN1WearableEntry theirs) { + if (!theirs.known) { + return YES; + } + if (!mine.known) { + return NO; + } + if (mine.stamp != theirs.stamp) { + return mine.stamp > theirs.stamp; + } +#if TARGET_OS_WATCH + return NO; +#else + return YES; +#endif +} + +/// How long a tombstone is kept before it is dropped, and the ceiling on how many are kept at all. +/// +/// A tombstone has to outlive the window in which the peer might still be holding the value it +/// supersedes -- otherwise dropping it lets that older value win again and the removal undoes +/// itself. But WatchConnectivity replaces the whole context on every publish and rejects one that +/// grows too large, so an app that creates and removes changing paths would eventually be unable to +/// publish at all. A day is far longer than any plausible replication delay and keeps the context +/// bounded; the count cap is the backstop for an app that churns paths faster than that. +static const int64_t kCN1TombstoneTTLMillis = 24 * 60 * 60 * 1000LL; +static const NSUInteger kCN1MaxTombstones = 256; + +/// When each tombstone was actually created, in local wall-clock milliseconds. +/// +/// Kept OUTSIDE the application context deliberately. The context value is the ordering stamp, and +/// that is a Lamport sequence: cn1WearableObserveSequence drags it ahead of local time whenever a +/// peer's clock is ahead, so comparing it against `now` kept a tombstone for the clock offset PLUS +/// the advertised day. Changing the context value's shape would also change the wire format the +/// peer parses, so the age lives here instead, in this device's own defaults. +static NSString *const kCN1TombBirthKey = @"cn1.wearable.tombstoneBirth"; + +/// How long to wait before looking at a retained tombstone again. Hourly: the thing being waited +/// for is a peer coming back, which is not a fast event, and each pass is a dictionary walk. +static const int64_t kCN1TombstonePruneRetryMillis = 60 * 60 * 1000; + +static int64_t cn1WearableNowMillis(void) { + return (int64_t) ([[NSDate date] timeIntervalSince1970] * 1000.0); +} + +static void cn1WearableNoteTombstoneBirth(NSString *path) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + // Both branches must be OWNED: ARC is off here, and releasing an autoreleased fallback below + // would over-release it and crash when the pool drains. + NSMutableDictionary *births = [[d dictionaryForKey:kCN1TombBirthKey] mutableCopy]; + if (births == nil) { + births = [[NSMutableDictionary alloc] init]; + } + births[path] = @(cn1WearableNowMillis()); + [d setObject:births forKey:kCN1TombBirthKey]; + [births release]; +} + +static void cn1WearableForgetTombstoneBirth(NSString *path) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + NSDictionary *stored = [d dictionaryForKey:kCN1TombBirthKey]; + if (stored[path] == nil) { + return; + } + NSMutableDictionary *births = [stored mutableCopy]; + [births removeObjectForKey:path]; + [d setObject:births forKey:kCN1TombBirthKey]; + [births release]; +} + +/// The tombstone's age in millis. An entry with no recorded birth -- written by an earlier build, +/// or restored -- starts its clock now, which delays pruning by at most one TTL and never shortens +/// it, so a removal cannot undo itself because of missing bookkeeping. +static int64_t cn1WearableTombstoneAge(NSString *path) { + NSDictionary *births = [[NSUserDefaults standardUserDefaults] dictionaryForKey:kCN1TombBirthKey]; + id born = births[path]; + if (![born isKindOfClass:[NSNumber class]]) { + cn1WearableNoteTombstoneBirth(path); + return 0; + } + return cn1WearableNowMillis() - [born longLongValue]; +} + +/// Drops tombstones that have outlived their purpose, oldest first. +/// +/// `peerCtx` is what the peer last told us it holds. A tombstone may only go once the peer has +/// stopped holding an older value for that path -- otherwise dropping it lets that value win the +/// next comparison and the removal silently undoes itself. Age alone is not evidence of that: a peer +/// that has been offline for a week still has its old value when it comes back. +/// `retired` collects the paths whose tombstones were dropped. Their birth records are NOT +/// forgotten here: the context this prunes has not been published yet, and an update that then +/// fails leaves the tombstone authoritative with no birth record -- so the next pass reads it as +/// newly born and grants it another full retention window. The caller forgets them once the +/// publication has actually succeeded. +static void cn1WearablePruneTombstones(NSMutableDictionary *ctx, NSDictionary *peerCtx, + NSMutableArray *retired) { + NSMutableArray *tombKeys = [NSMutableArray array]; + for (NSString *key in ctx.allKeys) { + if ([key isKindOfClass:[NSString class]] && [key hasPrefix:kTombPrefix]) { + [tombKeys addObject:key]; + } + } + for (NSString *key in tombKeys) { + id stamp = ctx[key]; + if (![stamp isKindOfClass:[NSNumber class]]) { + // Not ours, or corrupt: nothing to preserve. + [ctx removeObjectForKey:key]; + continue; + } + NSString *path = [key substringFromIndex:kTombPrefix.length]; + // Age from the recorded birth, never from the ordering stamp -- see kCN1TombBirthKey. + if (cn1WearableTombstoneAge(path) <= kCN1TombstoneTTLMillis) { + continue; + } + // Old enough to consider -- but only actually drop it once the peer has acknowledged the + // removal, meaning it no longer holds a value for that path older than the tombstone. + CN1WearableEntry theirs = cn1WearableEntryFor(peerCtx, path); + if (theirs.known && !theirs.removed && theirs.stamp < [stamp longLongValue]) { + continue; + } + [ctx removeObjectForKey:key]; + [retired addObject:path]; + } + if (ctx.count <= kCN1MaxTombstones) { + return; + } + NSMutableArray *remaining = [NSMutableArray array]; + for (NSString *key in ctx.allKeys) { + if ([key isKindOfClass:[NSString class]] && [key hasPrefix:kTombPrefix]) { + [remaining addObject:key]; + } + } + if (remaining.count <= kCN1MaxTombstones) { + return; + } + [remaining sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + int64_t sa = [ctx[a] isKindOfClass:[NSNumber class]] ? [ctx[a] longLongValue] : 0; + int64_t sb = [ctx[b] isKindOfClass:[NSNumber class]] ? [ctx[b] longLongValue] : 0; + return sa < sb ? NSOrderedAscending : (sa > sb ? NSOrderedDescending : NSOrderedSame); + }]; + // Walk the WHOLE list oldest-first and stop once the count is under the cap, rather than + // examining only the oldest (count - cap) entries. With the old bound, a protected entry among + // those oldest ones consumed one of the slots examined and nothing took its place: 300 + // tombstones whose oldest 44 were still protecting peer values meant every pass skipped and all + // 300 stayed, forever, even though 256 newer ones could safely have been kept. The choice was + // deterministic, so repeating the prune changed nothing. + NSUInteger keep = remaining.count; + for (NSUInteger i = 0; i < remaining.count && keep > kCN1MaxTombstones; i++) { + NSString *key = remaining[i]; + NSString *path = [key substringFromIndex:kTombPrefix.length]; + CN1WearableEntry theirs = cn1WearableEntryFor(peerCtx, path); + if (theirs.known && !theirs.removed && theirs.stamp < [ctx[key] longLongValue]) { + // Still unacknowledged. The cap is a backstop against unbounded growth, not a licence to + // resurrect data; an app churning this many unacknowledged removals while its peer stays + // offline keeps them until the peer catches up. + continue; + } + [ctx removeObjectForKey:key]; + // The birth record goes with it, once the caller has published. Pruning finds those + // records only through tombstones still in the context, so an entry dropped here becomes + // unreachable -- churn through unique paths would grow the defaults dictionary without + // bound. Same reason as the TTL and republish paths. + [retired addObject:[key substringFromIndex:kTombPrefix.length]]; + keep--; + } +} + +/// Every application path either side knows about, removals included. +static NSSet *cn1WearableAllPaths(NSDictionary *local, NSDictionary *peer) { + NSMutableSet *out = [NSMutableSet set]; + NSArray *contexts = @[(local == nil ? @{} : local), (peer == nil ? @{} : peer)]; + for (NSDictionary *ctx in contexts) { + for (NSString *key in ctx) { + if (![key isKindOfClass:[NSString class]]) { + continue; + } + if ([key hasPrefix:kValuePrefix] || [key hasPrefix:kTombPrefix]) { + [out addObject:[key substringFromIndex:kValuePrefix.length]]; + } + } + } + return out; +} + + +/// Builds the WearableMessage wire form for a received file: a two-entry payload carrying "name" +/// (string) and "contents" (bytes). Mirrors com.codename1.wearable.WearableMessage#toByteArray, so +/// the shapes have to stay in step -- see FORMAT_VERSION there. +static NSData *cn1WearableWrapFile(NSString *name, NSData *contents) { + const uint8_t kFormatVersion = 1; + const uint8_t kTypeString = 1; + const uint8_t kTypeBytes = 6; + NSMutableData *out = [NSMutableData data]; + [out appendBytes:&kFormatVersion length:1]; + uint16_t count = CFSwapInt16HostToBig(2); + [out appendBytes:&count length:2]; + + NSData *nameKey = [@"name" dataUsingEncoding:NSUTF8StringEncoding]; + NSData *nameVal = [(name == nil ? @"file" : name) dataUsingEncoding:NSUTF8StringEncoding]; + NSData *bodyKey = [@"contents" dataUsingEncoding:NSUTF8StringEncoding]; + + uint32_t len = CFSwapInt32HostToBig((uint32_t) nameKey.length); + [out appendBytes:&len length:4]; + [out appendData:nameKey]; + [out appendBytes:&kTypeString length:1]; + len = CFSwapInt32HostToBig((uint32_t) nameVal.length); + [out appendBytes:&len length:4]; + [out appendData:nameVal]; + + len = CFSwapInt32HostToBig((uint32_t) bodyKey.length); + [out appendBytes:&len length:4]; + [out appendData:bodyKey]; + [out appendBytes:&kTypeBytes length:1]; + len = CFSwapInt32HostToBig((uint32_t) contents.length); + [out appendBytes:&len length:4]; + [out appendData:contents]; + return out; +} + +@implementation CN1WatchConnectivity { + // Reply blocks for messages the peer sent us that expect an answer. The Java side answers + // asynchronously on the EDT, so the block has to outlive the delegate callback. + NSMutableDictionary *)> *_pendingReplies; + /// When each pending reply arrived, so one that is never answered can be retired. Parallel to + /// _pendingReplies and guarded by the same monitor. + NSMutableDictionary *_pendingReplyAt; + /// Guards the recurring tombstone sweep to a single pending chain; see pruneTombstonesNow. + BOOL _tombstoneSweepScheduled; + /// Guards the post-removal deadline sweep to one block, however many paths are removed. + BOOL _tombstoneDeadlineScheduled; + /// Guards the received-context replay to one pass, however many paths are forgotten. + BOOL _receivedReplayScheduled; + int _nextInboundToken; + /// Keys the peer's last context carried, so a key that vanishes is reported as a removal. + /// What the peer's context last said for each path: the authoritative stamp we delivered, or + /// the tombstone stamp for a removal. Stamps rather than bare keys, because WCSession hands + /// over the WHOLE context on any change -- a set of keys cannot tell an unchanged entry from a + /// re-sent one. + NSMutableDictionary *_lastReceived; +} + ++ (CN1WatchConnectivity *)shared { + static CN1WatchConnectivity *instance = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + instance = [[CN1WatchConnectivity alloc] init]; + [instance activate]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self != nil) { + _pendingReplies = [[NSMutableDictionary alloc] init]; + _pendingReplyAt = [[NSMutableDictionary alloc] init]; + _nextInboundToken = 1; + _lastReceived = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (void)activate { + if ([WCSession isSupported]) { + WCSession *s = [WCSession defaultSession]; + s.delegate = self; + [s activate]; + } +} + +- (WCSession *)session { + return [WCSession isSupported] ? [WCSession defaultSession] : nil; +} + +// --- state --------------------------------------------------------------- + +- (BOOL)isSupported { + return [WCSession isSupported]; +} + +- (BOOL)isPaired { +#if TARGET_OS_WATCH + // The watch always has a phone; there is no isPaired on this side. + return [WCSession isSupported]; +#else + WCSession *s = [self session]; + return s != nil && s.isPaired; +#endif +} + +- (BOOL)isReachable { + WCSession *s = [self session]; + return s != nil && s.reachable; +} + +- (BOOL)isCompanionInstalled { + WCSession *s = [self session]; + if (s == nil) { + return NO; + } +#if TARGET_OS_WATCH + return s.isCompanionAppInstalled; +#else + return s.isWatchAppInstalled; +#endif +} + +// --- messages ------------------------------------------------------------ + +- (void)sendMessage:(NSString *)path payload:(NSData *)payload replyToken:(int)replyToken { + WCSession *s = [self session]; + if (s == nil || !s.reachable) { + if (replyToken != 0) { + cn1_wearable_deliverReply(replyToken, NULL, 0, "The peer app is not reachable"); + } + return; + } + NSDictionary *msg = @{kPathKey: (path == nil ? @"" : path), + kBodyKey: (payload == nil ? [NSData data] : payload)}; + if (replyToken == 0) { + [s sendMessage:msg replyHandler:nil errorHandler:^(NSError *error) { + // Nothing to report: the sender asked for no answer, so a failure here is the same + // "dropped because unreachable" the API documents. + }]; + return; + } + [s sendMessage:msg replyHandler:^(NSDictionary *reply) { + NSData *body = reply[kReplyKey]; + cn1_wearable_deliverReply(replyToken, body.bytes, (int) body.length, NULL); + } errorHandler:^(NSError *error) { + cn1_wearable_deliverReply(replyToken, NULL, 0, + error.localizedDescription.UTF8String); + }]; +} + +- (void)sendReply:(int)replyToken payload:(NSData *)payload { + void (^handler)(NSDictionary *); + @synchronized (_pendingReplies) { + NSNumber *key = @(replyToken); + // ARC is off in this port, so the dictionary's reference is the only one keeping the block + // alive: retain before removing, or the block is deallocated before it is called. + handler = [_pendingReplies[key] retain]; + [_pendingReplies removeObjectForKey:key]; + [_pendingReplyAt removeObjectForKey:key]; + } + if (handler != nil) { + handler(@{kReplyKey: (payload == nil ? [NSData data] : payload)}); + [handler release]; + } +} + +// --- replicated data ----------------------------------------------------- + +// Replicated data is the session's application context: one dictionary that survives both apps +// being killed and is handed to the peer whenever it next runs. Each CN1 path is one entry, so +// publishing a path replaces only that path. + +- (void)putData:(NSString *)path payload:(NSData *)payload { + WCSession *s = [self session]; + if (s == nil || path == nil) { + return; + } + NSLock *ctxLock = cn1WearableContextLock(); + [ctxLock lock]; + NSMutableDictionary *ctx = [[s applicationContext] mutableCopy]; + if (ctx == nil) { + ctx = [[NSMutableDictionary alloc] init]; + } + // Stamp the publication so a reader can tell our value from a newer one the peer sent, and drop + // any tombstone: republishing a removed path brings it back. + ctx[cn1WearableValueKey(path)] = (payload == nil ? [NSData data] : payload); + ctx[cn1WearableStampKey(path)] = @(cn1WearableNextSequence()); + NSMutableArray *retiredBirths = [NSMutableArray array]; + if (ctx[cn1WearableTombKey(path)] != nil) { + [ctx removeObjectForKey:cn1WearableTombKey(path)]; + // Its birth record goes too, but only once this context is actually published -- see + // cn1WearablePruneTombstones. Pruning only visits tombstones still in the context, so a + // republished path would otherwise leave an entry nothing will ever revisit. + [retiredBirths addObject:path]; + } + // Pruned here as well, not only in removeData. Tombstones raised while the peer was offline + // become prunable the moment it reconnects and acknowledges them -- but an app that then only + // ever calls putData() never reached the sweep, because removeData() held its only call site. + // The context kept growing until a perfectly ordinary value publication was rejected for size, + // with every tombstone in it eligible for removal. Publishing is exactly when that matters, + // since publishing is what the oversized context breaks. + cn1WearablePruneTombstones(ctx, [s receivedApplicationContext], retiredBirths); + NSError *err = nil; + [s updateApplicationContext:ctx error:&err]; + if (err == nil) { + // Only now: the pruned context is live, so the birth records describe tombstones that are + // genuinely gone. Forgetting them earlier would restart the retention clock of every + // tombstone this publish failed to remove. + for (NSString *retiredPath in retiredBirths) { + cn1WearableForgetTombstoneBirth(retiredPath); + } + } + // Released before the error branch below, not after it: an early return there would leave the + // lock held for the life of the process and every later putData/removeData would block on it. + [ctxLock unlock]; + if (err != nil) { + NSLog(@"[cn1.wearable] failed to publish %@: %@", path, err.localizedDescription); + } + [ctx release]; +} + +- (NSData *)getData:(NSString *)path { + WCSession *s = [self session]; + if (s == nil || path == nil) { + return nil; + } + // Our own published values live in applicationContext; values the peer published arrive in + // receivedApplicationContext. Both halves may publish the same path, so "whichever exists" is + // not enough: preferring ours unconditionally would keep answering with a stale local value + // after a newer one arrived from the peer, contradicting the single-latest-value contract. Each + // publish stamps its path, so the two stamps decide -- and a removal carries a stamp too, so it + // can outrank the other side's older value instead of that value resurfacing. + CN1WearableEntry mine = cn1WearableEntryFor([s applicationContext], path); + CN1WearableEntry theirs = cn1WearableEntryFor([s receivedApplicationContext], path); + CN1WearableEntry winner = cn1WearableLocalWins(mine, theirs) ? mine : theirs; + return winner.removed ? nil : winner.data; +} + +- (void)removeData:(NSString *)path { + WCSession *s = [self session]; + if (s == nil || path == nil) { + return; + } + NSLock *ctxLock = cn1WearableContextLock(); + [ctxLock lock]; + NSMutableDictionary *ctx = [[s applicationContext] mutableCopy]; + if (ctx == nil) { + ctx = [[NSMutableDictionary alloc] init]; + } + // The value goes, but a stamped tombstone stays. Deleting the entry outright would let the + // peer's older value for the same path win the next comparison, so a removal on the newer + // publisher would resurrect data instead of clearing it. + [ctx removeObjectForKey:cn1WearableValueKey(path)]; + [ctx removeObjectForKey:cn1WearableStampKey(path)]; + // A tombstone may ALREADY exist for this path -- removeData called twice. Its birth record is + // then the one that matters, and overwriting it would restart a retention window that has been + // running; the failure branch below would go further and delete it, leaving the still-published + // original to be read as newly born on the next pass. + BOOL freshTombstone = ctx[cn1WearableTombKey(path)] == nil; + ctx[cn1WearableTombKey(path)] = @(cn1WearableNextSequence()); + if (freshTombstone) { + cn1WearableNoteTombstoneBirth(path); + } + NSMutableArray *retiredBirths = [NSMutableArray array]; + cn1WearablePruneTombstones(ctx, [s receivedApplicationContext], retiredBirths); + // Also swept when THIS tombstone comes of age. Pruning ran only from putData and removeData, so + // an app whose last act is a removal kept that final batch in its persisted context for good -- + // the tombstone is necessarily younger than its TTL at the moment it is written, and nothing + // else would ever look again. + // ONE deadline block, however many paths are removed. Every removeData used to queue its own + // 24-hour block, each retaining the delegate, so churn through many paths accumulated + // thousands of them and as many redundant whole-context passes. The first tombstone's deadline + // is the earliest one that can matter; the sweep it triggers re-arms itself hourly while + // anything is still held, which covers every tombstone raised after it. + if (!_tombstoneDeadlineScheduled) { + _tombstoneDeadlineScheduled = YES; + CN1WatchConnectivity *keepAlive = [self retain]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t) (kCN1TombstoneTTLMillis + 1000) * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + keepAlive->_tombstoneDeadlineScheduled = NO; + [keepAlive pruneTombstonesNow]; + [keepAlive release]; + }); + } + NSError *err = nil; + [s updateApplicationContext:ctx error:&err]; + if (err != nil) { + // Only a record this call CREATED. A tombstone that was already published keeps its own + // birth: it is still out there, and deleting its record would have the next pass treat it + // as newly born and grant it another full window -- the very thing this branch exists to + // avoid in the other direction. + // + // For a fresh one the reasoning is the opposite: it never entered the session context, so + // pruning -- which walks tombstones in the context -- can never reach its record again, and + // repeated failed removals of unique paths would grow the defaults dictionary without + // bound. + // + // The records the prune retired are deliberately KEPT either way: their tombstones are + // still in the published context. + if (freshTombstone) { + cn1WearableForgetTombstoneBirth(path); + } + } else { + for (NSString *retiredPath in retiredBirths) { + cn1WearableForgetTombstoneBirth(retiredPath); + } + } + [ctxLock unlock]; + [ctx release]; +} + +/// Drops a path's received marker; see cn1_wearable_forgetReceived. +- (void)forgetReceivedPath:(NSString *)path { + [_lastReceived removeObjectForKey:path]; +} + +/// Drops every received marker, for the overflow rescan. +- (void)forgetAllReceived { + [_lastReceived removeAllObjects]; +} + +/// Re-runs the received-context delivery once, after any number of paths have been forgotten. +- (void)scheduleReceivedContextReplay { + @synchronized (self) { + if (_receivedReplayScheduled) { + return; + } + _receivedReplayScheduled = YES; + } + CN1WatchConnectivity *keepAlive = [self retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + @synchronized (keepAlive) { + keepAlive->_receivedReplayScheduled = NO; + } + WCSession *s = [keepAlive session]; + NSDictionary *ctx = s == nil ? nil : [s receivedApplicationContext]; + if (ctx != nil && ctx.count > 0) { + // The ordinary delivery path, so every rule about winners, tombstones and + // acknowledgement applies exactly as it does for a context that just arrived. + [keepAlive session:s didReceiveApplicationContext:ctx]; + } + [keepAlive release]; + }); +} + +/// Prunes without publishing anything else, for the scheduled sweep, on activation, and whenever +/// the peer's context arrives. +/// +/// Re-arms itself while anything is still held back: a tombstone past its TTL is kept until the +/// peer acknowledges the removal, and a peer that is offline at the deadline acknowledges later -- +/// with no sweep left to notice, a quiet app kept that tombstone for good. +- (void)pruneTombstonesNow { + WCSession *s = [self session]; + if (s == nil) { + return; + } + NSLock *ctxLock = cn1WearableContextLock(); + [ctxLock lock]; + NSMutableDictionary *ctx = [[s applicationContext] mutableCopy]; + if (ctx == nil) { + [ctxLock unlock]; + return; + } + NSUInteger before = ctx.count; + NSMutableArray *retiredBirths = [NSMutableArray array]; + cn1WearablePruneTombstones(ctx, [s receivedApplicationContext], retiredBirths); + BOOL stillHeld = NO; + for (NSString *key in ctx.allKeys) { + if ([key isKindOfClass:[NSString class]] && [key hasPrefix:kTombPrefix]) { + stillHeld = YES; + break; + } + } + if (stillHeld && !_tombstoneSweepScheduled) { + // ONE chain, ever. This runs on activation and on every received application context, and + // each call used to start its own recurring hourly sweep -- a peer syncing frequently would + // accumulate thousands of delayed blocks, each retaining the delegate and rescheduling + // itself. The flag is cleared when the sweep fires, so exactly one is ever pending. + _tombstoneSweepScheduled = YES; + CN1WatchConnectivity *again = [self retain]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t) kCN1TombstonePruneRetryMillis * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + again->_tombstoneSweepScheduled = NO; + [again pruneTombstonesNow]; + [again release]; + }); + } + if (ctx.count != before) { + // Only when something actually went: updateApplicationContext with an unchanged dictionary + // is a wasted transfer, and on the peer it looks like a fresh context to re-examine. + NSError *err = nil; + [s updateApplicationContext:ctx error:&err]; + if (err == nil) { + for (NSString *retiredPath in retiredBirths) { + cn1WearableForgetTombstoneBirth(retiredPath); + } + } + } + [ctxLock unlock]; + [ctx release]; +} + +- (NSArray *)dataPaths { + WCSession *s = [self session]; + if (s == nil) { + return @[]; + } + NSDictionary *localCtx = [s applicationContext]; + NSDictionary *peerCtx = [s receivedApplicationContext]; + NSMutableArray *out = [NSMutableArray array]; + for (NSString *path in cn1WearableAllPaths(localCtx, peerCtx)) { + CN1WearableEntry mine = cn1WearableEntryFor(localCtx, path); + CN1WearableEntry theirs = cn1WearableEntryFor(peerCtx, path); + CN1WearableEntry winner = cn1WearableLocalWins(mine, theirs) ? mine : theirs; + // A tombstone is a path that was removed, not a path that has a value. + if (!winner.removed) { + [out addObject:path]; + } + } + return out; +} + +- (void)transferFile:(NSString *)path name:(NSString *)name contents:(NSData *)contents { + WCSession *s = [self session]; + if (s == nil || contents == nil) { + return; + } + // A per-transfer directory, because the system reads the staged file asynchronously and on its + // own schedule. Staging by name alone means two transfers of the same name -- or of the unnamed + // default -- overwrite each other's bytes while WatchConnectivity is still reading the first, + // corrupting one transfer or both. The directory carries the uniqueness so the file keeps the + // caller's name, which is what the receiver reads back out of lastPathComponent. + NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"cn1-wearable-%@", [[NSUUID UUID] UUIDString]]]; + NSError *dirErr = nil; + if (![[NSFileManager defaultManager] createDirectoryAtPath:dir + withIntermediateDirectories:YES + attributes:nil + error:&dirErr]) { + NSLog(@"[cn1.wearable] could not stage a transfer directory: %@", + dirErr.localizedDescription); + return; + } + // Only ever a bare file name inside our directory. A caller-supplied name is untrusted input -- + // "../../Documents/state" would otherwise let stringByAppendingPathComponent: escape the staging + // directory and overwrite an arbitrary file in the app's sandbox, and the completion handler + // would then delete whatever directory it landed in. + NSString *safeName = [name lastPathComponent]; + if (safeName.length == 0 || [safeName isEqualToString:@"."] + || [safeName isEqualToString:@".."] || [safeName hasPrefix:@"/"]) { + safeName = @"cn1-wearable-transfer"; + } + NSString *file = [dir stringByAppendingPathComponent:safeName]; + if (![[file stringByDeletingLastPathComponent] isEqualToString:dir]) { + NSLog(@"[cn1.wearable] refusing a transfer name that escapes its staging directory: %@", name); + [[NSFileManager defaultManager] removeItemAtPath:dir error:nil]; + return; + } + if (![contents writeToFile:file atomically:YES]) { + NSLog(@"[cn1.wearable] could not stage %@ for transfer", file); + [[NSFileManager defaultManager] removeItemAtPath:dir error:nil]; + return; + } + [s transferFile:[NSURL fileURLWithPath:file] + metadata:@{kPathKey: (path == nil ? @"" : path)}]; +} + +/// Deletes a staging directory once WatchConnectivity is done with it. The system owns the file until +/// the transfer finishes, so this can only happen from the completion delegate -- and it has to +/// happen there, or every transfer leaves a full copy of its payload in the container until the OS +/// decides to purge the temporary directory. +- (void)cn1CleanupStagedTransfer:(WCSessionFileTransfer *)transfer { + NSURL *url = transfer.file.fileURL; + if (url == nil) { + return; + } + NSString *dir = [url.path stringByDeletingLastPathComponent]; + // Only ever our own staging directories, never a caller's file. + if ([[dir lastPathComponent] hasPrefix:@"cn1-wearable-"]) { + [[NSFileManager defaultManager] removeItemAtPath:dir error:nil]; + } +} + +// --- WCSessionDelegate --------------------------------------------------- + +- (void)session:(WCSession *)session + didFinishFileTransfer:(WCSessionFileTransfer *)fileTransfer + error:(NSError *)error { + if (error != nil) { + NSLog(@"[cn1.wearable] file transfer failed: %@", error.localizedDescription); + } + [self cn1CleanupStagedTransfer:fileTransfer]; +} + +- (void)session:(WCSession *)session + activationDidCompleteWithState:(WCSessionActivationState)activationState + error:(NSError *)error { + // Anything a previous run parked but may not have delivered. Reaching activation means this + // process is up and the CN1 runtime with it, so a file still sitting in the inbox belongs to a + // run that did not get this far. + cn1WearableDrainInbox(); + // And sweep tombstones, which covers the case a timer cannot: an app whose last act was a + // removal and which then exited takes its scheduled sweep with it, so the next launch is the + // only thing that will ever look at that batch again. + [self pruneTombstonesNow]; + cn1_wearable_notifyStateChanged(); +} + +#if !TARGET_OS_WATCH +- (void)sessionDidBecomeInactive:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} + +- (void)sessionDidDeactivate:(WCSession *)session { + // The user switched to a different watch. Re-activating is what keeps the link alive. + [session activate]; + cn1_wearable_notifyStateChanged(); +} + +- (void)sessionWatchStateDidChange:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} +#else +- (void)sessionCompanionAppInstalledDidChange:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} +#endif + +- (void)sessionReachabilityDidChange:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} + +- (void)session:(WCSession *)session didReceiveMessage:(NSDictionary *)message { + [self dispatchInbound:message reply:nil]; +} + +- (void)session:(WCSession *)session + didReceiveMessage:(NSDictionary *)message + replyHandler:(void (^)(NSDictionary *))replyHandler { + [self dispatchInbound:message reply:replyHandler]; +} + +- (void)dispatchInbound:(NSDictionary *)message + reply:(void (^)(NSDictionary *))replyHandler { + NSString *path = message[kPathKey]; + NSData *body = message[kBodyKey]; + int token = 0; + if (replyHandler != nil) { + // Park the block so the Java side can answer after it has hopped to the EDT. + @synchronized (_pendingReplies) { + // Retire anything the app never answered. sendReply is the only other way out of this + // dictionary, so a build that registers no message listener -- WearableConnection parks + // those deliveries indefinitely -- never removes a single entry, and every reply-bearing + // message the peer sends leaks a copied block. The senders have long since timed out by + // then, so answering late is pointless; the entry just has to go. + cn1WearableExpireReplies(_pendingReplies, _pendingReplyAt); + token = _nextInboundToken++; + // -copy returns +1 under manual reference counting and the dictionary retains it too, + // so hand off the copy's ownership rather than leaking it. + void (^stored)(NSDictionary *) = [replyHandler copy]; + _pendingReplies[@(token)] = stored; + _pendingReplyAt[@(token)] = @([[NSDate date] timeIntervalSince1970]); + // Also swept when THIS batch expires. Expiry ran only when the next request arrived, so + // a finite burst -- or a single request -- to an app that never registers a message + // listener held its copied reply blocks for the rest of the process, long after every + // sender had given up. The retention window is a promise about the request, not about + // how often requests happen to arrive. + CN1WatchConnectivity *keepAlive = [self retain]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t) ((kCN1ReplyExpirySeconds + 1) * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + // Same monitor the insert holds: _pendingReplies guards both maps. + @synchronized (keepAlive->_pendingReplies) { + cn1WearableExpireReplies(keepAlive->_pendingReplies, keepAlive->_pendingReplyAt); + } + [keepAlive release]; + }); + [stored release]; + } + } + cn1_wearable_deliverMessage(path.UTF8String, body.bytes, (int) body.length, token); +} + +- (void)session:(WCSession *)session + didReceiveApplicationContext:(NSDictionary *)applicationContext { + // SERIALIZED against itself. WCSession delivers this on its own delegate queue, and the + // recovery replay re-runs the very same handler from the main queue -- so two executions could + // iterate, mutate, release and replace _lastReceived at once. The narrow @synchronized blocks + // elsewhere cover the replay flag and a single forget; they do not cover this handler's state. + // Taking the instance monitor for the whole body is what makes those two callers exclusive. + @synchronized (self) { + [self handleReceivedContext:session context:applicationContext]; + } +} + +- (void)handleReceivedContext:(WCSession *)session + context:(NSDictionary *)applicationContext { + // The peer replaces its whole context on every publish. Two things follow. + // + // First, a path the peer removed either carries a tombstone or has simply stopped being there, + // and both have to reach the listener -- otherwise removeData on one side is invisible on the + // other. + // + // Second, and this is the subtle one: a context that arrives after a reconnect can be OLDER than + // what this side has already published. Delivering it unconditionally would walk a listener-driven + // UI back to stale state while an immediate getData() still returned the newer local value -- the + // listener and the getter disagreeing about the same path. So every path is compared against the + // local entry first, and only a peer entry that actually wins is delivered. + // Third: WCSession hands over the peer's ENTIRE context whenever any part of it changes, so + // every unchanged peer path arrives again on every publish. Announcing them all meant + // publishing /b produced a dataChanged for /a as well, and every past removal was re-announced + // indefinitely. Only an entry whose authoritative stamp actually moved is delivered. + // The peer's context is exactly the evidence a retained tombstone was waiting for, so look at + // them again now. Without this, a tombstone kept past its TTL because the peer was offline had + // only the periodic retry to release it. + [self pruneTombstonesNow]; + NSDictionary *localCtx = [session applicationContext]; + NSMutableDictionary *seen = [NSMutableDictionary dictionary]; + NSMutableArray *acknowledge = [NSMutableArray array]; + for (NSString *path in cn1WearableAllPaths(nil, applicationContext)) { + CN1WearableEntry theirs = cn1WearableEntryFor(applicationContext, path); + CN1WearableEntry mine = cn1WearableEntryFor(localCtx, path); + seen[path] = @(theirs.stamp); + if (cn1WearableLocalWins(mine, theirs)) { + continue; + } + NSNumber *previous = _lastReceived[path]; + if (previous != nil && previous.longLongValue == theirs.stamp) { + // Same entry we already delivered, re-sent as part of the whole-context replace. + continue; + } + // A tombstone we have ALREADY announced is also unchanged, and it does not compare equal: + // what was recorded for it is the sentinel, not the peer's stamp. Without this, every + // unrelated publication by the peer re-delivered dataRemoved for that path -- once per + // whole-context update until the tombstone was finally pruned -- and an app that treats a + // removal as an event acted on it each time. + // + // Only while it is STILL a tombstone: a republish under the same path arrives with + // theirs.removed false and falls through to be delivered normally. + if (previous != nil && previous.longLongValue == kCN1RemovalAnnounced && theirs.removed) { + seen[path] = @(kCN1RemovalAnnounced); + continue; + } + if (theirs.removed) { + seen[path] = @(kCN1RemovalAnnounced); + if (mine.known && !mine.removed) { + // Acknowledge it by dropping OUR live value for the path. The remover keeps a + // tombstone until the value it removed is gone from our context, so without this + // its tombstones accumulate past the cap and eventually the context can no longer + // be published at all. Clearing the value is the acknowledgement. + [acknowledge addObject:path]; + } + cn1_wearable_deliverDataRemoved(path.UTF8String); + } else if (theirs.data != nil) { + cn1_wearable_deliverDataChanged(path.UTF8String, theirs.data.bytes, + (int) theirs.data.length); + } + } + if (acknowledge.count > 0) { + NSLock *ctxLock = cn1WearableContextLock(); + [ctxLock lock]; + NSMutableDictionary *mineCtx = [[session applicationContext] mutableCopy]; + if (mineCtx != nil) { + BOOL acknowledged = NO; + for (NSString *path in acknowledge) { + // Re-decided against the context as it is NOW, under the lock. The decision to + // acknowledge was taken against a snapshot read before this lock was held, and the + // app can republish the path in between -- removing the value on the strength of + // the old snapshot would then delete a NEWER, higher-stamped publication and lose + // it silently. If our current entry still loses to the peer's tombstone the + // acknowledgement stands; if it now wins, the republish is the newer fact and the + // peer will see it and drop its tombstone in turn. + CN1WearableEntry current = cn1WearableEntryFor(mineCtx, path); + CN1WearableEntry tomb = cn1WearableEntryFor(applicationContext, path); + if (cn1WearableLocalWins(current, tomb)) { + continue; + } + [mineCtx removeObjectForKey:cn1WearableValueKey(path)]; + [mineCtx removeObjectForKey:cn1WearableStampKey(path)]; + acknowledged = YES; + } + if (acknowledged) { + NSError *ackErr = nil; + [session updateApplicationContext:mineCtx error:&ackErr]; + if (ackErr != nil) { + // The acknowledgement did NOT go out, so our stale value is still in the + // context and the peer must keep its tombstone. Recording the tombstone as + // announced anyway would send every later context update down the + // unchanged-sentinel branch, so this path would never be acknowledged again and + // the peer would hold that tombstone for good. Forget the marks instead: the + // next context update re-runs the acknowledgement. + for (NSString *path in acknowledge) { + [seen removeObjectForKey:path]; + } + } + } + [mineCtx release]; + } + [ctxLock unlock]; + } + for (NSString *gone in _lastReceived.allKeys) { + if (seen[gone] == nil) { + // Dropped out of the peer's context without a tombstone -- an older peer build, or a + // context rebuilt from scratch. Treat it as the removal it is. + // + // Except when we already announced that removal. A tombstone the peer PRUNED after its + // 24-hour TTL also disappears from the context, and the listener was told about it when + // the tombstone first arrived; re-announcing on the next routine context update turns + // ordinary housekeeping into a duplicate dataRemoved. _lastReceived remembers the + // tombstone's stamp, and a negative marker records that its removal has been reported. + CN1WearableEntry mine = cn1WearableEntryFor(localCtx, gone); + NSNumber *previous = _lastReceived[gone]; + BOOL alreadyAnnounced = previous != nil && previous.longLongValue == kCN1RemovalAnnounced; + // mine.removed means WE hold the tombstone -- this device called removeData. The peer's + // value then disappears from its context precisely because it acknowledged our removal, + // and reporting that back would fire dataRemoved on the device that asked for it, which + // WearableDataListener promises never to do. Only a path we never had, or never removed + // ourselves, is a peer-originated disappearance. + if (mine.known && !mine.removed) { + // A LIVE local value survived the peer's disappearance. Both devices had published + // this path, the peer's newer value was the one delivered, and now the peer's entry + // is gone without a tombstone -- so the winner is our own value again. Saying + // nothing left the listener on the vanished peer value while getData() already + // returned the local one, and nothing later would reconcile them. + // + // Announced only once per disappearance: _lastReceived is rewritten from `seen` + // below, so `gone` drops out of it and this branch cannot re-fire on the next + // context update. + if (mine.data != nil && !alreadyAnnounced) { + cn1_wearable_deliverDataChanged(gone.UTF8String, mine.data.bytes, + (int) mine.data.length); + } + } else if (!mine.known && !alreadyAnnounced) { + cn1_wearable_deliverDataRemoved(gone.UTF8String); + } + } + } + [_lastReceived release]; + _lastReceived = [seen mutableCopy]; +} + +- (void)session:(WCSession *)session didReceiveFile:(WCSessionFile *)file { + // The only delivery path decodes bytes as a WearableMessage, so raw file contents would arrive + // as a malformed payload with the name lost. Encode name+contents into one, matching what the + // Android bridge publishes for a transfer. + NSString *path = file.metadata[kPathKey]; + NSData *body = [NSData dataWithContentsOfURL:file.fileURL]; + if (body == nil) { + return; + } + // Copied somewhere durable BEFORE this returns. WatchConnectivity deletes its temporary file as + // soon as the delegate returns and considers the transfer complete, while the payload at that + // point exists only in WearableConnection's in-process queue or an un-run callSerially. A + // process death in between loses a one-shot file that the sender has already been told arrived, + // and nothing redelivers it -- the sender's copy is gone too. + NSData *wrapped = cn1WearableWrapFile(file.fileURL.lastPathComponent, body); + NSString *stashed = cn1WearableStashInbox(path, wrapped); + // Marked before the hand-off, so a reactivation that drains the inbox mid-flight skips it. + cn1WearableMarkInFlight(stashed); + // Retired from the EDT once the app actually has the payload, not here. Deleting or marking at + // this point discards the only durable copy while the delivery is still merely queued, and a + // process death in that window loses a one-shot transfer the sender was already told arrived. + cn1_wearable_deliverDataChangedTracked(path.UTF8String, wrapped.bytes, (int) wrapped.length, + stashed == nil ? NULL : stashed.UTF8String); +} + +@end + +#endif // CN1_USE_WATCHCONNECTIVITY diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 8ac07a2f5e5..0a921d09cf4 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CodenameOne_GLAppDelegate.h" #ifdef CN1_USE_UI_SCENE #import "CodenameOne_GLSceneDelegate.h" @@ -1124,3 +1127,10 @@ - (void)cn1MenuAction:(UICommand *)sender API_AVAILABLE(ios(13.0)) { #endif @end + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_codenameone_glappdelegate_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m index d82f4badb72..ebe3c8d67af 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CodenameOne_GLSceneDelegate.h" #ifdef CN1_USE_UI_SCENE @@ -121,3 +124,10 @@ - (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivi @end #endif + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_codenameone_glscenedelegate_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index f10950bccab..950dcccd177 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -164,6 +164,17 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); #undef CN1_USE_WIDGETS #endif +// CN1_USE_WATCHCONNECTIVITY gates the phone-to-watch link (CN1WatchConnectivity.{h,m} + the +// IOSNative wearable* trampolines) backing com.codename1.wearable. IPhoneBuilder uncomments this +// only when the classpath scanner saw com.codename1.wearable.*, so apps that never talk to a watch +// ship without any WatchConnectivity symbols and link no framework. Unlike the defines above this +// one deliberately SURVIVES on watchOS: WCSession is symmetric, and the watch half of a pair needs +// exactly the same code as the phone half. It does not exist on tvOS or Mac Catalyst. +//#define CN1_USE_WATCHCONNECTIVITY +#if TARGET_OS_TV || TARGET_OS_MACCATALYST +#undef CN1_USE_WATCHCONNECTIVITY +#endif + // CN1_INCLUDE_OIDC gates the com.codename1.io.oidc native bridge // (AuthenticationServices.framework import, ASWebAuthenticationSession code // in CN1OidcBrowser.m). IPhoneBuilder uncomments this only when the diff --git a/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m b/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m index 491b250e2f5..cac69622472 100644 --- a/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m +++ b/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "DrawGradientTextureCache.h" #import "ExecutableOp.h" #include "xmlvm.h" @@ -139,5 +142,9 @@ -(void)dealloc { @end - - +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_drawgradienttexturecache_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/DrawStringTextureCache.m b/Ports/iOSPort/nativeSources/DrawStringTextureCache.m index f8f7e4d7a26..9ea86e21290 100644 --- a/Ports/iOSPort/nativeSources/DrawStringTextureCache.m +++ b/Ports/iOSPort/nativeSources/DrawStringTextureCache.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "DrawStringTextureCache.h" #import "ExecutableOp.h" #include "xmlvm.h" @@ -157,3 +160,10 @@ -(void)dealloc { } #endif @end + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_drawstringtexturecache_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/EAGLView.m b/Ports/iOSPort/nativeSources/EAGLView.m index ff1e3343063..792b401a5bc 100644 --- a/Ports/iOSPort/nativeSources/EAGLView.m +++ b/Ports/iOSPort/nativeSources/EAGLView.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import #import "EAGLView.h" @@ -455,3 +458,10 @@ -(void)layoutSubviews @end + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_eaglview_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 8e3a7053bdb..cec45177e4d 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15116,6 +15116,335 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported___R_bo return com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); } +// --- Phone-to-watch link (com.codename1.wearable / WatchConnectivity) -------- +// +// Compiled into BOTH the phone target and the watch target: WCSession is symmetric, so the two +// halves of a pair run identical code. Gated on CN1_USE_WATCHCONNECTIVITY, which the builder +// defines only when the app references com.codename1.wearable, so other apps link no framework and +// carry no symbols. Payloads cross as opaque bytes; the value model lives in Java. + +#if defined(CN1_USE_WATCHCONNECTIVITY) && !TARGET_OS_TV && !TARGET_OS_MACCATALYST + +#import "CN1WatchConnectivity.h" + +// Callbacks the delegate calls when the peer sends something. Each hops into the Java callback +// surface, which owns EDT dispatch and the cold-start queue. + +void cn1_wearable_deliverMessage(const char *path, const void *payload, int payloadLength, int replyToken) { + JAVA_OBJECT jPath = path == NULL ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:path]); + JAVA_OBJECT jBody = payload == NULL ? JAVA_NULL + : nsDataToByteArr([NSData dataWithBytes:payload length:payloadLength]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeMessageReceived___java_lang_String_byte_1ARRAY_int( + CN1_THREAD_GET_STATE_PASS_ARG jPath, jBody, replyToken); +} + +void cn1_wearable_deliverReply(int replyToken, const void *payload, int payloadLength, const char *error) { + JAVA_OBJECT jBody = payload == NULL ? JAVA_NULL + : nsDataToByteArr([NSData dataWithBytes:payload length:payloadLength]); + JAVA_OBJECT jError = error == NULL ? JAVA_NULL + : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:error]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeReplyReceived___int_byte_1ARRAY_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG replyToken, jBody, jError); +} + +void cn1_wearable_deliverDataChanged(const char *path, const void *payload, int payloadLength) { + JAVA_OBJECT jPath = path == NULL ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:path]); + JAVA_OBJECT jBody = payload == NULL ? JAVA_NULL + : nsDataToByteArr([NSData dataWithBytes:payload length:payloadLength]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeDataChanged___java_lang_String_byte_1ARRAY( + CN1_THREAD_GET_STATE_PASS_ARG jPath, jBody); +} + +void cn1_wearable_deliverDataChangedTracked(const char *path, const void *payload, int payloadLength, + const char *inboxToken) { + JAVA_OBJECT jPath = path == NULL ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:path]); + JAVA_OBJECT jBody = payload == NULL ? JAVA_NULL + : nsDataToByteArr([NSData dataWithBytes:payload length:payloadLength]); + JAVA_OBJECT jToken = inboxToken == NULL ? JAVA_NULL + : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:inboxToken]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeDataChangedTracked___java_lang_String_byte_1ARRAY_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG jPath, jBody, jToken); +} + +void com_codename1_impl_ios_IOSNative_wearableConfirmInbox___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT token) { + if (token == JAVA_NULL) { + return; + } + POOL_BEGIN(); + cn1_wearable_confirmInbox([toNSString(CN1_THREAD_GET_STATE_PASS_ARG token) UTF8String]); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_wearableReleaseInbox___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT token) { + if (token == JAVA_NULL) { + return; + } + POOL_BEGIN(); + cn1_wearable_releaseInbox([toNSString(CN1_THREAD_GET_STATE_PASS_ARG token) UTF8String]); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_wearableReplayInbox__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + cn1_wearable_replayInbox(); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_wearableForgetReceived___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { + POOL_BEGIN(); + // NULL is PASSED THROUGH, not filtered. It is the rescan request: the pending-delivery cap + // discarded more paths than it could name, and cn1_wearable_forgetReceived reads a null path as + // "forget every received marker and re-offer the whole held context". Returning early here -- + // the reflex for a null argument -- silently dropped the one signal that recovers an overflow, + // so those values stayed marked delivered and never reached the listener that finally arrived. + cn1_wearable_forgetReceived(path == JAVA_NULL + ? NULL : [toNSString(CN1_THREAD_GET_STATE_PASS_ARG path) UTF8String]); + POOL_END(); +} + +void cn1_wearable_deliverDataRemoved(const char *path) { + JAVA_OBJECT jPath = path == NULL ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:path]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeDataRemoved___java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG jPath); +} + +void cn1_wearable_notifyStateChanged(void) { + com_codename1_impl_ios_IOSWearableCallbacks_nativeStateChanged__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +// Turns a Java byte[] into NSData. A null array becomes empty data rather than nil so the callers +// never have to branch. +static NSData *cn1WearableToNSData(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT arr) { + if (arr == JAVA_NULL) { + return [NSData data]; + } + JAVA_ARRAY byteArray = (JAVA_ARRAY) arr; + JAVA_ARRAY_BYTE *data = (JAVA_ARRAY_BYTE *) byteArray->data; + return [NSData dataWithBytes:data length:byteArray->length]; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isSupported]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearablePaired__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isPaired]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableReachable__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isReachable]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableCompanionInstalled__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isCompanionInstalled]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerName__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + // WCSession exposes no peer name, so name the form factor: from the phone the peer is the + // watch, from the watch it is the phone. +#if TARGET_OS_WATCH + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"iPhone"); +#else + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"Apple Watch"); +#endif + POOL_END(); + return r; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerId__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); +#if TARGET_OS_WATCH + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"phone"); +#else + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"watch"); +#endif + POOL_END(); + return r; +} + +void com_codename1_impl_ios_IOSNative_wearableSendMessage___java_lang_String_byte_1ARRAY_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload, JAVA_INT replyToken) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + [[CN1WatchConnectivity shared] sendMessage:p + payload:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG payload) + replyToken:(int) replyToken]; + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_wearableSendReply___int_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT replyToken, JAVA_OBJECT payload) { + POOL_BEGIN(); + [[CN1WatchConnectivity shared] sendReply:(int) replyToken + payload:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG payload)]; + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_wearablePutData___java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + [[CN1WatchConnectivity shared] putData:p + payload:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG payload)]; + POOL_END(); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + NSData *d = [[CN1WatchConnectivity shared] getData:p]; + JAVA_OBJECT r = d == nil ? JAVA_NULL : nsDataToByteArr(d); + POOL_END(); + return r; +} + +void com_codename1_impl_ios_IOSNative_wearableRemoveData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + [[CN1WatchConnectivity shared] removeData:p]; + POOL_END(); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableDataPaths__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + NSArray *paths = [[CN1WatchConnectivity shared] dataPaths]; + // Escaped before joining, because a newline is only "impossible" in a path by convention and + // nothing enforces it: WearableMessage rejects null and empty paths and nothing else, and + // Android and JavaSE both carry a path containing one perfectly well. Joining raw meant + // "/sync\nstate" came back to the app as two phantom paths that getData() could not read, + // making getDataPaths() disagree with the other two platforms about what exists. + // + // '%' first, so unescaping cannot turn a literal "%0a" in a path into a delimiter. + NSMutableArray *escaped = [NSMutableArray arrayWithCapacity:paths.count]; + for (NSString *p in paths) { + NSString *e = [p stringByReplacingOccurrencesOfString:@"%" withString:@"%25"]; + e = [e stringByReplacingOccurrencesOfString:@"\n" withString:@"%0a"]; + [escaped addObject:e]; + } + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG [escaped componentsJoinedByString:@"\n"]); + POOL_END(); + return r; +} + +void com_codename1_impl_ios_IOSNative_wearableTransferFile___java_lang_String_java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT name, JAVA_OBJECT contents) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + NSString *n = name == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG name); + [[CN1WatchConnectivity shared] transferFile:p + name:n + contents:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG contents)]; + POOL_END(); +} + +#else // CN1_USE_WATCHCONNECTIVITY + +// The app never references com.codename1.wearable (or this is tvOS / Mac Catalyst, where +// WatchConnectivity does not exist). No framework is linked and everything answers unsupported, +// which makes the public API an inert no-op. + +void cn1_wearable_deliverMessage(const char *path, const void *payload, int payloadLength, int replyToken) { +} +void cn1_wearable_deliverReply(int replyToken, const void *payload, int payloadLength, const char *error) { +} +void cn1_wearable_deliverDataChanged(const char *path, const void *payload, int payloadLength) { +} +void cn1_wearable_deliverDataChangedTracked(const char *path, const void *payload, int payloadLength, + const char *inboxToken) { +} +void cn1_wearable_confirmInbox(const char *inboxToken) { +} +void cn1_wearable_releaseInbox(const char *inboxToken) { +} +void cn1_wearable_replayInbox(void) { +} +void cn1_wearable_forgetReceived(const char *path) { +} +void cn1_wearable_deliverDataRemoved(const char *path) { +} +void cn1_wearable_notifyStateChanged(void) { +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearablePaired__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableReachable__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableCompanionInstalled__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerName__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerId__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_wearableSendMessage___java_lang_String_byte_1ARRAY_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload, JAVA_INT replyToken) { +} +void com_codename1_impl_ios_IOSNative_wearableSendReply___int_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT replyToken, JAVA_OBJECT payload) { +} +void com_codename1_impl_ios_IOSNative_wearablePutData___java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload) { +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_wearableRemoveData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableDataPaths__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_wearableTransferFile___java_lang_String_java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT name, JAVA_OBJECT contents) { +} +void com_codename1_impl_ios_IOSNative_wearableConfirmInbox___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT token) { +} +void com_codename1_impl_ios_IOSNative_wearableReleaseInbox___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT token) { +} +void com_codename1_impl_ios_IOSNative_wearableReplayInbox__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} +void com_codename1_impl_ios_IOSNative_wearableForgetReceived___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { +} + +#endif // CN1_USE_WATCHCONNECTIVITY + +// Return-typed aliases the translator emits for methods with a non-void return. +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearablePaired___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearablePaired__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableReachable___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableReachable__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableCompanionInstalled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableCompanionInstalled__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerName___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearablePeerName__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerId___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearablePeerId__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String_R_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT path) { + return com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, path); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableDataPaths___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableDataPaths__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} + void com_codename1_impl_ios_IOSNative_setSecureStorageAccessGroup___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT accessGroup) { if (cn1_keychainAccessGroup != nil) { [cn1_keychainAccessGroup release]; diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 6fdbe77259e..42550329564 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -20,6 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #import "CN1ES2compat.h" #ifdef CN1_USE_METAL #import @@ -1482,3 +1485,10 @@ -(void)layoutSubviews @end #endif + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_metalview_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/UIWebViewEventDelegate.m b/Ports/iOSPort/nativeSources/UIWebViewEventDelegate.m index d43a8872dc8..f6c21a4044b 100644 --- a/Ports/iOSPort/nativeSources/UIWebViewEventDelegate.m +++ b/Ports/iOSPort/nativeSources/UIWebViewEventDelegate.m @@ -21,6 +21,9 @@ * need additional information or have any questions. */ +#include "TargetConditionals.h" +#if !TARGET_OS_WATCH + #include "TargetConditionals.h" // UIWebView / WebKit are unavailable on tvOS; the legacy browser-peer delegate // is dropped on the tvOS slice (matching how it is excluded on watchOS). @@ -167,3 +170,10 @@ - (void)userContentController:(WKUserContentController *)userContentController d @end #endif // !TARGET_OS_TV + +#else +// Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch +// slice renders through the Core Graphics backend instead. The typedef keeps the +// translation unit non-empty, which ISO C requires. +typedef int cn1_uiwebvieweventdelegate_unused_on_watch; +#endif // !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/WATCHOS_PORT.md b/Ports/iOSPort/nativeSources/WATCHOS_PORT.md index 9133966b85c..0d13a517082 100644 --- a/Ports/iOSPort/nativeSources/WATCHOS_PORT.md +++ b/Ports/iOSPort/nativeSources/WATCHOS_PORT.md @@ -44,10 +44,16 @@ A CN1 project declares the watch entry point next to the phone main in codename1.mainName=com.example.MyApp # phone lifecycle ("main" class) codename1.watchMain=com.example.MyWatchApp # watch lifecycle (Apple Watch + Wear) ``` -`codename1.watchMain` flows through `CN1BuildMojo` as the `watchMain` build arg. -`WatchNativeBuilder.parseHints` auto-enables the watch slice whenever `watchMain` -is present (no separate `watchNative.enabled` needed), so the regular iPhone -build emits the packaged double app. +Declaring `codename1.watchMain` is the *entire* opt-in — there are no wearable +build hints. It reaches `WatchNativeBuilder.parseHints` as the `watchMain` build +argument by two routes: `CN1BuildMojo.putSecondaryEntryPointArguments` on local +builds, and, for cloud builds, `createAntProject` mirroring it into +`codename1.arg.watchMain` in the uploaded settings file (the server only lifts +`codename1.arg.*` keys, so without that mirror a cloud build produced no watch +app at all). Everything else — bundle id, deployment target, team id, display +name — is derived. The one other recognized setting is +`codename1.watchStandalone=true`, which ships the watch app on its own instead of +embedding it in the phone app. **Important - current bootstrap reality (do NOT assume watchMain tree-shaking):** The watch target compiles the SAME single ParparVM translation as the phone and @@ -73,9 +79,11 @@ Core-Graphics-backend issue, not absent code. - a Swift bridging header. Because the watch app is SwiftUI-`@main`-rooted, the shared ParparVM `int main()` -(the phone entry) must be excluded from the watch target via -`watchNative.phoneMainSource=` (added to the -watch target's `EXCLUDED_SOURCE_FILE_NAMES`). +(the phone entry) must not produce a second `main` symbol in the watch target. +`applyXcodeSettings` neutralises it with a per-file `-Dmain=...` rename on the +translated phone Stub, which keeps the app's translated classes available to the +watch. (An earlier draft of this document described a `watchNative.phoneMainSource` +hint that excluded the file outright; that hint never existed.) ## Complete interactive app on the simulator — VERIFIED (2026-06-17) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 20f741f3698..1dbbbe9e021 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -368,6 +368,20 @@ public boolean isCarConnected() { return nativeInstance.isCarPlayConnected(); } + private IOSWearableBridge wearableBridge; + + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + // Only meaningful in builds that linked the WatchConnectivity natives + // (CN1_USE_WATCHCONNECTIVITY, flipped by the builder when the app references + // com.codename1.wearable). Always returned: the bridge's own isSupported() answers honestly + // through the natives, which stub to unsupported when the define is off. + if (wearableBridge == null) { + wearableBridge = IOSWearableCallbacks.getBridge(nativeInstance); + } + return wearableBridge; + } + private IOSSurfaceBridge surfaceBridge; @Override diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 682c8db1fa3..99853fe0ce5 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1145,6 +1145,69 @@ native void walletExtensionAddPassEntry(boolean remote, String identifier, Strin /** True when ActivityKit live activities are available and enabled (iOS 16.1+). */ native boolean surfacesActivitiesSupported(); + // --- Phone-to-watch link (WatchConnectivity) ---------------------------- + // Backs com.codename1.wearable. The same natives serve both halves of a pair: WCSession is + // symmetric, so the phone app and the watch app run identical code. Payloads cross as opaque + // bytes; the value model lives in com.codename1.wearable.WearableMessage. + + /** True when this device supports a phone-to-watch link at all (false on iPad). */ + native boolean wearableSupported(); + + /** True when a counterpart device is paired, in range or not. */ + native boolean wearablePaired(); + + /** True when the peer app can receive a live message right now. */ + native boolean wearableReachable(); + + /** True when the counterpart app is installed on the paired device. */ + native boolean wearableCompanionInstalled(); + + /** The paired device's name, for display. Empty when nothing is paired. */ + native String wearablePeerName(); + + /** The paired device's opaque identifier. Empty when nothing is paired. */ + native String wearablePeerId(); + + /** + * Sends a live message, delivered only while the peer is reachable. A non-zero + * {@code replyToken} asks for an answer, which comes back through {@code IOSWearableCallbacks}. + */ + native void wearableSendMessage(String path, byte[] payload, int replyToken); + + /** Answers a message that arrived carrying a reply token. */ + native void wearableSendReply(int replyToken, byte[] payload); + + /** Publishes or replaces the replicated value at a path (the WCSession application context). */ + native void wearablePutData(String path, byte[] payload); + + /** Reads the replicated value at a path, published by either side. Null when absent. */ + native byte[] wearableGetData(String path); + + /** Removes the replicated value at a path. */ + native void wearableRemoveData(String path); + + /** Every path currently holding a replicated value, newline separated. */ + native String wearableDataPaths(); + + /** Queues a background file transfer to the peer. */ + native void wearableTransferFile(String path, String name, byte[] contents); + + /// Retires a durable inbox entry once the payload has reached the application. Delivery of an + /// incoming file parks a copy on disk first, and only this call -- made from the EDT after the + /// listener has run -- is allowed to discard it. + native void wearableConfirmInbox(String token); + + /// Gives up an inbox entry that was never delivered, keeping the file so a later activation can + /// replay it. Only the in-process marker that suppresses replay is cleared. + native void wearableReleaseInbox(String token); + + /// Re-offers everything still parked in the durable inbox. Called once a data listener exists. + native void wearableReplayInbox(); + + /// Forgets that a path's value was received, so the next context update delivers it again. + /// Used to recover a delivery the pending-delivery cap discarded. + native void wearableForgetReceived(String path); + // --- Secure storage (Security.framework keychain) ----------------------- /** Sets the kSecAttrAccessGroup applied to subsequent keychain operations. {@code null} clears. */ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java new file mode 100644 index 00000000000..4e5943b20ac --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java @@ -0,0 +1,151 @@ +/* + * 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.wearable.WearableConnection; +import com.codename1.wearable.spi.WearableBridge; + +/// Apple `WearableBridge`, backing `com.codename1.wearable` with `WCSession`. +/// +/// The same class runs on both halves of a pair: WatchConnectivity is symmetric, so the phone app +/// and the watch app use identical code and the Java API behaves identically at both ends. The three +/// transports map onto WCSession as follows: +/// +/// - a live message is `sendMessage:replyHandler:`, delivered only while the peer is reachable; +/// - replicated data is the session's application context, which survives both apps being killed and +/// is handed to the peer whenever it next runs; +/// - a file transfer is `transferFile:metadata:`, which the system schedules in the background. +/// +/// Payloads cross as opaque bytes, so the native layer never has to understand the value model. +/// +/// This whole class is dead code unless the build linked the WatchConnectivity natives (the +/// `CN1_USE_WATCHCONNECTIVITY` define the builder flips when the app references +/// `com.codename1.wearable`); without it every native answers unsupported and the public API no-ops. +final class IOSWearableBridge implements WearableBridge { + private final IOSNative nativeInstance; + + IOSWearableBridge(final IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + // What to do when the pending-delivery cap discards one of our callbacks: forget that the + // path's value was received. WatchConnectivity replaces the whole context on every publish, + // so the entry is otherwise treated as unchanged forever and the app never sees it -- there + // is no per-path redelivery to fall back on. Forgetting makes the next context update look + // new. Runs after the drain, so the re-offer meets a listener. + WearableConnection.setDroppedDeliveryHandler(new DroppedDelivery(nativeInstance)); + } + + /// Named and STATIC, not an anonymous inner class. + /// + /// It needs the natives and nothing else, but an anonymous class declared in the constructor + /// also captures the enclosing bridge -- and this one is handed straight to a static registry, + /// so it published `this` before the constructor had finished and then pinned the bridge for + /// the life of the process. Naming it costs six lines and removes both. + private static final class DroppedDelivery implements WearableConnection.DroppedDeliveryHandler { + private final IOSNative nativeInstance; + + DroppedDelivery(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + } + + public void deliveryDropped(String path) { + // A null path is the rescan request; the native side reads it as "forget every + // received marker" and re-runs the delivery over the held context. + nativeInstance.wearableForgetReceived(path); + } + } + + public boolean isSupported() { + return nativeInstance.wearableSupported(); + } + + public boolean isPaired() { + return nativeInstance.wearablePaired(); + } + + public boolean isReachable() { + return nativeInstance.wearableReachable(); + } + + public boolean isCompanionAppInstalled() { + return nativeInstance.wearableCompanionInstalled(); + } + + public String[] getConnectedNodes() { + if (!isReachable()) { + // WCSession has no node list -- Apple pairs exactly one watch -- so the peer is either + // there or it is not, and "there" is what reachable means. + return new String[0]; + } + String name = nativeInstance.wearablePeerName(); + String id = nativeInstance.wearablePeerId(); + return new String[] {(id == null ? "peer" : id) + "\t" + + (name == null ? "Paired device" : name) + "\t1"}; + } + + public void sendMessage(String path, byte[] payload, int replyToken) { + nativeInstance.wearableSendMessage(path, payload, replyToken); + } + + public void sendReply(int replyToken, byte[] payload) { + nativeInstance.wearableSendReply(replyToken, payload); + } + + public void putData(String path, byte[] payload) { + nativeInstance.wearablePutData(path, payload); + } + + public byte[] getData(String path) { + return nativeInstance.wearableGetData(path); + } + + public void removeData(String path) { + nativeInstance.wearableRemoveData(path); + } + + public String[] getDataPaths() { + String joined = nativeInstance.wearableDataPaths(); + if (joined == null || joined.length() == 0) { + return new String[0]; + } + // Newline-separated with the newlines escaped, because "a CN1 path never contains one" was + // a convention rather than a rule -- WearableMessage rejects only null and empty paths, and + // Android and JavaSE carry a path with a newline in it without complaint. The native side + // percent-escapes '%' and then '\n'; unescaping in the reverse order is what keeps a + // literal "%0a" in a path from becoming a delimiter on the way back. + java.util.List parts = com.codename1.util.StringUtil.tokenize(joined, '\n'); + String[] out = new String[parts.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = com.codename1.util.StringUtil.replaceAll( + com.codename1.util.StringUtil.replaceAll(parts.get(i), "%0a", "\n"), + "%25", "%"); + } + return out; + } + + public void transferFile(String path, String name, byte[] contents) { + // WCSession moves the file itself, so the bytes go across untouched; the native receive + // side re-encodes them as a WearableMessage carrying name and contents, which is what the + // delivery path decodes. Sending is therefore raw by design, not by omission. + nativeInstance.wearableTransferFile(path, name, contents); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java new file mode 100644 index 00000000000..7d869ef65e3 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java @@ -0,0 +1,140 @@ +/* + * 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.wearable.WearableConnection; + +/// Static callback surface invoked from `CN1WatchConnectivity` when the peer app sends something. +/// +/// Mirrors the `IOSSurfaceCallbacks` pattern: the static initializer calls each callback once +/// (guarded so it has no effect) purely to keep the ParparVM dead-code eliminator from stripping +/// targets that have no Java caller. Everything here forwards straight to +/// `WearableConnection`, which owns EDT dispatch and the cold-start queue. +final class IOSWearableCallbacks { + private static IOSWearableBridge bridge; + private static boolean dceGuard; + + static { + // Keep the native callback targets reachable for the iOS VM optimizer. + dceGuard = true; + nativeMessageReceived(null, null, 0); + nativeReplyReceived(0, null, null); + nativeDataChanged(null, null); + nativeDataChangedTracked(null, null, null); + nativeDataRemoved(null); + nativeStateChanged(); + dceGuard = false; + } + + private IOSWearableCallbacks() { + } + + /// Returns the singleton wearable bridge, creating it on first use. + static synchronized IOSWearableBridge getBridge(IOSNative nativeInstance) { + if (bridge == null) { + bridge = new IOSWearableBridge(nativeInstance); + } + return bridge; + } + + // ---- Callbacks invoked from native code (do not rename) ---------------- + + /// Called from native when the peer app sends a live message. + static void nativeMessageReceived(String path, byte[] payload, int replyToken) { + if (dceGuard) { + return; + } + WearableConnection.deliverMessage(path, payload, replyToken); + } + + /// Called from native with the peer's answer to a message that asked for one. + static void nativeReplyReceived(int replyToken, byte[] payload, String error) { + if (dceGuard) { + return; + } + WearableConnection.deliverReply(replyToken, payload, error); + } + + /// Called from native when the peer publishes or updates a replicated value. + static void nativeDataChanged(String path, byte[] payload) { + if (dceGuard) { + return; + } + WearableConnection.deliverDataChanged(path, payload); + } + + /// Called from native for a delivery whose durable copy must survive until the app has it. + /// + /// An incoming file transfer is stashed on disk before it is handed over, because + /// WatchConnectivity deletes its own temporary the moment the delegate returns. Confirming from + /// inside the delivery -- rather than when it was merely queued -- is what makes the stash + /// worth having: a process death before the EDT runs now replays on the next activation + /// instead of losing a one-shot transfer the sender already considers delivered. + static void nativeDataChangedTracked(final String path, byte[] payload, final String token) { + if (dceGuard) { + return; + } + if (token == null) { + WearableConnection.deliverDataChanged(path, payload); + return; + } + WearableConnection.deliverDataChangedTracked(path, payload, new Runnable() { + public void run() { + IOSImplementation.nativeInstance.wearableConfirmInbox(token); + } + }, new Runnable() { + public void run() { + // Evicted before any listener saw it. The file stays -- it is still the only copy -- + // but the native in-flight mark has to go, or every later drain in this process + // skips the very entry it is protecting and the transfer is never offered again. + IOSImplementation.nativeInstance.wearableReleaseInbox(token); + // Clearing the mark only makes it eligible. Re-offer it once a listener exists: + // doing it now would park the delivery, evict another one-shot to make room, and + // set off a cycle of mutual evictions. + // One key for every eviction: the native drain re-offers the WHOLE inbox, so a + // request per evicted transfer would rescan the entire backlog once per eviction. + WearableConnection.requestReplayAfterDrain("ios-wearable-inbox", new Runnable() { + public void run() { + IOSImplementation.nativeInstance.wearableReplayInbox(); + } + }); + } + }); + } + + /// Called from native when the peer removes a replicated value. + static void nativeDataRemoved(String path) { + if (dceGuard) { + return; + } + WearableConnection.deliverDataRemoved(path); + } + + /// Called from native when reachability, pairing or peer-app installation changes. + static void nativeStateChanged() { + if (dceGuard) { + return; + } + WearableConnection.notifyStateChanged(); + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java index 0cf4bf5fb99..d42877e3385 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java @@ -1,3 +1,26 @@ +/* + * 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.*; @@ -15,6 +38,8 @@ import com.codename1.charts.views.*; import com.codename1.capture.*; import com.codename1.io.*; +import com.codename1.surfaces.*; +import com.codename1.wearable.*; import com.codename1.l10n.*; import com.codename1.location.*; import com.codename1.maps.*; @@ -55,6 +80,13 @@ class WearablesJava001Snippet { Label label; BrowserComponent browserComponent; Resources theme; + Label stepsLabel; + int stepCount = 0; + void showWorkout(String id) { + } + String beginWorkout() { + return "w1"; + } void snippet() throws Exception { // tag::wearables-java-001[] Form f = new Form(BoxLayout.y()); @@ -68,5 +100,63 @@ void snippet() throws Exception { } f.show(); // end::wearables-java-001[] + + // tag::wearables-java-002[] + // On the phone: publish the value the watch should show whenever it next wakes. + WearableConnection.putData(new WearableMessage("/steps") + .put("count", stepCount) + .put("goalReached", stepCount >= 10000)); + // end::wearables-java-002[] + + // tag::wearables-java-003[] + // On the watch: react to it. Register from init(), not from a form -- a value that + // arrived while the app was starting is replayed only to listeners that exist by then. + WearableConnection.addDataListener(new WearableDataListener() { + public void dataChanged(WearableMessage data) { + stepsLabel.setText("" + data.getInt("count", 0)); + } + + public void dataRemoved(String path) { + stepsLabel.setText("--"); + } + }); + // end::wearables-java-003[] + + // tag::wearables-java-004[] + // Ask the phone something and use the answer. Only works while both apps are awake, + // so check first and fall back to what you already replicated. + if (WearableConnection.isReachable()) { + WearableConnection.sendMessage(new WearableMessage("/workout/start"), + new WearableReplyHandler() { + public void replyReceived(WearableMessage reply) { + showWorkout(reply.getString("id", null)); + } + + public void replyFailed(String message) { + Log.p("Could not start the workout: " + message); + } + }); + } + // end::wearables-java-004[] + + // tag::wearables-java-005[] + // Answer the watch. Reply quickly and do slow work afterwards -- the sender is waiting. + WearableConnection.addMessageListener(new WearableMessageListener() { + public WearableMessage messageReceived(WearableMessage message, boolean expectsReply) { + if ("/workout/start".equals(message.getPath())) { + return new WearableMessage("/workout/start").put("id", beginWorkout()); + } + return null; + } + }); + // end::wearables-java-005[] + + // tag::wearables-java-006[] + // A complication is a widget in a watch family, published from the same timeline. + WidgetKind steps = new WidgetKind("steps") + .setDisplayName("Steps") + .addSupportedSize(WidgetSize.WATCH_CIRCULAR) + .addSupportedSize(WidgetSize.WATCH_RECTANGULAR); + // end::wearables-java-006[] } } diff --git a/docs/demos/common/src/main/snippets/developer-guide/wearables.properties b/docs/demos/common/src/main/snippets/developer-guide/wearables.properties index 1a1cd0d41e4..9d93449019f 100644 --- a/docs/demos/common/src/main/snippets/developer-guide/wearables.properties +++ b/docs/demos/common/src/main/snippets/developer-guide/wearables.properties @@ -1,13 +1,10 @@ // Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. // tag::wearables-properties-001[] -watchNative.enabled=true +codename1.mainName=MyApp +codename1.watchMain=com.mycompany.myapp.MyWatchMain // end::wearables-properties-001[] // tag::wearables-properties-002[] -codename1.watchMain=com.mycompany.myapp.MyWatchMain +codename1.watchStandalone=true // end::wearables-properties-002[] - -// tag::wearables-properties-003[] -android.wear=true -// end::wearables-properties-003[] diff --git a/docs/developer-guide/TVPlatforms.asciidoc b/docs/developer-guide/TVPlatforms.asciidoc index 7ba5bbcbe08..a0a91c32d2c 100644 --- a/docs/developer-guide/TVPlatforms.asciidoc +++ b/docs/developer-guide/TVPlatforms.asciidoc @@ -87,8 +87,9 @@ feature, makes `android.hardware.touchscreen` optional, and generates the === Building for Apple TV (tvOS) -Enable the tvOS application target with the `tvNative.*` build hints (analogous -to the `watchNative.*` hints used for Apple Watch): +Enable the tvOS application target with the `tvNative.*` build hints (the Apple +Watch build is enabled by declaring a `codename1.watchMain` instead -- see the +wearables chapter): [source,properties] ---- diff --git a/docs/developer-guide/Wearables.asciidoc b/docs/developer-guide/Wearables.asciidoc index 5eb188145b2..ae9c228c75c 100644 --- a/docs/developer-guide/Wearables.asciidoc +++ b/docs/developer-guide/Wearables.asciidoc @@ -1,12 +1,35 @@ == Wearables (Apple Watch and Wear OS) -Codename One can build and run your application UI on smartwatches: Apple Watch -(watchOS) and Android Wear OS. The same Java/Kotlin code base that drives your -phone app drives the watch app -- you write Codename One UI as usual, and the -build pipeline produces the appropriate watch artifact for each platform. +Codename One builds a watch app from the same project as your phone app, on both +Apple Watch and Wear OS. This chapter covers the whole picture: how one project +produces two apps, how you run the pair while you develop, how the two apps +exchange information, and how to put a complication on a watch face. -The two platforms reach the watch through different mechanisms, and -understanding the difference explains why the build hints and the supported +=== One Project, Two Apps + +Declaring a watch lifecycle class next to your phone main class is the entire +opt-in: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-001,indent=0] +---- + +There are no wearable build hints. The watch bundle identifier, deployment +target, signing team and display name are all derived from settings your project +already has, and one declaration builds the watch app on both platforms. + +Note the asymmetry: `codename1.mainName` is a simple class name resolved against +`codename1.packageName`, while `codename1.watchMain` is fully qualified. + +What the two apps share is the code base: your classes, your resources, your +theme and your CSS. What they don't share is anything at runtime. They're two +apps, on two devices, in two sandboxes, with separate lifecycles. In particular +`Storage`, `Preferences` and the SQLite database are *per device*: writing on +the phone doesn't make the value appear on the watch. Moving information +between them is what <> is for. + +The two platforms get there by different routes, which is why their supported feature sets differ: * *Wear OS is Android.* A Wear OS app is an ordinary Android app that declares @@ -20,8 +43,41 @@ feature sets differ: The graphics-heavy, GPU-bound and UIKit-peer APIs that have no watchOS equivalent are unavailable on the watch (see <>). -In both cases the build is *additive*: with the watch hints turned off your -phone build is byte-for-byte unchanged. +Without a watch main class the build is byte-for-byte what it was, so adding one +never changes a phone build you already ship. + +=== Companion or Standalone + +By default the watch app is a *companion*: it ships inside the phone app and the +pair installs together. If the watch app is the product and there is no phone app +to pair with, declare it standalone: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-002,indent=0] +---- + +On Android a standalone build turns the single APK into the Wear OS app, and that +is what ships. On Apple the watch target is built standalone -- detached from the +phone app rather than embedded in it -- but the archive step still targets the +phone scheme, so submission needs one manual step in Xcode. See +<> before you archive. + +=== Running the Pair While You Develop + +The simulator can run both halves. Choose a watch skin (Apple Watch 41mm or 45mm, +Wear round or Wear square) to develop the watch UI on its own, or pick *Watch -> +Launch Watch App* to start the watch app beside the phone app. + +The watch app runs in its own process rather than in another window of the same +one, because that's what it becomes on a device -- a second app with its own sandbox. +The two processes find each other, so `sendMessage` and `putData` genuinely +round-trip on your desktop and you can develop the conversation between the two +apps without deploying anything. + +TIP: Check your layout against the *Wear round* skin. A round face is where a +design that assumes a rectangle falls apart, and its safe area is inset +accordingly. === Detecting the Watch Form Factor @@ -58,91 +114,216 @@ A watch screen is small and is frequently round. A few practical guidelines: without forking your code (the override layer activates on watch devices the same way platform overrides do elsewhere). -TIP: You can lay out and iterate on a watch UI in the simulator by guarding the -watch layout with `CN.isWatch()` and exercising both branches; the device build -then renders the same code on the real watch. +=== Sharing Data Between the Phone and the Watch +[[wearable-data]] -=== Apple Watch (watchOS) +The two apps share no storage. `Storage`, `Preferences` and the SQLite database +are per device, and there's no container that spans the pair, so a value written +on the phone is simply not on the watch. `com.codename1.wearable` is the channel +between them, and it's the same API on Apple Watch and Wear OS. -The watchOS build adds a second Xcode target to the generated project. It -compiles the shared, translated application sources for the watch architecture -(`arm64_32` on device), renders through the Core Graphics backend, and -- in the -default _companion_ distribution -- embeds the watch app inside your iOS app so -the pair installs together. The watch app is rooted in a generated SwiftUI -`@main` shell that hosts the Codename One frames and forwards Digital Crown and -tap input into the runtime. +The platforms offer three transports because they answer three different +questions. Choosing the wrong one is the usual reason a watch app "never gets the +update": -.Codename One UI rendered on the watchOS simulator via the Core Graphics backend -image::img/wearables/apple-watch-simulator.png[Codename One running on the Apple Watch simulator,scaledwidth=30%] +[cols="2,2,2"] +|=== +|You need |Use |Delivered -==== Enabling the watchOS Build +|An answer, now, while both apps are awake +|`WearableConnection.sendMessage` +|Immediately, or it fails -Set the build hint: +|The peer to end up with the latest value, whenever it next looks +|`WearableConnection.putData` +|Eventually, survives sleep and relaunch -[source,properties] +|To move a file or a large blob +|`WearableConnection.transferFile` +|In the background, possibly much later + +|Data the watch needs with no phone involved at all +|Ordinary `Storage` plus the network +|As usual + +|Something rendered while your app isn't running +|`com.codename1.surfaces` (see <>) +|By the system, from a published timeline +|=== + +A message is a phone call: it only connects if someone picks up. Replicated data +is a noticeboard: you pin the current value at a path, and the peer reads it +whenever it wakes. Reach for data by default and for messages only when you +genuinely need an answer now. + +==== Replicating State + +Publish on one side: + +[source,java] ---- -include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-001,indent=0] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-002,indent=0] ---- -Alternatively, declare a watch entry point and the watch slice is produced -automatically as part of the regular iOS build: +React on the other: -[source,properties] +[source,java] ---- -include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-002,indent=0] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-003,indent=0] ---- -If you don't declare a distinct `watchMain`, the watch app reuses your phone -main class as its lifecycle entry point. +Each path holds one value, so this replicates state rather than queueing events: +two rapid updates to the same path may reach the peer as one. That's what makes +it the right default -- the peer always converges on the latest value, however +long it was away. + +IMPORTANT: Register listeners from your app's `init()`. The platform starts an +app purely to hand it a payload, so what arrives may well be the thing that +launched you. Codename One queues those deliveries and replays them on the EDT, +but only to listeners that exist by the time it does. -NOTE: `codename1.watchMain` (and `watchNative.enabled`) affect only the Apple -Watch (watchOS) build. They have no effect on Android: a Wear OS build is never -produced implicitly -- you enable it explicitly with `android.wear=true` (see -<>). A project can target both wearables at once by setting a -`watchMain` (or `watchNative.enabled=true`) and `android.wear=true` together. +==== Asking a Question -==== watchOS Build Hints +When you need an answer rather than a value, send a message and handle the reply: -[cols="2,1,4"] +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-004,indent=0] +---- + +Then answer it on the other side: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-005,indent=0] +---- + +A reply is never guaranteed: the peer may be asleep, out of range, or running a +version of your app that doesn't know the path. `replyFailed` is the normal +case, not the exceptional one. + +==== Knowing What's There + +`isSupported()` asks whether the platform provides the link, not whether a watch +is there. It's false on a desktop build and on any platform with no wearable +API, and every call is then a harmless no-op, so this API needs no platform +conditionals around it -- but an iPhone with no paired watch still answers true, +because Apple's API is present either way. + +Ask the other three about the counterpart. `isPaired()`, +`isCompanionAppInstalled()` and `isReachable()` distinguish the cases worth +telling a user about: no watch, a watch without your watch app installed, and a +sleeping watch. + +Don't decide your UI from one call at startup. These answers come from state +queried asynchronously, so the first calls in a cold process can report false for +a device that's paired -- nothing is known until the first query lands. Register +a `WearableStateListener` and react when the answer changes. + +One Android limit is worth knowing. The Data Layer exposes pairing only through +the nodes it knows about, so a paired watch that has never run your watch app +appears in no list and `isPaired()` reports false until it runs once. Treat +false as "no counterpart known" rather than proof there is none, and prefer +showing setup guidance to hiding it. Apple's API answers pairing directly and +has no such gap. Gate a wearable feature on those rather than on `isSupported()`, +or you will offer it to someone holding a phone and nothing else. Add a +`WearableStateListener` rather than polling. + +=== Complications and Tiles +[[watch-complications]] + +A complication -- the small live readout on a watch face -- is the same idea as a +home-screen widget: content-driven, rendered while your app isn't running, fed +by a timeline. Codename One models it as such, so a complication is a watch +*family* of `com.codename1.surfaces` rather than an API of its own: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-006,indent=0] +---- + +Everything you already know about surfaces applies: the same node catalog, the +same `${key}` state interpolation, the same timeline that lets the OS advance +content on its own clock with no app wake-ups. `SurfaceVector` is especially at +home here, because most complications are a gauge, a dial or a ring. + +[cols="2,2,2"] |=== -|Build hint |Default |Description - -|`watchNative.enabled` -|`false` -|Force the watch target on even without a distinct `watchMain`. - -|`codename1.watchMain` (a.k.a. `watchMain`) -|_(none)_ -|Fully-qualified watch lifecycle entry class. Setting it also turns on the watch -build. - -|`watchNative.distribution` -|`companion` -|`companion` embeds the watch app in the iOS app; `standalone` builds a -watch-only app with no paired phone app. - -|`watchNative.bundleId` -|`.watchkitapp` -|Bundle identifier of the watch app. - -|`watchNative.minDeploymentTarget` -|`10.0` -|`WATCHOS_DEPLOYMENT_TARGET` for the watch target. - -|`watchNative.displayName` -|_(app display name)_ -|The watch app name shown on the watch. - -|`watchNative.teamId` -|_(falls back to the iOS team id)_ -|Apple Developer Team ID used to sign the watch target. - -|`watchNative.embedCompanion` -|`false` -|Embed the watch app into the iOS app as a build dependency. Off by default so -the iOS build is unaffected; enable it for a packaged companion submission. +|Family |Apple Watch |Wear OS + +|`WATCH_CIRCULAR` +|`accessoryCircular` +|Ranged-value or monochromatic-image complication + +|`WATCH_RECTANGULAR` +|`accessoryRectangular` +|Long-text complication, or a Tile for a richer layout + +|`WATCH_INLINE` +|`accessoryInline` +|Short-text complication. Text only -- anything else is dropped + +|`WATCH_CORNER` +|`accessoryCorner` +|Renders as circular; Wear OS has no corner slot |=== +Design for a glance. A complication is a few dozen pixels someone reads in under +a second, so one number or one gauge beats any layout that has to be read. + +NOTE: `WATCH_RECTANGULAR` and `LOCKSCREEN` share a family on Apple. If you +publish both, each surface gets the layout you designed for it; if you publish +only one, it's used for both. + +IMPORTANT: The watch families and the descriptor pipeline behind them are in +place, and declaring them is forward-compatible. The platform targets that render +them on a watch face -- the watchOS widget extension and the Wear OS complication +data source and Tile service -- aren't generated yet, so a kind that declares +only watch families produces no on-device surface today. Declaring a phone family +alongside them keeps the widget working meanwhile. + +=== Apple Watch (watchOS) + +The watchOS build adds a second Xcode target to the generated project. It +compiles the shared, translated application sources for the watch architecture +(`arm64_32` on device), renders through the Core Graphics backend, and -- in the +default _companion_ distribution -- embeds the watch app inside your iOS app so +the pair installs together. The watch app is rooted in a generated SwiftUI +`@main` shell that hosts the Codename One frames and forwards Digital Crown and +tap input into the runtime. + +.Codename One UI rendered on the watchOS simulator via the Core Graphics backend +image::img/wearables/apple-watch-simulator.png[Codename One running on the Apple Watch simulator,scaledwidth=30%] + +[[watch-distribution]] +==== What the Watch App Runs Today +[[watch-entry-point]] + +Two properties of the watchOS build are worth knowing before you plan around it, +because neither is obvious from the setting you wrote. + +The watch target compiles the same translated application sources as the phone +and starts the phone lifecycle class. On watchOS `codename1.watchMain` therefore +selects and enables the watch build, but it doesn't yet root the watch app at a +different entry point. Branch on `CN.isWatch()` to decide what the watch shows. +On Wear OS the declared class *is* the watch launcher, so a project that keeps +its watch screens behind that check behaves the same on both platforms -- which is +the pattern to write today regardless. + +Distribution has rough edges too, and they surface at archive time rather than in +the build itself. Submitting the companion pair to the App Store needs an app icon +for the watch app, which the generated project doesn't produce. Under manual +signing the embedded watch target has no provisioning profile of its own, since +the host app's profile is the one installed. And with +`codename1.watchStandalone` the watch product is built but not archived, because +the archive step targets the phone scheme -- open the generated project and +archive the watch scheme directly. The build logs this rather than leaving you to +discover it from the contents of the IPA. + +None of this affects building, running or testing on the simulator or a device. +Before you archive for submission, add an `AppIcon` set to the watch target, and +under manual signing give the watch bundle id its own profile. + ==== Supported and Unsupported APIs on watchOS [[watch-supported-apis]] @@ -165,70 +346,72 @@ so keep watch screens light. ==== Building and Debugging -A `companion` build produces an iOS `.ipa` that carries the embedded watch app; -a `standalone` build produces a watch-only product. The generated project is a -standard Xcode project, so you can open it and debug/profile the watch target -with the native Xcode tools as usual. Cloud builds support the watch target -through the same iOS build -- set the hints above and build for iOS. +A companion build produces an iOS `.ipa` that carries the embedded watch app. A +standalone build generates the watch target, but the archive step still targets +the phone scheme, so the artifact handed back is the iOS app: open the generated +project and archive the watch scheme yourself to produce the watch application. +The build logs this. The generated project is a standard Xcode project, so you +can open it and debug or profile the watch target with the native Xcode tools as +usual. Cloud builds generate the watch target through the same iOS build -- +declare the watch main class and build for iOS -- and the same applies there, +so a standalone cloud build returns the iOS archive. === Android (Wear OS) [[wear-os-android]] A Wear OS app is a regular Android app. The Codename One Android port renders the UI with the same pipeline it uses on phones, so no special rendering backend is -required -- you only need to mark the build as a watch app. - -==== Enabling the Wear OS Build +required. The same `codename1.watchMain` declaration drives both platforms. -[source,properties] ----- -include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-003,indent=0] ----- +What it produces differs, though, and that difference is worth stating precisely. +Set `codename1.watchStandalone` and the Android build *is* the watch app: one APK +that installs and runs on the watch. Leave it unset and the Android build stays a +phone build -- a companion Wear APK alongside the phone APK isn't generated yet, +so on Android the companion configuration currently gives you the phone app and +the wearable link, not a second artifact. The build logs this rather than leaving +you to discover it. On Apple the companion case does produce and embed the watch +app, which is why the two platforms have a section each. -This injects the watch hardware feature into the manifest: +A standalone Wear app declares the watch hardware feature in the manifest: [source,xml] ---- include::../demos/common/src/main/snippets/developer-guide/wearables.xml[tag=wearables-xml-001,indent=0] ---- -By default it also declares the app *standalone*, so it installs and runs -directly on the watch without a paired phone app: +It also marks itself standalone, so it installs and runs directly on the watch +without a paired phone app: [source,xml] ---- include::../demos/common/src/main/snippets/developer-guide/wearables.xml[tag=wearables-xml-002,indent=0] ---- -Setting `android.wear=true` also raises the minimum SDK to API 23 (the Wear OS -2.0 standalone baseline) if your project requests a lower level. +A standalone Wear build also raises the minimum SDK to API 23, the Wear OS 2.0 +standalone baseline, if your project requests a lower level. -==== Wear OS Build Hints +==== Wear OS Input and Screen Shape -[cols="2,1,4"] -|=== -|Build hint |Default |Description - -|`android.wear` -|`false` -|Mark the build as a Wear OS app (manifest feature + standalone meta-data + -minimum SDK floor). - -|`android.wear.standalone` -|`true` -|Declare the app standalone. Set to `false` for a watch app that requires a -companion phone app. - -|`android.playService.wearable` -|`false` -|Add the `play-services-wearable` dependency (only needed if you use the -Wearable Data Layer / message APIs directly). -|=== +Two things behave differently on a watch and are handled for you: + +* *Rotary input.* The rotating side button or bezel scrolls the focused + scrollable container, exactly as the Digital Crown does on Apple Watch. It + arrives on its own input source rather than the mouse-wheel axes, and is scaled + by the device's own scroll factor. +* *Round screens.* A circular face reports no display cutout, so a layout drawn + to the full rectangle would have its corners eaten by the bezel. The safe area + is inset to the largest rectangle that fits inside the circle -- about 15% a + side -- so honoring the form's safe-area insets is enough. TIP: Because a Wear OS app is an ordinary Android app, you can also declare any additional manifest features and permissions with the generic `android.uses_feature.` and `android.uses_permission.` hints. +NOTE: Referencing `com.codename1.wearable` adds the `play-services-wearable` +dependency and the listener service automatically. The +`android.playService.wearable` hint remains for apps that want to call the Data +Layer APIs directly. + === Summary [cols="1,2,2"] @@ -236,21 +419,29 @@ additional manifest features and permissions with the generic | |Apple Watch (watchOS) |Wear OS (Android) |Enable -|`watchNative.enabled=true` or `codename1.watchMain` -|`android.wear=true` +|`codename1.watchMain` +|`codename1.watchMain` |Rendering |Dedicated Core Graphics backend + separate watch target |Standard Android rendering pipeline |Distribution -|Companion (embedded in iOS app) or standalone -|Standalone (default) or companion +|Companion (embedded in the phone app) or standalone +|Companion or standalone |Runtime detection |`CN.isWatch()` |`CN.isWatch()` + +|Talking to the phone app +|`com.codename1.wearable` over WatchConnectivity +|`com.codename1.wearable` over the Wearable Data Layer + +|Complications +|WidgetKit accessory families +|Complication data source and Tiles |=== -The wearable build is additive on both platforms: with the hints off, your phone -builds are unchanged. +The wearable build is additive on both platforms: without a watch main class, +your phone builds are unchanged. 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 6e6c8a6b06d..797c346a79d 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 @@ -310,6 +310,43 @@ public File getGradleProjectDirectory() { // activities). Gates the surfaces.json parse, the per-kind widget provider codegen, the // pre-baked layout resources and the manifest receivers/trampoline activity. private boolean usesSurfaces; + // Set when the app references com.codename1.wearable.* (the phone-to-watch link). Gates the + // play-services-wearable dependency, the WearableListenerService manifest entry and the + // injected Data Layer glue. + private boolean usesWearable; + + /** + * The lifecycle class the generated stub instantiates. + * + *

Normally the phone main class. In a standalone Wear OS build the watch app is the product + * -- there is no phone app beside it -- so the single APK is rooted at {@code + * codename1.watchMain} instead; without this the watch declaration only reached the manifest + * and the app still started the phone UI. + * + * @param request the build being generated + * @return the class name the stub should instantiate + */ + /** The declared watch lifecycle class, or an empty string when the project declares none. */ + private static String watchMainClass(BuildRequest request) { + return request.getArg("watchMain", "").trim(); + } + + private String appLifecycleClass(BuildRequest request) { + // Unit-test mode wins. generateUnitTestFiles has already replaced the main class with + // CodenameOneUnitTestExecutor, and rooting the APK at the watch lifecycle instead started + // the watch application rather than DeviceRunner -- so the tests never ran and the build + // reported no results rather than failing, which is the worse of the two. + if (isUnitTestMode()) { + return request.getMainClass(); + } + String watchMain = request.getArg("watchMain", "").trim(); + boolean standalone = "true".equals(request.getArg("watchStandalone", "false")); + if (watchMain.length() > 0 && standalone) { + return watchMain; + } + return request.getMainClass(); + } + private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -1386,24 +1423,53 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc String googlePlayAdViewCode = ""; String userXapplication = request.getArg("android.xapplication", ""); - // Wear OS support. android.wear=true marks this as an Android Wear - // (Wear OS) app. A Wear app is a regular Android app that declares the - // watch hardware feature; the Codename One UI renders through the same - // Android pipeline (no separate render backend is needed, unlike the - // Apple Watch port), and CN.isWatch() returns true at runtime via - // PackageManager.FEATURE_WATCH. Standalone Wear apps (the default since - // Wear OS 2.0) install and run directly on the watch without a paired - // phone app. With the hint off the manifest is unchanged. + // Wear OS support, driven by the same entry point as the Apple Watch + // build: a project declares a watch lifecycle class with + // codename1.watchMain and gets a watch app on both platforms. A Wear app + // is a regular Android app that declares the watch hardware feature; the + // Codename One UI renders through the same Android pipeline (no separate + // render backend is needed, unlike the Apple Watch port), and + // CN.isWatch() returns true at runtime via PackageManager.FEATURE_WATCH. + // + // codename1.watchStandalone=true means the watch app IS the product: it + // installs and runs directly on the watch with no paired phone app, so + // this single APK becomes the watch app. Without it the watch app is a + // companion to the phone app and ships as its own artifact, which leaves + // this (phone) manifest untouched. String wearApplicationMetaData = ""; - if ("true".equals(request.getArg("android.wear", "false"))) { + String watchMain = request.getArg("watchMain", "").trim(); + boolean watchStandalone = "true".equals(request.getArg("watchStandalone", "false")); + // The retired android.wear / android.wear.standalone hints still have to work. A project + // configured against them predates codename1.watchMain and declares neither of the new + // settings, so keying only on those would silently drop the watch hardware feature, the API + // 23 floor and the standalone marker from a manifest that used to have them -- turning a + // working Wear app into a phone APK with no error. android.wear alone implied standalone, + // which is why it maps to the standalone branch. + // + // Keyed on android.wear ALONE. android.wear.standalone is a sub-hint that only ever + // applied inside android.wear=true, so treating it as an independent trigger inverts the + // relationship: android.wear implied standalone, standalone never implied wear. A legacy + // phone project carrying a stray android.wear.standalone=true would otherwise be given the + // API 23 floor and a REQUIRED android.hardware.type.watch feature, and Play would filter + // that APK off every phone -- a working phone app made undeliverable, with no error. + boolean legacyWear = legacyWearMode(request.getArg("android.wear", "false")); + boolean legacyStandaloneStillOn = legacyWearStandalone( + request.getArg("android.wear", "false"), + request.getArg("android.wear.standalone", "")); + if (legacyWear) { + log("[wearable] android.wear is superseded by codename1.watchMain plus " + + "codename1.watchStandalone; still honoured, but the new settings also build " + + "the Apple Watch app from the same declaration."); + } + boolean standaloneWatchBuild = + (watchMain.length() > 0 && watchStandalone) || legacyStandaloneStillOn; + if ((watchMain.length() > 0 && watchStandalone) || legacyWear) { // Wear OS 2.0 (the standalone-app baseline) is API 23. minSDK = maxInt("23", minSDK); if (!xPermissions.contains("android.hardware.type.watch")) { xPermissions += " \n"; } - // Declare the app standalone (runs without a companion phone app) - // unless the developer opts out or already declared the meta-data. - if (!"false".equals(request.getArg("android.wear.standalone", "true")) + if (standaloneWatchBuild && !userXapplication.contains("com.google.android.wearable.standalone")) { wearApplicationMetaData = " \n"; } @@ -1638,6 +1704,13 @@ public void usesClass(String cls) { usesSurfaces = true; } + // Phone-to-watch link (com.codename1.wearable.*). Gated on actual usage so the + // play-services-wearable dependency, the listener service and the injected Data + // Layer glue are only added for apps that talk to their watch app. + if (!usesWearable && cls.indexOf("com/codename1/wearable/") == 0) { + usesWearable = true; + } + if (cls.equals("com/codename1/background/ForegroundService")) { usesForegroundService = true; } @@ -2696,7 +2769,20 @@ public void usesClassMethod(String cls, String method) { String headphonesVars = ""; String headphonesOnResume = ""; - if (request.getArg("android.headphoneCallback", "false").equals("true")) { + // The generated glue calls headphonesConnected()/headphonesDisconnected() on the lifecycle + // instance, so it only compiles when that class declares them -- which the phone main class + // does because the developer added them to enable the hint. In a standalone watch build the + // lifecycle is the watch class instead, and there is no phone app whose author agreed to + // implement a headphone callback, so emitting the glue would simply fail to compile. + // ACTION_HEADSET_PLUG on a watch is not a meaningful event either. + boolean headphonesApplicable = appLifecycleClass(request).equals(request.getMainClass()); + if (request.getArg("android.headphoneCallback", "false").equals("true") + && !headphonesApplicable) { + debug("Ignoring android.headphoneCallback: this is a standalone watch build, whose " + + "lifecycle class is " + appLifecycleClass(request)); + } + if (request.getArg("android.headphoneCallback", "false").equals("true") + && headphonesApplicable) { headphonesVars = " HeadSetReceiver myHeadphoneReceiver;\n\n" + " public static void headphonesConnected() {\n" + " i.headphonesConnected();" @@ -2957,6 +3043,58 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } } + // Wearable Data Layer glue: when the app references com.codename1.wearable, copy the + // injected WearableBridge + WearableListenerService (typed against play-services-wearable) + // into the generated project and add the dependency. The Android port itself cannot + // reference play-services-wearable, which is why these ship as .java resources here and are + // only added for apps that talk to a watch. + if (usesWearable) { + File wearImpl = new File(srcDir, "com/codename1/impl/android"); + wearImpl.mkdirs(); + String[] glue = {"CN1WearableBridge.java", "CN1WearableListenerService.java"}; + for (String g : glue) { + InputStream gin = getResourceAsStream("/com/codename1/builders/wearable/" + g); + if (gin == null) { + throw new BuildException("Missing wearable glue resource " + g); + } + try { + copy(gin, new FileOutputStream(new File(wearImpl, g))); + } catch (IOException ex) { + throw new BuildException("Failed to write wearable glue " + g, ex); + } + } + playServicesWear = true; + // The capability the peer half advertises, so isCompanionAppInstalled() can tell a + // watch running this app from a watch that merely exists. + // resDir, NOT projectDir + "app/...". projectDir already IS the generated app module, + // so the extra segment put this at /app/src/main/res/values -- a directory Gradle + // never packages. The failure is silent and total: the capability is never advertised, + // so after the first query isCompanionAppInstalled() and isReachable() answer false and + // message fan-out filters out every valid peer as "not running the app". + File wearValues = new File(resDir, "values"); + wearValues.mkdirs(); + try { + createFile(new File(wearValues, "cn1_wearable.xml"), + ("\n" + + "\n" + + " \n" + + " cn1_wearable\n" + + " \n" + + "\n").getBytes("UTF-8")); + } catch (IOException ex) { + throw new BuildException("Failed to write the wearable capability declaration", ex); + } + } + if (watchMainClass(request).length() > 0 + && !"true".equals(request.getArg("watchStandalone", "false"))) { + // Say so rather than quietly producing one artifact: a companion Wear APK is not + // generated yet (see the wearables chapter of the developer guide). + log("[wearable] codename1.watchMain is set without codename1.watchStandalone. The " + + "Apple Watch companion is built, but a companion Wear OS APK is not produced " + + "yet -- set codename1.watchStandalone=true to build the watch app as the " + + "Android product."); + } + // External surfaces (com.codename1.surfaces): parse the build-time kinds manifest, // generate one thin widget provider subclass per kind, copy the pre-baked RemoteViews // layout/drawable resources shipped with the plugin and emit the per-kind @@ -3865,6 +4003,34 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } } + // The Data Layer starts this service to deliver a message or a data change even when the + // app is not running -- which is the whole point, and why com.codename1.wearable queues + // callbacks across a cold start. Both the message and data-changed actions are needed: the + // system dispatches them separately. + String wearableListenerService = ""; + if (usesWearable) { + wearableListenerService = + // Exported because Play services binds it -- that is not optional for a + // WearableListenerService. There is no binding permission Play services holds + // that would narrow it, so the service validates the source node of every event + // instead (see CN1WearableListenerService). + " \n" + // BIND_LISTENER is how Play services binds the service, and its intent carries + // no wear: URI -- so it needs a filter of its own. Putting it alongside the + // event actions would apply the constraint to it too and nothing would + // ever bind. + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n"; + } + if (foregroundServicePermission) { permissions += permissionAdd(request, "\"android.permission.FOREGROUND_SERVICE\"", " \n"); @@ -4259,6 +4425,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + remoteControlService + hceService + carAppService + + wearableListenerService + surfacesManifestEntries + " \n" + " \n" @@ -4736,14 +4903,14 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " public static final String LICENSE_KEY = \"" + xorEncode(licenseKey) + "\";\n" + " String [] consumable = new String[]{" + consumable + "};\n" + " private static " + request.getMainClass() + "Stub stubInstance;\n" - + " private static " + request.getMainClass() + " i;\n" + + " private static " + appLifecycleClass(request) + " i;\n" + " private boolean running;\n" + " private" + firstTimeStatic + " boolean firstTime = true;\n" + " private Form currentForm;\n" + " private static final Object LOCK = new Object();\n" + additionalMembers + headphonesVars - + " public static " + request.getMainClass() + " getAppInstance() {\n" + + " public static " + appLifecycleClass(request) + " getAppInstance() {\n" + " return i;\n" + " }\n\n" + activityBillingSource @@ -4803,7 +4970,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + reinitCode + " }\n" + " if (i == null) {\n" - + " i = new " + request.getMainClass() + "();\n" + + " i = new " + appLifecycleClass(request) + "();\n" + " if(i instanceof PushCallback) {\n" + " com.codename1.impl.CodenameOneImplementation.setPushCallback((PushCallback)i);\n" + " }\n"; @@ -5025,7 +5192,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " public PushCallback getPushCallbackInstance() {\n" + " if(" + handlePushImmediatelyCheck + ") {\n" + " " + request.getMainClass() + "Stub stub = " + request.getMainClass() + "Stub.getInstance();\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " return (PushCallback)main;\n" + " }\n" @@ -5144,7 +5311,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " if (intent.getStringExtra(\"error\") != null) {\n" + " final String error = intent.getStringExtra(\"error\");\n" + " System.out.println(\"Push handleRegistration() error: \" + error);\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " Display.getInstance().callSerially(new Runnable() {\n" + " public void run() {\n" @@ -5161,7 +5328,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " Preferences.set(\"push_key\", registration);\n" + " editor.commit();\n" + " com.codename1.impl.android.AndroidImplementation.registerPushOnServer(registration, d(BUILT_BY_USER) + '/' + PACKAGE_NAME, (byte)1, \"\", \"" + request.getPackageName() + "\");\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " Display.getInstance().callSerially(new Runnable() {\n" + " public void run() {\n" @@ -5198,7 +5365,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " System.out.println(\"Is running: \" + " + request.getMainClass() + "Stub.isRunning());\n" + " if(" + handlePushImmediatelyCheck +") {\n" + " " + request.getMainClass() + "Stub stub = " + request.getMainClass() + "Stub.getInstance();\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " Display.getInstance().setProperty(\"pushType\", messageType);\n"; @@ -5560,6 +5727,18 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { if (legacyGplayServicesMode) { additionalDependencies += " "+compile+" 'com.google.android.gms:play-services:6.5.87'\n"; + if (playServicesWear) { + // The 6.5.87 monolith predates the Wearable Data Layer split, so it carries no + // MessageClient/DataClient -- but it DOES carry older copies of the shared wearable + // classes, so adding the modern artifact beside it produces duplicate classes at + // dex time rather than a working build. There is no combination of the two that + // works, so say which setting to drop instead of failing later and obscurely. + throw new BuildException("android.includeGPlayServices=true pins the legacy " + + "play-services 6.5.87 bundle, which predates the Wearable Data Layer and " + + "conflicts with the modern play-services-wearable that " + + "com.codename1.wearable needs. Remove android.includeGPlayServices to " + + "build the wearable API, or remove the com.codename1.wearable usage."); + } } else { if(playServicesPlus){ additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-plus:"+getDefaultPlayServiceVersion("plus")+"'\n"; @@ -7242,6 +7421,36 @@ private void initPlayServiceVersions(BuildRequest request) { } } + /** + * Whether the legacy {@code android.wear} hints put this build in Wear mode. + * + *

Package-private for direct unit testing; not part of the builder API. Extracted because + * the relationship between the two hints is directional and easy to invert: + * {@code android.wear} implied standalone, but {@code android.wear.standalone} is a SUB-hint + * that only ever applied inside {@code android.wear=true} and never implied Wear on its own. + * Getting that backwards gives a legacy phone project the API 23 floor and a required + * {@code android.hardware.type.watch} feature, and Play filters the APK off every phone.

+ */ + static boolean legacyWearMode(String androidWear) { + return "true".equals(androidWear); + } + + /** + * Whether the legacy hints ask for a standalone (phone-less) Wear app. + * + *

Only meaningful in Wear mode. {@code android.wear=true} implied standalone, so the + * sub-hint reads as an explicit opt-OUT: an empty or absent value keeps the historical + * standalone behaviour, and only {@code false} turns it off, which is what lets a project that + * deliberately configured a companion app stay a companion.

+ */ + static boolean legacyWearStandalone(String androidWear, String androidWearStandalone) { + if (!legacyWearMode(androidWear)) { + return false; + } + String optOut = androidWearStandalone == null ? "" : androidWearStandalone.trim(); + return !"false".equals(optOut); + } + // Package-private for direct unit testing; this is not part of the builder API. static String ensureCompileSdkAtLeastTarget(String compileSdkVersion, String targetSdkVersion) { Integer compileSdkInt = parseSdkInt(compileSdkVersion); 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 009e26848b1..30feca436f8 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 @@ -61,9 +61,9 @@ public class IPhoneBuilder extends Executor { // that is an implementation detail -- never surfaced in hint names. private final MacNativeBuilder macNativeBuilder = new MacNativeBuilder(this); - // watchNative.* delegate: adds an Apple Watch (watchOS) target rendered via - // the Core Graphics backend. Like macNativeBuilder this is inert unless the - // watchNative.enabled hint is set, keeping the iOS build unchanged. + // Watch delegate: adds an Apple Watch (watchOS) target rendered via the Core + // Graphics backend. Like macNativeBuilder this is inert unless the project + // declares a codename1.watchMain, keeping the iOS build unchanged. private final WatchNativeBuilder watchNativeBuilder = new WatchNativeBuilder(this); // tvNative.* delegate: adds an Apple TV (tvOS) target. tvOS is handled like @@ -135,6 +135,46 @@ private static String trimToNull(String v) { /// them the generated factory can actually construct. private final HealthListenerScan healthScan = new HealthListenerScan(); + /// Whether this project uses HealthKit -- scanner-detected calls, or a capability the project + /// asked for explicitly. + /// + /// The privacy strings are deliberately NOT evidence: a project can retain an + /// `ios.NSHealth*UsageDescription` hint long after the code that needed it is gone, and + /// entitling on that basis fails codesigning against an App ID with no HealthKit capability. + /// The capability hints ARE evidence, because asking for background delivery or recalibrated + /// estimates is asking for HealthKit -- and they are how a project whose health access lives in + /// native code declares it at all, where the bytecode scan sees nothing. + /// + /// This is what both the phone entitlement decision and the watch target's + /// CODE_SIGN_ENTITLEMENTS read, so the two slices of one app cannot disagree about whether the + /// app uses HealthKit. + boolean phoneUsesHealthData(BuildRequest request) { + return usesHealthRead || usesHealthWrite || usesHealthWorkout + // The parent entitlement asked for outright. Enumerating only the two + // sub-capabilities missed the plainest declaration of all: a project with native + // health code that says com.apple.developer.healthkit=true and supplies its purpose + // string. The phone kept the entitlement it was handed and the watch, signed + // independently, went without it. + || "true".equalsIgnoreCase(request.getArg( + "ios.entitlements.com.apple.developer.healthkit", "false")) + || healthCapabilityRequested(request, "ios.health.backgroundDelivery", + "background-delivery") + || healthCapabilityRequested(request, "ios.health.recalibrateEstimates", + "recalibrate-estimates"); + } + + /// A HealthKit sub-capability requested under either spelling: the short alias, or the + /// canonical entitlement key written out in full. The generic renderer emits whatever is in the + /// `ios.entitlements.*` namespace, so a project that used the long spelling got its + /// sub-capability emitted while a gate reading only the aliases left the parent entitlement + /// off -- the unsignable set the aliases exist to avoid, reached by the other spelling. + private static boolean healthCapabilityRequested(BuildRequest request, String alias, + String suffix) { + return "true".equalsIgnoreCase(request.getArg(alias, "false")) + || "true".equalsIgnoreCase(request.getArg( + "ios.entitlements.com.apple.developer.healthkit." + suffix, "false")); + } + private boolean usesHealthRead; private boolean usesHealthWrite; private boolean usesHealthWorkout; @@ -161,6 +201,23 @@ private static String trimToNull(String v) { private boolean surfacesLiveActivities; private final List surfacesKinds = new ArrayList(); + // Set when the app references com.codename1.wearable.* (the phone-to-watch link). Gates the + // CN1_USE_WATCHCONNECTIVITY native define and WatchConnectivity.framework linkage on both the + // phone target and the watch target -- WCSession is symmetric, so both halves of a pair need + // it. Apps that never touch the API see no change. + private boolean usesWearable; + + /** + * Whether the API scan saw {@code com.codename1.wearable}. + * + *

Package-private for {@link WatchNativeBuilder}, which links WatchConnectivity onto the + * watch target it generates. The phone target gets the framework through {@code addLibs}, but + * that list is consumed before the watch target exists, so the watch half has to ask.

+ */ + boolean usesWearable() { + return usesWearable; + } + private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -1107,6 +1164,12 @@ public void usesClass(String cls) { if (!usesSurfaces && cls.indexOf("com/codename1/surfaces/") == 0) { usesSurfaces = true; } + // Phone-to-watch link (com.codename1.wearable.*). Gated on actual usage + // so WatchConnectivity.framework and the CN1_USE_WATCHCONNECTIVITY + // natives are only added for apps that talk to their watch app. + if (!usesWearable && cls.indexOf("com/codename1/wearable/") == 0) { + usesWearable = true; + } // OidcClient + SystemBrowser rely on // ASWebAuthenticationSession (AuthenticationServices.framework, // iOS 12+). @@ -2510,6 +2573,15 @@ public void usesClassMethod(String cls, String method) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_WIDGETS", "#define CN1_USE_WIDGETS"); } + // com.codename1.wearable usage compiles the WatchConnectivity glue (gated by + // CN1_USE_WATCHCONNECTIVITY so other builds carry no WCSession symbols). The define + // lives in the shared CodenameOne_GLViewController.h so it reaches every wearable + // translation unit, and unlike the widgets define it deliberately survives on the watch + // slice: both halves of a pair run the same symmetric code. + if (usesWearable) { + replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_WATCHCONNECTIVITY", "#define CN1_USE_WATCHCONNECTIVITY"); + } + String glAppDelegeateBody = request.getArg("ios.glAppDelegateBody", null); if (glAppDelegeateBody != null && glAppDelegeateBody.length() > 0) { replaceInFile(glAppDelegate, "//GL_APP_DELEGATE_BODY", glAppDelegeateBody); @@ -2958,20 +3030,11 @@ public void usesClassMethod(String cls, String method) { // com.apple.developer.healthkit off. That is the same // unsignable entitlement set the aliases were fixed to // avoid, reachable by the other spelling. - boolean entitleHealthKit = usesHealthRead || usesHealthWrite - || usesHealthWorkout - || "true".equalsIgnoreCase(request.getArg( - "ios.health.backgroundDelivery", "false")) - || "true".equalsIgnoreCase(request.getArg( - "ios.health.recalibrateEstimates", "false")) - || "true".equalsIgnoreCase(request.getArg( - "ios.entitlements.com.apple.developer" - + ".healthkit.background-delivery", - "false")) - || "true".equalsIgnoreCase(request.getArg( - "ios.entitlements.com.apple.developer" - + ".healthkit.recalibrate-estimates", - "false")); + // The one expression, shared with the watch builder. Two copies of it came apart + // once already: the watch read the scanner flags alone, so a project whose health + // access is in native code -- declared only through the capability hints -- got an + // entitled phone and an unentitled watch. + boolean entitleHealthKit = phoneUsesHealthData(request); String healthKitEntitlement = request.getArg( "ios.entitlements.com.apple.developer.healthkit", null); @@ -3134,6 +3197,19 @@ public void usesClassMethod(String cls, String method) { // Apple per app category, so we only inject the ones the project opts into via the // ios.carplay. build hints; the binary references CarPlay symbols (gated by // CN1_USE_CARPLAY) which is why the framework is linked here in lockstep with the scan. + // The phone-to-watch link references WCSession (gated by CN1_USE_WATCHCONNECTIVITY), so + // link WatchConnectivity.framework in lockstep with the scan. It exists on both iOS and + // watchOS, which is why it is a plain link rather than one of the watch slice's + // weak-linked frameworks. + if (usesWearable) { + String wearableLib = "WatchConnectivity.framework"; + if (addLibs == null || addLibs.length() == 0) { + addLibs = wearableLib; + } else if (!addLibs.toLowerCase().contains("watchconnectivity.framework")) { + addLibs = addLibs + ";" + wearableLib; + } + } + if (usesCar) { String carPlayLibs = "CarPlay.framework;MediaPlayer.framework"; if (addLibs == null || addLibs.length() == 0) { @@ -3491,18 +3567,40 @@ public void usesClassMethod(String cls, String method) { parparCmd.add("-DINCLUDE_NPE_CHECKS=" + includeNullChecks); parparCmd.add("-Dcn1.onDeviceDebug=" + onDeviceDebug); parparCmd.add("-DbundleVersionNumber=" + bundleVersionNumber); + // The UNION of every enabled product's list, in ONE argument. These used to be + // mutually exclusive branches on the claim that the Mac list already covered the + // others; it does not -- the watch additionally needs Metal, MapKit, WebKit, + // StoreKit, CarPlay and SceneKit weak-linked. With macNative and a companion watch + // both enabled, the phone archive builds the watch target as a dependency and its + // link failed on symbols nobody had marked optional. Only one + // -Doptional.frameworks can take effect, so the lists are merged rather than added + // twice. Weak-linking a framework a slice does not need costs that slice nothing, + // which is why the union is safe. + java.util.LinkedHashSet optionalFrameworks = + new java.util.LinkedHashSet(); if (macNativeBuilder.isEnabled()) { - parparCmd.add(macNativeBuilder.parparvmOptionalFrameworksArg()); - } else if (watchNativeBuilder.isEnabled()) { - // Weak-link the watch-incompatible frameworks so the shared - // sources link on both the iOS app target and the watch - // target. (macNative already widens the set when both apply.) - parparCmd.add(watchNativeBuilder.parparvmOptionalFrameworksArg()); - } else if (tvNativeBuilder.isEnabled()) { - // Weak-link the tvOS-incompatible frameworks (OpenGL ES, GLKit, - // WebKit, MessageUI, AddressBook) so the shared sources link on - // both the iOS app target and the tvOS target. - parparCmd.add(tvNativeBuilder.parparvmOptionalFrameworksArg()); + collectOptionalFrameworks(optionalFrameworks, + macNativeBuilder.parparvmOptionalFrameworksArg()); + } + if (watchNativeBuilder.isEnabled()) { + collectOptionalFrameworks(optionalFrameworks, + watchNativeBuilder.parparvmOptionalFrameworksArg()); + } + if (tvNativeBuilder.isEnabled()) { + collectOptionalFrameworks(optionalFrameworks, + tvNativeBuilder.parparvmOptionalFrameworksArg()); + } + if (!optionalFrameworks.isEmpty()) { + StringBuilder frameworksArg = new StringBuilder("-Doptional.frameworks="); + boolean firstFramework = true; + for (String framework : optionalFrameworks) { + if (!firstFramework) { + frameworksArg.append(';'); + } + frameworksArg.append(framework); + firstFramework = false; + } + parparCmd.add(frameworksArg.toString()); } // Pass through extra translator JVM options (notably a larger // -Xmx) from the CN1_TRANSLATOR_OPTS environment variable. The @@ -4869,6 +4967,28 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui throw new BuildException("surfaces.json declares neither widget kinds nor " + "\"liveActivities\": true; there is nothing to build"); } + // Whether anything in this manifest can appear on iOS, decided HERE -- before the app + // group, CN1_USE_WIDGETS and the app-target Swift glue are gated on + // surfacesExtensionEnabled. Turning the flag off later, at the point the extension target + // would have been generated, was too late: the host app had already been given an + // application-groups entitlement and compiled widget support for an extension that is + // never produced, and a release profile without that group then fails code signing. + boolean anyIosSurface = surfacesLiveActivities; + for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { + if (!IOSWidgetExtensionBuilder.isWatchOnly(kind)) { + anyIosSurface = true; + break; + } + } + if (!anyIosSurface) { + log("[surfaces] Every declared kind is a watch complication family, so no iOS " + + "extension is generated and the iOS surface lowering is skipped entirely " + + "-- no app group, no widget support compiled into the app."); + surfacesExtensionEnabled = false; + // Nothing further to prepare, and in particular no xcodeproj gem to require: that + // check exists for wiring an extension into the project, and there is no extension. + return; + } // The extension is wired into the Xcode project through the ruby xcodeproj gem; // fail early with a friendly message when it is missing. ensureXcodeprojInstalled(); @@ -4893,6 +5013,36 @@ private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { widgetBuilder.addKind(kind); } + // Named out loud, every time, whether or not the extension is generated. A watch-only kind + // is silently dropped from the iOS bundle and NOTHING else emits it -- there is no watchOS + // widget extension target and no Wear complication data source yet -- so a developer who + // declares one and says nothing about it gets no surface on any platform and no clue why. + // The limitation is documented in the Wearables guide; this is the build-time half of it. + StringBuilder watchOnly = new StringBuilder(); + for (IOSWidgetExtensionBuilder.Kind watchKind : surfacesKinds) { + if (IOSWidgetExtensionBuilder.isWatchOnly(watchKind)) { + if (watchOnly.length() > 0) { + watchOnly.append(", "); + } + watchOnly.append(watchKind.getId()); + } + } + if (watchOnly.length() > 0) { + log("[surfaces] NOTE: these kinds declare only watch complication families and will " + + "NOT appear on any device in this build: " + watchOnly + ". The watchOS " + + "widget extension target and the Wear OS complication data source are not " + + "generated yet. Declare a phone family alongside them if you need a surface " + + "today."); + } + if (!widgetBuilder.hasIosSurface()) { + // Every declared kind is a watch complication and there is no live activity, so the iOS + // extension would host nothing -- and a WidgetBundle with an empty body does not compile. + // Declaring only complications is legitimate; it simply produces no iOS surface until the + // watchOS extension target exists, so skip the extension instead of failing the build. + log("Skipping the WidgetKit extension target: surfaces.json declares only watch " + + "complication families, which the iOS extension cannot host"); + return; + } String extensionName = widgetBuilder.getExtensionName(); File extensionDir = new File(distDir, extensionName); IOSWalletExtensionBuilder.writeFileMap(widgetBuilder.buildFileMap(), extensionDir); @@ -6221,4 +6371,21 @@ static String plistEscape(String value) { } return value.replace("&", "&").replace("<", "<").replace(">", ">"); } + + /// Splits a {@code -Doptional.frameworks=a;b;c} argument into {@code set}, so several products' + /// lists can be merged into the single argument the translator honours. + static void collectOptionalFrameworks(java.util.Set set, String arg) { + if (arg == null) { + return; + } + int eq = arg.indexOf('='); + String list = eq < 0 ? arg : arg.substring(eq + 1); + for (String framework : list.split(";")) { + String trimmed = framework.trim(); + if (trimmed.length() > 0) { + set.add(trimmed); + } + } + } + } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java index 78f906090a9..9e4d7e86c40 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java @@ -672,6 +672,14 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" if base == 'OpenGLES.framework'\n") .append(" removed_refs << ref\n") .append(" bf.remove_from_project\n") + .append(" elsif base == 'WatchConnectivity.framework'\n") + // WatchConnectivity does not exist on Mac Catalyst -- CN1WatchConnectivity.h + // already compiles its code out there via !TARGET_OS_MACCATALYST -- but the + // framework REFERENCE stayed in the shared phase, so the Catalyst slice linked + // against something the macOS SDK does not ship. Same treatment as OpenGLES: + // out of the unconditional phase, back in for the iOS SDKs below. + .append(" removed_refs << ref\n") + .append(" bf.remove_from_project\n") .append(" elsif base == 'GLKit.framework'\n") .append(" bf.settings ||= {}\n") .append(" attrs = (bf.settings['ATTRIBUTES'] || []).dup\n") @@ -691,6 +699,15 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" bs['OTHER_LDFLAGS[sdk=iphoneos*]'] = existing + ' -framework OpenGLES'\n") .append(" existing_sim = bs['OTHER_LDFLAGS[sdk=iphonesimulator*]'] || '$(inherited)'\n") .append(" bs['OTHER_LDFLAGS[sdk=iphonesimulator*]'] = existing_sim + ' -framework OpenGLES'\n") + // Only when the app actually uses the wearable API, so a project that does not + // link nothing extra -- and unconditionally safe either way, since the flag is + // scoped to the iOS SDKs the framework exists on. + .append(owner.usesWearable() + ? " bs['OTHER_LDFLAGS[sdk=iphoneos*]'] = " + + "bs['OTHER_LDFLAGS[sdk=iphoneos*]'] + ' -framework WatchConnectivity'\n" + + " bs['OTHER_LDFLAGS[sdk=iphonesimulator*]'] = " + + "bs['OTHER_LDFLAGS[sdk=iphonesimulator*]'] + ' -framework WatchConnectivity'\n" + : "") .append(" bs['DEAD_CODE_STRIPPING[sdk=macosx*]'] = 'YES'\n") .append("end\n"); s.append("xcproj.save\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 a25ad91331d..4ca3bfd0cbe 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 @@ -30,40 +30,45 @@ /** * Helper extracted from {@link IPhoneBuilder} that owns the Apple Watch - * (watchOS) native build path. Activated by the build hint {@code - * watchNative.enabled=true}. + * (watchOS) native build path. Activated by the project declaring a watch + * lifecycle class, {@code codename1.watchMain}; there are no other watch build + * hints, everything else is derived. * *

Unlike {@link MacNativeBuilder} (which Mac-Catalyst-slices the SAME iOS app * target), a watchOS app is a distinct product: it has its own bundle, its own * {@code WKApplication} Info.plist, and the {@code arm64_32} architecture. So * this builder adds a second Xcode target to the generated project, - * compiles the shared ParparVM-generated sources (minus the GL/Metal-only files) - * for watchOS, and - in the default {@code companion} distribution - embeds the - * watch app inside the iOS {@code .app} via an "Embed Watch Content" copy-files - * phase. The watch UI is rendered by the Core Graphics backend - * ({@code CN1CGGraphics} + {@code CN1WatchRenderingView}) driven by + * compiles the ParparVM-generated sources (minus the GL/Metal-only files) for + * watchOS, and embeds the watch app inside the iOS {@code .app} via an "Embed + * Watch Content" copy-files phase so the pair installs together. A project that + * sets {@code codename1.watchStandalone=true} ships a watch-only product with no + * paired phone app instead. The watch UI is rendered by the Core Graphics + * backend ({@code CN1CGGraphics} + {@code CN1WatchRenderingView}) driven by * {@code CN1WatchHost}. * *

The underlying mechanism is a Ruby {@code xcodeproj} script (same toolchain * macNative relies on). Like {@link MacNativeBuilder} this is a delegate owned * by {@link IPhoneBuilder}, invoked at hint-parse time and at the - * post-project-generate patching point. Every change is additive: with the hint - * off, the iOS build is byte-for-byte unchanged. + * post-project-generate patching point. Every change is additive: without a + * {@code watchMain} the iOS build is byte-for-byte unchanged. */ class WatchNativeBuilder { private final IPhoneBuilder owner; - // Parsed hints. + // watchOS floor: single-target WKApplication apps, WidgetKit complications, + // and the SwiftUI onChange(of:) two-parameter API the generated + // CN1WatchRootView uses. + private static final String MIN_DEPLOYMENT_TARGET = "10.0"; + + // Derived build state. private boolean enabled; - private String distribution; // companion | standalone + private boolean standalone; // codename1.watchStandalone private String bundleId; - private String minDeploymentTarget; // WATCHOS_DEPLOYMENT_TARGET private String teamId; private String displayName; - // Fully-qualified watch lifecycle entry class (codename1.watchMain). May - // equal the phone main class; a distinct value lets the watch slice tree- - // shake from its own root. Empty when neither watchMain nor an explicit - // watchNative.mainClass hint is set (then we fall back to the phone main). + // Fully-qualified watch lifecycle entry class (codename1.watchMain). Its + // presence is what turns the watch build on, and it is the root the watch + // slice is translated from. Empty when the project declares no watch app. private String watchMain; // Whether the watch shakes from its own root rather than the phone's. // A distinct root means the phone's health usage says nothing about @@ -76,36 +81,15 @@ class WatchNativeBuilder { // decision can read it too -- a workout session is HealthKit. private String workoutProcessingHint; - // GL/Metal-only source files with no watchOS substitute. Excluded from the - // watch target; the CG backend (CN1CGGraphics/CN1WatchRenderingView) and the - // per-op TARGET_OS_WATCH branches replace them. Kept in sync with - // Ports/iOSPort/nativeSources/WATCHOS_PORT.md. + // Files the watch target cannot take at all -- not a policy list, a mechanical + // one. Everything that CAN be guarded is guarded in the source instead, with + // `#if !TARGET_OS_WATCH` wrapping the whole file, so a new GL/Metal/UIKit + // source carries its own exclusion and cannot silently break the watch build + // by being forgotten here. These five have no preprocessor to run: + // a .metal shader is compiled by the Metal compiler (absent on watchOS) and a + // .xib is Interface Builder data. private static final String[] EXCLUDED_WATCH_SOURCES = { - "EAGLView.m", "METALView.m", - "CN1ES1compat.m", "CN1ES2compat.m", "CN1GL3D.m", - "CN1Metalcompat.m", "CN1MetalGlyphAtlas.m", "CN1MetalPipelineCache.m", "CN1MetalShaders.metal", - "DrawGradientTextureCache.m", "DrawStringTextureCache.m", - "CodenameOne_GLSceneDelegate.m", - // The UIApplication delegate is UIApplication/UIApplicationMain based - // (unavailable on watchOS) and is replaced by the SwiftUI @main shell - // (CN1WatchApp.swift) / CN1WatchHost. CodenameOne_GLViewController.m is - // NOT excluded: it carries the shared CGContext/op-based graphics - // primitives (createImage, fonts, the *Impl drawing entry points) that - // the watch slice reuses. Its UIViewController class + UIKit event code - // are guarded with #if !TARGET_OS_WATCH, and the watch render-driver - // class (CodenameOne_GLViewController as an NSObject) lives in - // CN1WatchViewController.m. - "CodenameOne_GLAppDelegate.m", - // UIWebView-based legacy browser peer (UIWebView + UIApplication - // networkActivityIndicator are unavailable on watchOS). - "UIWebViewEventDelegate.m", - // UIKit peer components unavailable on watchOS: tap gesture - // (UIGestureRecognizer), inline text editors (UITextField/UITextView), - // and the low-level AudioQueue recorder (AudioToolbox). Their headers - // are empty under #if !TARGET_OS_WATCH so importers still compile. - "CN1TapGestureRecognizer.m", "CN1UITextField.m", "CN1UITextView.m", - "CN1AudioUnit.m", "CodenameOne_GLViewController.xib", "MainWindow.xib", "CodenameOne_METALViewController.xib", "MainWindowMETAL.xib" }; @@ -134,55 +118,67 @@ boolean isEnabled() { } /** - * Parse the {@code watchNative.*} hint family. Caller flips Metal on (the - * watch slice cannot use GL ES; the iOS slice still wants Metal) and raises - * the watch deployment floor. + * Resolve the watch build from the project's entry points. The watch app is + * built whenever the project declares a watch lifecycle class + * ({@code codenameone_settings.properties -> codename1.watchMain}, arriving + * here as the {@code watchMain} argument); everything else is derived. The + * only other recognized setting is {@code codename1.watchStandalone}, which + * says the watch app ships on its own rather than inside the phone app -- + * the one thing that cannot be inferred from the project. + * + *

Caller flips Metal on (the watch slice cannot use GL ES; the iOS slice + * still wants Metal) and raises the watch deployment floor. */ void parseHints(BuildRequest request) { - // The watch slice auto-enables when the project declares a watchMain - // entry point (codenameone_settings.properties -> codename1.watchMain), - // so the double app is produced seamlessly as part of the regular iPhone - // build. watchNative.enabled=true forces it on even without a distinct - // watchMain (the watch then shares the phone main class). - watchMain = request.getArg("watchMain", - request.getArg("watchNative.mainClass", "")).trim(); - enabled = "true".equals(request.getArg("watchNative.enabled", "false")) - || watchMain.length() > 0; - if (!enabled) { - return; - } + watchMain = request.getArg("watchMain", "").trim(); + // Read before the enablement check, deliberately: the HealthKit entitlement decision + // consults these even for a project that declares no watch app, and returning early first + // would silently turn an explicit watchNative.health=false back into inference. + // getMainClass() is the SIMPLE class name while watchMain is fully qualified, so comparing + // them directly marked every project as having a distinct watch root -- including one whose + // watchMain names the very same class. That matters because "distinct" is what tells the + // HealthKit inference it cannot read the watch's usage from the phone's privacy strings. + String phoneMainFqn = request.getPackageName() == null || request.getPackageName().isEmpty() + ? request.getMainClass() + : request.getPackageName() + "." + request.getMainClass(); distinctWatchMain = watchMain.length() > 0 - && !watchMain.equals(request.getMainClass()); - if (watchMain.length() == 0) { - // No distinct watch entry: reuse the phone main class as the watch - // lifecycle root. - watchMain = request.getMainClass(); - } + && !watchMain.equals(request.getMainClass()) + && !watchMain.equals(phoneMainFqn); healthHint = request.getArg("watchNative.health", "").trim(); workoutProcessingHint = request.getArg( "watchNative.health.workoutProcessing", "false").trim(); - distribution = request.getArg("watchNative.distribution", "companion"); - bundleId = request.getArg("watchNative.bundleId", - request.getPackageName() + ".watchkitapp"); - // watchOS 10 is the floor: single-target WKApplication apps, WidgetKit - // complications, and the SwiftUI onChange(of:) two-parameter API the - // generated CN1WatchRootView uses. Lower only if the project explicitly - // asks (and adjusts the generated shell accordingly). - minDeploymentTarget = request.getArg("watchNative.minDeploymentTarget", "10.0"); - teamId = request.getArg("watchNative.teamId", - request.getArg("ios.release.teamId", - request.getArg("ios.teamId", - request.getArg("ios.debug.teamId", "")))); - displayName = request.getArg("watchNative.displayName", - request.getDisplayName() != null ? request.getDisplayName() : request.getMainClass()); + enabled = watchMain.length() > 0; + if (!enabled) { + return; + } + // Everything below is derived rather than hinted. The watchNative.* settings master used + // here (distribution, bundleId, minDeploymentTarget, teamId, displayName) are gone: the + // whole point of this change is that codename1.watchMain plus the optional + // codename1.watchStandalone are the entire surface, and the rest comes from settings the + // project already has. The health hints above are the exception -- they select an + // entitlement, which is not derivable from anything else. + standalone = "true".equals(request.getArg("watchStandalone", "false")); + bundleId = request.getPackageName() + ".watchkitapp"; + // Selected by BUILD TYPE, exactly as the phone target selects it. Preferring the release + // team unconditionally meant a debug build paired a debug provisioning profile with the + // release team's DEVELOPMENT_TEAM, and manual signing of the embedded watch target failed + // on the mismatch -- for a project that simply set both hints. + String watchTeamDefault = request.getArg("ios.teamId", ""); + teamId = "debug".equals(request.getArg("ios.buildType", "debug")) + ? request.getArg("ios.debug.teamId", watchTeamDefault) + : request.getArg("ios.release.teamId", watchTeamDefault); + displayName = request.getDisplayName() != null + ? request.getDisplayName() : request.getMainClass(); } - boolean isStandalone() { - return "standalone".equalsIgnoreCase(distribution); + /// The team id this build resolved for the watch target, selected by build type like the + /// phone's. Package-visible for the tests that pin that selection. + String getTeamId() { + return teamId; } - String getMinDeploymentTarget() { - return minDeploymentTarget; + boolean isStandalone() { + return standalone; } /** Fully-qualified watch lifecycle entry class. */ @@ -414,6 +410,177 @@ String parparvmOptionalFrameworksArg() { * WKCompanionAppBundleIdentifier} to the iOS app so the pair installs * together. */ + /** + * The marketing version the containing app will carry, reproducing {@code IPhoneBuilder}'s own + * derivation: the project version, reformatted to two decimal places when + * {@code ios.twoDigitVersion} asks for it. The watch app has to agree with the phone digit for + * digit, so this cannot simply read {@code request.getVersion()}. + * + * @param request the build request + * @return the version string, never null + */ + + /** + * The value {@code ios.plistInject} gives a key, or null when it does not set one. + * + *

Deliberately literal: the hint is a raw plist fragment, so this looks for + * {@code NAME} and takes the next {@code } that follows it. Anything more + * clever would be pretending to parse a document that is only ever a fragment.

+ */ + /// Every {@code } the {@code ios.plistInject} fragment sets. + /// + /// Deliberately literal, like {@link #injectedPlistString}: the hint is a fragment, not a + /// document, so this scans for the tags rather than pretending to parse XML. + static java.util.List injectedPlistKeys(BuildRequest request) { + java.util.List out = new java.util.ArrayList(); + String inject = request.getArg("ios.plistInject", null); + if (inject == null) { + return out; + } + int at = 0; + while (true) { + int open = inject.indexOf("", at); + if (open < 0) { + return out; + } + int close = inject.indexOf("", open); + if (close < 0) { + return out; + } + String key = inject.substring(open + "".length(), close).trim(); + if (key.length() > 0 && !out.contains(key)) { + out.add(key); + } + at = close + "".length(); + } + } + + static String injectedPlistString(BuildRequest request, String key) { + String inject = request.getArg("ios.plistInject", null); + if (inject == null) { + return null; + } + int at = inject.indexOf("" + key + ""); + if (at < 0) { + return null; + } + int open = inject.indexOf("", at); + if (open < 0) { + return null; + } + int close = inject.indexOf("", open); + if (close < 0) { + return null; + } + return decodeXmlEntities(inject.substring(open + "".length(), close).trim()); + } + + /// Turns the five predefined XML entities back into their characters. + /// + /// The value read out of {@code ios.plistInject} is SERIALIZED text: a disclosure written as + /// "Health &amp; Fitness" arrives with the entity intact, and re-emitting it through + /// {@code plistString} escapes the ampersand again -- so the phone shows the intended text + /// while the watch permission dialog shows "&amp;" literally. Decoding here puts the value + /// back into the plain form the escaper expects. + /// + /// {@code &amp;} is decoded LAST. Doing it first would turn "&amp;lt;" into "<" + /// rather than the literal "&lt;" the author wrote. + static String decodeXmlEntities(String value) { + if (value == null || value.indexOf('&') < 0) { + return value; + } + // ONE left-to-right pass, not a sequence of replaces. Chained replacements decode their own + // output: turning "&" into "&" first makes "&#38;" -- an author writing a literal + // "&" -- come out as "&", and no ordering of replaces fixes that in general. Scanning + // once consumes each reference exactly as written. + StringBuilder out = new StringBuilder(value.length()); + int i = 0; + while (i < value.length()) { + char c = value.charAt(i); + if (c != '&') { + out.append(c); + i++; + continue; + } + int end = value.indexOf(';', i + 1); + // A bare ampersand is not a reference; leave it exactly as the author wrote it. + if (end < 0 || end - i > 12) { + out.append(c); + i++; + continue; + } + String body = value.substring(i + 1, end); + String decoded = decodeReference(body); + if (decoded == null) { + out.append(c); + i++; + continue; + } + out.append(decoded); + i = end + 1; + } + return out.toString(); + } + + /// The character a reference body stands for, or null when it is not one this decoder knows. + /// + /// Numeric forms are included because they are ordinary XML: a purpose string written with + /// {@code &} is as valid as one written with {@code &}, and leaving it encoded put the + /// literal text in front of the user in the permission dialog. + private static String decodeReference(String body) { + if ("lt".equals(body)) { + return "<"; + } + if ("gt".equals(body)) { + return ">"; + } + if ("quot".equals(body)) { + return "\""; + } + if ("apos".equals(body)) { + return "'"; + } + if ("amp".equals(body)) { + return "&"; + } + if (body.length() < 2 || body.charAt(0) != '#') { + return null; + } + try { + int code = body.charAt(1) == 'x' || body.charAt(1) == 'X' + ? Integer.parseInt(body.substring(2), 16) + : Integer.parseInt(body.substring(1)); + if (code <= 0 || code > 0x10FFFF) { + return null; + } + return new String(Character.toChars(code)); + } catch (RuntimeException notANumber) { + return null; + } + } + + static String shortVersion(BuildRequest request) { + String version = request.getVersion(); + if (version == null || version.length() == 0) { + return "1.0"; + } + if (!"true".equals(request.getArg("ios.twoDigitVersion", "false"))) { + return version; + } + try { + int intVersion = Math.round(100 * Float.parseFloat(version)); + int lsb = intVersion % 100; + String out = "" + (intVersion / 100) + "."; + if (lsb == 0) { + return out + "00"; + } + return out + (lsb < 10 ? "0" + lsb : "" + lsb); + } catch (NumberFormatException notANumber) { + // The phone builder swallows this too and keeps the raw string. + return version; + } + } + void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOException { appSrcDir.mkdirs(); StringBuilder sb = new StringBuilder(); @@ -426,12 +593,34 @@ void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOExceptio plistString(sb, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"); plistString(sb, "CFBundleName", "$(PRODUCT_NAME)"); plistString(sb, "CFBundlePackageType", "$(PRODUCT_BUNDLE_PACKAGE_TYPE)"); - plistString(sb, "CFBundleShortVersionString", - request.getVersion() == null ? "1.0" : request.getVersion()); - plistString(sb, "CFBundleVersion", "1"); + // Apple's validation compares the embedded watch app's versions against the containing app's + // and rejects the archive when they differ, so both keys are derived exactly the way the + // phone derives them -- including the ios.twoDigitVersion reformatting and the + // ios.bundleVersion override -- rather than being pinned to a constant. + // ios.plistInject wins where it sets either key. It REPLACES the phone's default version + // injection rather than adding to it, so a project that overrides the version there ships a + // phone app whose version is not shortVersion(request) at all -- and an embedded watch app + // whose versions differ from its container is rejected by App Store validation, which is + // the one failure that only shows up at submission. + String injectedShort = injectedPlistString(request, "CFBundleShortVersionString"); + String watchShort = injectedShort != null ? injectedShort : shortVersion(request); + String injectedBundle = injectedPlistString(request, "CFBundleVersion"); + plistString(sb, "CFBundleShortVersionString", watchShort); + // The fallback stays shortVersion(request), NOT watchShort. The two keys are independent: + // the phone's CFBundleVersion is ios.bundleVersion defaulting to the build version, and it + // does not follow an injected marketing version -- so deriving the watch's from the + // injected short string produced the very mismatch this code exists to prevent (project + // 1.0 with an injected 9.9 gave phone 1.0 against watch 9.9). + plistString(sb, "CFBundleVersion", injectedBundle != null ? injectedBundle + : request.getArg("ios.bundleVersion", shortVersion(request))); // Modern single-target watch app marker. sb.append(" WKApplication\n \n"); - if (!isStandalone()) { + if (isStandalone()) { + // A standalone bundle must SAY it is watch-only, not merely omit the companion key. + // Without WKWatchOnly the bundle is neither tied to a containing iOS app nor declared + // independent, which installs unpredictably and can fail App Store validation. + sb.append(" WKWatchOnly\n \n"); + } else { plistString(sb, "WKCompanionAppBundleIdentifier", request.getPackageName()); } // HealthKit privacy strings. The watch slice has its own Info.plist and @@ -442,16 +631,57 @@ void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOExceptio // works this way. A whitespace-only hint used to emit a blank // purpose string that satisfied the check below, producing an // entitled watch bundle whose disclosure said nothing. - String healthShare = trimToNull(request.getArg( - "ios.NSHealthShareUsageDescription", null)); - if (healthShare != null) { - plistString(sb, "NSHealthShareUsageDescription", healthShare); + // EVERY privacy description the project declares, not only the HealthKit pair. A watch app + // that uses location, the microphone or motion needs its own purpose string in ITS bundle: + // the phone's plist does not cover it, so authorization fails or watchOS terminates the app + // when the API is exercised, on a project that configured the hint correctly. Collected the + // same way the phone builder collects them, from every ios.NS*UsageDescription argument. + // ONE map, from both sources, resolved before anything is written. An explicit argument + // wins over the same key in ios.plistInject, so nothing is emitted twice, and the checks + // further down read the same map rather than re-deriving a narrower view of it -- which is + // how the HealthKit validation came to reject a purpose string the plist already carried. + java.util.Map purposeStrings = new java.util.LinkedHashMap(); + for (String injectedKey : injectedPlistKeys(request)) { + if (injectedKey.startsWith("NS") && injectedKey.endsWith("UsageDescription")) { + String description = injectedPlistString(request, injectedKey); + if (description != null && description.length() > 0 + && !isPurposeStringOptOut(description)) { + purposeStrings.put(injectedKey, description); + } + } + } + for (String arg : request.getArgs()) { + if (arg.startsWith("ios.NS") && arg.endsWith("UsageDescription")) { + String description = trimToNull(request.getArg(arg, null)); + if (description != null && !isPurposeStringOptOut(description)) { + purposeStrings.put(arg.substring(arg.lastIndexOf('.') + 1), description); + } + } } - String healthUpdate = trimToNull(request.getArg( - "ios.NSHealthUpdateUsageDescription", null)); - if (healthUpdate != null) { - plistString(sb, "NSHealthUpdateUsageDescription", healthUpdate); + // The location FALLBACK too. ios.locationUsageDescription is a supported hint -- the phone + // builder even supplies one itself when it detects location use -- and the phone plist + // turns it into NSLocationWhenInUseUsageDescription later. The watch plist is written + // before that translation happens, so a loop over ios.NS* alone leaves the watch bundle + // with no purpose string while the project is configured correctly. + String locationFallback = trimToNull(request.getArg("ios.locationUsageDescription", null)); + if (locationFallback != null + && !purposeStrings.containsKey("NSLocationWhenInUseUsageDescription")) { + // Only when nothing else supplied that key -- from an argument OR from the injected + // fragment. Checking arguments alone let the fallback be emitted a second time under a + // key the injection had already set, which both duplicates the entry and lets a default + // overwrite the developer's own disclosure. + purposeStrings.put("NSLocationWhenInUseUsageDescription", locationFallback); } + for (java.util.Map.Entry purpose : purposeStrings.entrySet()) { + plistString(sb, purpose.getKey(), purpose.getValue()); + } + // Read again, not re-emitted: the HealthKit pair is already in the plist from the loop + // above, but the entitlement checks below need to know whether they were declared. + // From the resolved map, so a purpose string supplied through ios.plistInject counts. Read + // from arguments alone, this validation aborted the build over a key the plist it had just + // written did contain. + String healthShare = purposeStrings.get("NSHealthShareUsageDescription"); + String healthUpdate = purposeStrings.get("NSHealthUpdateUsageDescription"); // HKWorkoutSession keeps the app running while a workout records; // without this background mode watchOS suspends it mid-run. if ("true".equalsIgnoreCase(workoutProcessingHint)) { @@ -473,8 +703,12 @@ void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOExceptio + " the two hints.", new RuntimeException("contradictory watch health hints")); } - boolean watchHealth = - watchUsesHealth(healthShare != null || healthUpdate != null); + // The detected usage, not the purpose strings. A string can outlive the code that needed + // it, and treating it as evidence entitled the watch bundle for an app that no longer + // touches HealthKit -- which then failed codesigning against an App ID without the + // capability, with nothing in the output to say why. Same rule, same accessor, as the + // BuildDaemon mirror: a cloud build and a local build must reach the same verdict. + boolean watchHealth = watchUsesHealth(owner.phoneUsesHealthData(request)); boolean workoutProcessing = "true".equalsIgnoreCase(workoutProcessingHint); if (needsPurposeString(watchHealth, healthShare, healthUpdate, @@ -653,20 +887,17 @@ static String watchEntitlementsPlist(BuildRequest request, * The CODE_SIGN_ENTITLEMENTS setting for the watch target, or an empty * string when the watch does not use HealthKit. */ - private String watchEntitlementsSetting(BuildRequest request, + String watchEntitlementsSetting(BuildRequest request, String mainClass) { // The same gate the entitlements file itself uses. Pointing the // target at a file that is not written, or writing one the target // never signs with, are two different ways to be wrong. - // Trimmed, exactly as writeWatchInfoPlist trims them. A raw null - // check here saw a whitespace-only hint as health usage and - // pointed CODE_SIGN_ENTITLEMENTS at an entitlements file the plist - // pass had decided not to write, so Xcode failed on a missing - // file. - boolean phoneUsesHealth = trimToNull(request.getArg( - "ios.NSHealthShareUsageDescription", null)) != null - || trimToNull(request.getArg( - "ios.NSHealthUpdateUsageDescription", null)) != null; + // The SAME source of truth writeWatchInfoPlist uses -- detected usage, not the privacy + // strings. Resolving it twice from different inputs is how these two came apart: reading + // ios.NSHealth* here while the plist pass read the merged purpose strings meant a + // description supplied through ios.plistInject produced a bundle that declared HealthKit + // and was signed without the entitlement, so authorization failed on device. + boolean phoneUsesHealth = owner.phoneUsesHealthData(request); if (!watchUsesHealth(phoneUsesHealth)) { return ""; } @@ -675,6 +906,16 @@ private String watchEntitlementsSetting(BuildRequest request, + "-Watch.entitlements") + "'\n"; } + /// The established opt-out for a privacy hint: the phone's generic injector skips a usage + /// description whose value is exactly {@code false}, so a project suppresses a key the builder + /// would otherwise supply by setting it to that. Carried over verbatim, because the watch + /// treating it as an ordinary description put the literal word "false" in front of the user in + /// a watchOS permission prompt -- and in the HealthKit case that string is also what the + /// entitlement validation reads. + private static boolean isPurposeStringOptOut(String description) { + return "false".equals(description); + } + private static void plistString(StringBuilder sb, String key, String value) { sb.append(" ").append(key).append("\n ") .append(escapeXml(value)).append("\n"); @@ -723,7 +964,7 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) String watchTargetName = mainClass + "Watch"; String projectFile = new File(tmpFile, "dist/" + mainClass + ".xcodeproj").getAbsolutePath(); String infoPlistPath = mainClass + "-src/" + mainClass + "-Watch-Info.plist"; - String resolvedTeamId = owner.sanitizeTeamId(teamId, "watchNative.teamId"); + String resolvedTeamId = owner.sanitizeTeamId(teamId, "ios.teamId"); StringBuilder excluded = new StringBuilder(); for (String f : EXCLUDED_WATCH_SOURCES) { @@ -751,7 +992,7 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append("watch_target = xcproj.targets.find { |t| t.name == watch_name }\n") .append("if watch_target.nil?\n") .append(" watch_target = xcproj.new_target(:application, watch_name, :watchos, '") - .append(IPhoneBuilder.escapeRubyStr(minDeploymentTarget)).append("')\n") + .append(IPhoneBuilder.escapeRubyStr(MIN_DEPLOYMENT_TARGET)).append("')\n") .append("end\n") // Compile the shared ParparVM sources for the watch, minus the // GL/Metal-only files. Reuse the app target's compile sources so @@ -763,7 +1004,12 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" base = File.basename(ref.path)\n") .append(" next if excluded.include?(base)\n") .append(" unless watch_target.source_build_phase.files_references.include?(ref)\n") - .append(" watch_target.source_build_phase.add_file_reference(ref)\n") + .append(" added = watch_target.source_build_phase.add_file_reference(ref)\n") + // Carry the per-file COMPILER_FLAGS across, not just the reference. A cn1lib source + // that requires ARC is compiled with -fobjc-arc on the iOS target while the port + // itself builds with ARC off; copying the reference alone dropped that flag and the + // watch slice failed with "requires ARC (-fobjc-arc)". + .append(" added.settings = bf.settings.dup if added && bf.settings\n") .append(" end\n") .append("end\n") // Add the generated watch entry point (SwiftUI @main shell + @@ -785,9 +1031,15 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) // per-SDK so the simulator build doesn't try arm64_32 (whose // Swift stdlib slice doesn't exist -> 'Unable to find module Swift'). .append(" bs['ARCHS[sdk=watchos*]'] = 'arm64_32'\n") + // The watch SIMULATOR arch is left to ARCHS_STANDARD plus ONLY_ACTIVE_ARCH, so it + // follows the host: arm64 on Apple Silicon, x86_64 on Intel. This was pinned to + // arm64 because IOSSimd.m included unconditionally and an x86_64 slice + // could not satisfy it -- that has since been guarded (#if defined(__ARM_NEON)), so + // the pin now only serves to make the watch target unbuildable on an Intel host. .append(" bs['ARCHS[sdk=watchsimulator*]'] = '$(ARCHS_STANDARD)'\n") + .append(" bs['ONLY_ACTIVE_ARCH'] = 'YES'\n") .append(" bs['WATCHOS_DEPLOYMENT_TARGET'] = '") - .append(IPhoneBuilder.escapeRubyStr(minDeploymentTarget)).append("'\n") + .append(IPhoneBuilder.escapeRubyStr(MIN_DEPLOYMENT_TARGET)).append("'\n") .append(" bs['TARGETED_DEVICE_FAMILY'] = '4'\n") .append(" bs['PRODUCT_BUNDLE_IDENTIFIER'] = '") .append(IPhoneBuilder.escapeRubyStr(bundleId)).append("'\n") @@ -820,7 +1072,11 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(IPhoneBuilder.escapeRubyStr(mainClass)).append("-src/watchOSStubs'\n") .append(" bs['HEADER_SEARCH_PATHS[sdk=watchsimulator*]'] = '$(inherited) $(SRCROOT)/") .append(IPhoneBuilder.escapeRubyStr(mainClass)).append("-src/watchOSStubs'\n") - .append(" bs['SKIP_INSTALL'] = 'YES'\n"); + // A standalone watch app IS the product, so it must be installable; an embedded + // companion is carried inside the phone app and must not be. + .append(standalone + ? " bs['SKIP_INSTALL'] = 'NO'\n" + : " bs['SKIP_INSTALL'] = 'YES'\n"); if (resolvedTeamId != null && !resolvedTeamId.isEmpty()) { s.append(" bs['DEVELOPMENT_TEAM'] = '").append(resolvedTeamId).append("'\n"); } @@ -856,6 +1112,30 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" bf.remove_from_project if gl.include?(File.basename(ref.path))\n") .append("end\n"); + // WatchConnectivity is linked EXPLICITLY on the watch target when the app uses the wearable + // API, rather than left to the module auto-link above. + // + // IPhoneBuilder appends the framework to addLibs, and addLibs is consumed while generating + // the PHONE target -- the watch target is created here, afterwards, and copies sources and + // resources but not that list. What currently saves the link is CLANG_ENABLE_MODULES plus + // the '#import ' in CN1WatchConnectivity.h, which + // makes clang emit an autolink directive. That works, but it means the watch slice links a + // framework it never names: turn modules off, or reach WCSession through a header that does + // not import the umbrella, and the target compiles and then fails at link with no + // indication of which build setting withdrew the framework. + if (owner.usesWearable()) { + s.append("wc = xcproj.frameworks_group.files.find { |f| f.path && " + + "File.basename(f.path) == 'WatchConnectivity.framework' }\n") + .append("if wc.nil?\n") + .append(" wc = xcproj.frameworks_group.new_file(" + + "'System/Library/Frameworks/WatchConnectivity.framework')\n") + .append(" wc.source_tree = 'SDKROOT'\n") + .append("end\n") + .append("unless watch_target.frameworks_build_phase.files_references.include?(wc)\n") + .append(" watch_target.frameworks_build_phase.add_file_reference(wc)\n") + .append("end\n"); + } + // Mirror the iOS app's bundle resources into the watch target. The CN1 // runtime loads its theme + assets from the app bundle at runtime // (Resources.open(\"/iOS7Theme.res\"), the app theme.res / CN1Resource.res, @@ -863,11 +1143,20 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) // ships with an empty resources phase, so without this the watch app // can't find the native theme (falls back to the default look), the app // theme, or any bundled image/font -> wrong styling + missing images. - // Copying the iOS app-icon PNGs along too is harmless (the watch uses its - // own Info.plist icon set; the extra files are just ignored). + // Copying the iOS app-icon PNGs along too is harmless -- they are simply + // ignored, because watchOS takes its icon from an asset catalog + // (ASSETCATALOG_COMPILER_APPICON_NAME), not from Info.plist keys. // Skip iOS-only UI / icon assets: the asset catalog's AppIcon set has no // watch-applicable content (build error), and storyboards/xibs are the // iOS UI. The CN1 runtime resources (.res/.ttf/data) are what we need. + // + // Consequence, and it is deliberate rather than overlooked: the watch app + // therefore ships with no app icon. That does not affect building, running + // or testing -- only archiving for App Store submission, which Apple + // rejects without one. Generating a watch AppIcon catalog belongs with the + // watchOS widget-extension target, where there is a real archive to verify + // it against; until then the developer guide tells the reader to add an + // AppIcon set to the watch target before submitting. s.append("res_skip = %w[.xcassets .storyboard .xib]\n") .append("app_target.resources_build_phase.files.to_a.each do |bf|\n") .append(" ref = bf.file_ref\n") @@ -878,13 +1167,12 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" end\n") .append("end\n"); - // Companion embedding is opt-in (watchNative.embedCompanion=true) and OFF - // by default. Embedding adds the watch target as a build dependency of the - // iOS app, which makes building the iOS app also build the watch target. - // Remove any dependency/copy phase that Xcode or an older generator run - // left behind unless the project explicitly asks for companion packaging. - boolean embedCompanion = "true".equals(request.getArg("watchNative.embedCompanion", "false")); - if (!embedCompanion || isStandalone()) { + // A companion watch app is embedded in the iOS app so the pair installs + // together -- that is the whole point of declaring a watchMain next to a + // phone main, so it is not opt-in. A standalone watch app ships on its + // own instead, so strip any dependency/copy phase Xcode or an earlier + // generator run left behind. + if (isStandalone()) { s.append("app_target.dependencies.to_a.each do |dep|\n") .append(" proxy = dep.respond_to?(:target_proxy) ? dep.target_proxy : nil\n") .append(" remote = proxy && proxy.respond_to?(:remote_global_id) ? xcproj.objects_by_uuid[proxy.remote_global_id] : nil\n") @@ -903,6 +1191,19 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) // $(CONTENTS_FOLDER_PATH)/Watch and add a build dependency so the // pair archives together. s.append("app_target.add_dependency(watch_target)\n") + // Mac Catalyst builds this same app target for macOS, and macOS refuses to + // carry embedded watchOS content ("contains embedded content built for the + // watchOS platform, which is not allowed"). A platformFilter of ios keeps the + // dependency and the copy out of the Catalyst variant while leaving the iPhone + // build with its embedded watch app. + .append("app_target.dependencies.to_a.each do |dep|\n") + .append(" proxy = dep.respond_to?(:target_proxy) ? dep.target_proxy : nil\n") + .append(" remote = proxy && proxy.respond_to?(:remote_global_id) ? xcproj.objects_by_uuid[proxy.remote_global_id] : nil\n") + .append(" dep_target = dep.respond_to?(:target) ? dep.target : nil\n") + .append(" dep_target = remote if dep_target.nil?\n") + .append(" next unless dep_target && dep_target.respond_to?(:name) && dep_target.name == watch_name\n") + .append(" dep.platform_filter = 'ios' if dep.respond_to?(:platform_filter=)\n") + .append("end\n") .append("embed = app_target.build_phases.find { |p| p.respond_to?(:symbol_dst_subfolder_spec) && p.display_name == 'Embed Watch Content' }\n") .append("if embed.nil?\n") .append(" embed = app_target.new_copy_files_build_phase('Embed Watch Content')\n") @@ -913,6 +1214,9 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append("unless embed.files_references.include?(product)\n") .append(" bf = embed.add_file_reference(product)\n") .append(" bf.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }\n") + .append("end\n") + .append("embed.files.to_a.each do |bf|\n") + .append(" bf.platform_filter = 'ios' if bf.respond_to?(:platform_filter=)\n") .append("end\n"); } @@ -938,7 +1242,17 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) } owner.log("[watchNative] Added watchOS target " + watchTargetName + " (" + (isStandalone() ? "standalone" : "companion") + ", " - + "watchOS " + minDeploymentTarget + ", arm64_32)"); + + "watchOS " + MIN_DEPLOYMENT_TARGET + ", arm64_32)"); + if (isStandalone()) { + // Said out loud at build time. The target is detached from the phone app and + // installable, but the archive step still selects the phone scheme, so the IPA this + // build returns is the phone app -- and a developer who asked for a watch-only + // product would otherwise discover that by inspecting the artifact. + owner.log("[watchNative] NOTE: codename1.watchStandalone builds " + watchTargetName + + " as a detached, installable product, but this build archives the phone " + + "scheme. To submit the watch app, open the generated Xcode project and " + + "archive the " + watchTargetName + " scheme directly."); + } } catch (BuildException ex) { throw ex; } catch (Exception ex) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 7ed13995086..773ba453dc7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -613,6 +613,66 @@ private File getStringsJar() throws IOException { public static final String BUILD_TARGET_MAC_NATIVE = Executor.BUILD_TARGET_MAC_NATIVE; public static final String BUILD_TARGET_LINUX_NATIVE = Executor.BUILD_TARGET_LINUX_NATIVE; + /** + * The entry points a project can declare besides {@code codename1.mainName}, + * mapped to the build argument each one becomes. A project with a + * {@code codename1.watchMain} gets an Apple Watch and a Wear OS app built + * from that root; {@code codename1.tvMain} does the same for tvOS. The + * accompanying {@code codename1.watchStandalone} says the watch app ships on + * its own rather than alongside the phone app. + * + *

These ride the extensible build-argument map rather than the + * {@link BuildRequest} wire format, so adding an entry point needs no + * protocol change. + */ + private static final Map SECONDARY_ENTRY_POINTS; + static { + Map m = new LinkedHashMap(); + m.put("codename1.watchMain", "watchMain"); + m.put("codename1.watchStandalone", "watchStandalone"); + m.put("codename1.tvMain", "tvMain"); + SECONDARY_ENTRY_POINTS = Collections.unmodifiableMap(m); + } + + /** + * Copies the secondary entry points declared in the project settings onto a + * local {@link BuildRequest}. The cloud path does the equivalent by mirroring + * them into the {@code codename1.arg.} namespace of the uploaded settings + * file, so both paths hand the builders the same arguments. + * + * @param r the request being assembled + * @param props the project's codenameone_settings.properties + */ + private static void putSecondaryEntryPointArguments(BuildRequest r, Properties props) { + for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { + String value = props.getProperty(entry.getKey()); + if (value != null && value.trim().length() > 0) { + r.putArgument(entry.getValue(), value.trim()); + } + } + } + + /** + * Copies the secondary entry points into the {@code codename1.arg.} namespace + * of the settings file that is uploaded to the build server. + * + *

They are declared without that prefix because they sit next to + * {@code codename1.mainName} and that is the shape developers expect. The + * server, however, only lifts {@code codename1.arg.*} keys out of the + * uploaded file, so without this mirror a cloud build never learns that the + * project has a watch or TV app and silently produces neither. + * + * @param props the settings being prepared for upload, mutated in place + */ + static void mirrorSecondaryEntryPointsToBuildArgs(Properties props) { + for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { + String value = props.getProperty(entry.getKey()); + if (value != null && value.trim().length() > 0) { + props.setProperty("codename1.arg." + entry.getValue(), value.trim()); + } + } + } + private static boolean isLocalBuildTarget(String buildTarget) { if (buildTarget == null) { return false; @@ -882,6 +942,8 @@ private void createAntProject() throws IOException, LibraryPropertiesException, cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion); cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-maven-plugin", cn1MavenPluginVersion); + mirrorSecondaryEntryPointsToBuildArgs(cn1SettingsProps); + // App-extension provisioning profiles (e.g. the generated CN1Widgets WidgetKit // extension) are named by the codename1.ios.appext..provision setting, which // points at a local .mobileprovision file. Cloud builds have no folder to drop the @@ -1194,29 +1256,7 @@ private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File dist r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); String iconPath = props.getProperty("codename1.icon"); File iconFile = new File(iconPath); @@ -1456,29 +1496,7 @@ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); String iconPath = props.getProperty("codename1.icon"); File iconFile = new File(iconPath); @@ -1592,29 +1610,7 @@ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, Fil r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); r.setVendor(props.getProperty("codename1.vendor")); r.setType("windows"); @@ -1695,29 +1691,7 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); r.setVendor(props.getProperty("codename1.vendor")); r.setType("linux"); @@ -1771,29 +1745,7 @@ private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File d r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); String iconPath = props.getProperty("codename1.icon"); if (iconPath != null) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java index 436f098d78c..1c60d3fbc1d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java @@ -194,6 +194,20 @@ public IOSWidgetExtensionBuilder addKind(Kind kind) { */ public Map buildFileMap() throws IOException { validate(); + if (!hasIosSurface()) { + // Every declared kind is a watch complication and there is no live activity, so nothing + // would reach the bundle body -- and a WidgetBundle whose body holds no Widget expression + // does not compile. Callers check hasIosSurface() and skip the extension; reaching here + // means that check was missed, and failing loudly beats emitting Swift that breaks the + // whole iOS build. + // + // Deliberately here rather than in validate(): the APP-target glue is still wanted when + // the app publishes surfaces that only a watch can show, so buildAppTargetFileMap() must + // not be blocked by this. + throw new IllegalStateException("the iOS widget extension would be empty: every kind " + + "declares only watch complication families. Check hasIosSurface() before " + + "generating the extension"); + } LinkedHashMap map = new LinkedHashMap(); map.put("Info.plist", utf8(buildInfoPlist())); map.put(extensionName + ".entitlements", utf8(buildEntitlements())); @@ -243,10 +257,19 @@ private void validate() { } // WidgetBundleBuilder composes at most 10 widgets per bundle body; keeping the // generator single-bundle is simpler and 9 kinds is far beyond practical use. - if (kinds.size() > (liveActivitiesEnabled ? 9 : 10)) { + // Only the kinds that actually reach the bundle count against the limit. Watch-only kinds + // are skipped when it is generated, so counting them here would reject a manifest that + // produces a perfectly legal bundle -- ten complications plus one iOS widget is one widget. + int emitted = 0; + for (Kind kind : kinds) { + if (!isWatchOnly(kind)) { + emitted++; + } + } + if (emitted > (liveActivitiesEnabled ? 9 : 10)) { throw new IllegalStateException("surfaces.json declares more than " - + (liveActivitiesEnabled ? 9 : 10) + " widget kinds; a single WidgetBundle " - + "supports at most 10 widgets"); + + (liveActivitiesEnabled ? 9 : 10) + " widget kinds with an iOS surface; a " + + "single WidgetBundle supports at most 10 widgets"); } for (Kind kind : kinds) { if (kind.getId() == null || !isKindId(kind.getId())) { @@ -388,6 +411,9 @@ private String buildBundleSwift() { sb.append("struct CN1WidgetBundle: WidgetBundle {\n"); sb.append(" var body: some Widget {\n"); for (Kind kind : kinds) { + if (isWatchOnly(kind)) { + continue; + } sb.append(" ").append(structName(kind)).append("()\n"); } if (liveActivitiesEnabled) { @@ -396,6 +422,13 @@ private String buildBundleSwift() { sb.append(" }\n"); sb.append("}\n"); for (Kind kind : kinds) { + if (isWatchOnly(kind)) { + // Nothing to host it: the generated extension target is the iOS one, so a kind that + // declares only complication families has no surface here. Emitting it anyway would + // fall through to the default home-screen sizes and ship an iPhone widget the + // manifest never asked for. + continue; + } sb.append("\n"); sb.append("struct ").append(structName(kind)).append(": Widget {\n"); sb.append(" var body: some WidgetConfiguration {\n"); @@ -403,7 +436,24 @@ private String buildBundleSwift() { sb.append(" kind: \"").append(escapeSwift(kind.getId())).append("\",\n"); sb.append(" displayName: \"").append(escapeSwift(kind.getName())).append("\",\n"); sb.append(" description: \"").append(escapeSwift(kind.getDescription())).append("\",\n"); - sb.append(" families: [").append(familiesSwift(kind)).append("])\n"); + // .accessoryCorner exists only on watchOS, so the corner family is emitted behind a + // platform guard rather than in the shared list -- naming the symbol on iOS would not + // compile even in code that never runs. + String shared = familiesSwift(kind, false); + String watchOnly = watchOnlyFamiliesSwift(kind, false); + if (watchOnly.length() == 0) { + sb.append(" families: [").append(shared).append("])\n"); + } else { + sb.append("#if os(watchOS)\n"); + sb.append(" families: [").append(shared); + if (shared.length() > 0) { + sb.append(", "); + } + sb.append(watchOnly).append("])\n"); + sb.append("#else\n"); + sb.append(" families: [").append(shared).append("])\n"); + sb.append("#endif\n"); + } sb.append(" }\n"); sb.append("}\n"); } @@ -414,12 +464,12 @@ private static String structName(Kind kind) { return "CN1Widget_" + kind.getId(); } - private static String familiesSwift(Kind kind) { + private static String familiesSwift(Kind kind, boolean watchTarget) { List families = kind.getIosFamilies(); StringBuilder sb = new StringBuilder(); if (families != null) { for (String family : families) { - String mapped = mapFamily(family); + String mapped = mapFamily(family, watchTarget); if (mapped != null && sb.indexOf(mapped) < 0) { if (sb.length() > 0) { sb.append(", "); @@ -435,7 +485,23 @@ private static String familiesSwift(Kind kind) { return sb.toString(); } - private static String mapFamily(String family) { + /// The families that exist only on watchOS, emitted behind an os(watchOS) guard. + /// + /// Like the other watch families this is confined to a watch target: the corner complication has + /// no iOS surface, so emitting it -- and the platform guard that carries it -- into the iOS + /// extension would advertise something the manifest never asked for. + private static String watchOnlyFamiliesSwift(Kind kind, boolean watchTarget) { + if (!watchTarget) { + return ""; + } + List families = kind.getIosFamilies(); + if (families != null && families.contains("watchCorner")) { + return ".accessoryCorner"; + } + return ""; + } + + private static String mapFamily(String family, boolean watchTarget) { // Both the portable names (matching the core WidgetSize wire names) and the // WidgetKit-style spellings are accepted, so manifests written against either // naming in the docs resolve to the same families. @@ -451,10 +517,89 @@ private static String mapFamily(String family) { if ("lockscreen".equals(family) || "accessoryRectangular".equals(family)) { return ".accessoryRectangular"; } + // Watch complications. On Apple a complication is a WidgetKit widget in an accessory + // family, which is why they map here rather than through an API of their own. + // watchRectangular shares .accessoryRectangular with the lock screen -- the Swift renderer + // picks the more specific published layout when both exist. + // + // They belong to the watch flavour of the extension only. Mapping them into the iOS target + // would put a complication in front of the user as an iPhone lock-screen widget, which is + // not the surface the manifest asked for. + if (family.startsWith("watch") && !watchTarget) { + return null; + } + if ("watchCircular".equals(family)) { + return ".accessoryCircular"; + } + if ("watchRectangular".equals(family)) { + return ".accessoryRectangular"; + } + if ("watchInline".equals(family)) { + return ".accessoryInline"; + } + if ("watchCorner".equals(family)) { + // Emitted separately behind an os(watchOS) guard; see watchOnlyFamiliesSwift. + return null; + } // Unknown family names are skipped so newer manifests degrade gracefully. return null; } + /// True when the kind declares at least one watch complication family, which is what decides + /// whether the watch flavour of the extension is worth generating at all. + /// + /// @param kind the kind to inspect + /// @return true if the kind offers a complication + /// True when a kind declares complication families and nothing else, so the iOS extension has no + /// surface to offer it. Distinct from {@link #hasWatchFamily}, which is true for a kind that + /// offers both a phone widget and a complication. + /// + /// @param kind the kind to inspect + /// @return true if every declared family is a watch family + /// Whether the iOS widget extension would host anything at all: at least one kind with an iOS + /// family, or live activities. False means the extension should not be generated -- a project may + /// legitimately declare only watch complications, and that should produce no iOS surface rather + /// than a build failure. + /// + /// @return true if there is something for the iOS extension to show + public boolean hasIosSurface() { + if (liveActivitiesEnabled) { + return true; + } + for (Kind kind : kinds) { + if (!isWatchOnly(kind)) { + return true; + } + } + return false; + } + + public static boolean isWatchOnly(Kind kind) { + List families = kind.getIosFamilies(); + if (families == null || families.isEmpty()) { + return false; + } + for (String family : families) { + if (family != null && !family.startsWith("watch")) { + return false; + } + } + return true; + } + + public static boolean hasWatchFamily(Kind kind) { + List families = kind.getIosFamilies(); + if (families == null) { + return false; + } + for (String family : families) { + if (family != null && family.startsWith("watch")) { + return true; + } + } + return false; + } + private static void plistKeyString(StringBuilder sb, String key, String value) { sb.append(" ").append(escapeXml(key)).append("\n"); sb.append(" ").append(escapeXml(value)).append("\n"); diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift index 72a4c257e00..08b46f96bf0 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift @@ -1,3 +1,26 @@ +/* + * 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. + */ + // Auto-generated by Codename One from the com.codename1.surfaces framework. // Compiled ONLY into the CN1Widgets extension target (iOS 16.1+). Shared entry view + // configuration factory used by the generated per-kind widget structs. @@ -41,22 +64,52 @@ struct CN1WidgetEntryView: View { } } +/// Maps a WidgetKit family onto the Codename One size families, most specific first. +/// +/// The accessory families are shared between the iOS lock screen and the watch face, so +/// accessoryRectangular resolves to the watch layout when one was published and falls back to the +/// lock-screen layout otherwise -- an app that only publishes "lockscreen" still gets a +/// complication, and one that publishes both gets the layout it designed for each surface. func cn1LayoutForFamily(_ layouts: [String: Any], family: WidgetFamily) -> [String: Any]? { - let key: String + var keys: [String] switch family { case .systemSmall: - key = "small" + keys = ["small"] case .systemMedium: - key = "medium" + keys = ["medium"] case .systemLarge, .systemExtraLarge: - key = "large" - case .accessoryRectangular: - key = "lockscreen" + keys = ["large"] default: - key = "default" + keys = [] } - if let layout = layouts[key] as? [String: Any] { - return layout + if #available(iOS 16.0, watchOS 9.0, *) { + switch family { + case .accessoryCircular: + keys = ["watchCircular"] + case .accessoryRectangular: + // The same family serves the iPhone lock screen and the watch face, so each surface + // has to prefer the layout that was designed for it. +#if os(watchOS) + keys = ["watchRectangular", "lockscreen"] +#else + keys = ["lockscreen", "watchRectangular"] +#endif + case .accessoryInline: + keys = ["watchInline"] + default: + break + } +#if os(watchOS) + if family == .accessoryCorner { + // No corner slot outside watchOS; the circular layout is the closest shape. + keys = ["watchCorner", "watchCircular"] + } +#endif + } + for key in keys { + if let layout = layouts[key] as? [String: Any] { + return layout + } } return layouts["default"] as? [String: Any] } diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java new file mode 100644 index 00000000000..5c6ace74604 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java @@ -0,0 +1,3980 @@ +/* + * 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.content.Context; +import android.net.Uri; + +import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.WearableMessage; +import com.codename1.wearable.spi.WearableBridge; + +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.Tasks; +import com.google.android.gms.wearable.CapabilityClient; +import com.google.android.gms.wearable.CapabilityInfo; +import com.google.android.gms.wearable.DataClient; +import com.google.android.gms.wearable.DataItem; +import com.google.android.gms.wearable.DataItemBuffer; +import com.google.android.gms.wearable.DataMap; +import com.google.android.gms.wearable.DataMapItem; +import com.google.android.gms.wearable.MessageClient; +import com.google.android.gms.wearable.Node; +import com.google.android.gms.wearable.NodeClient; +import com.google.android.gms.wearable.Asset; +import com.google.android.gms.wearable.PutDataMapRequest; +import com.google.android.gms.wearable.PutDataRequest; +import com.google.android.gms.wearable.Wearable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Wearable Data Layer implementation of the Codename One {@code WearableBridge}, injected into the + * generated project only when the app references {@code com.codename1.wearable}. The Android port + * itself carries no dependency on play-services-wearable, which is why this class lives in the + * builder's resources rather than in the port -- see {@link AndroidWearableSupport}. + * + *

The three Codename One transports map onto the Data Layer as follows: + *

    + *
  • a live message is {@code MessageClient.sendMessage}, delivered only to nearby nodes;
  • + *
  • replicated data is a {@code DataItem} at the given path, which the system syncs to every + * paired node whenever it next connects, surviving both apps being killed;
  • + *
  • a file transfer is a DataItem carrying an {@code Asset}, which the system streams in the + * background.
  • + *
+ * + *

Unlike Apple, Wear allows several watches paired to one phone, so sends fan out to every + * connected node. Payloads are the opaque bytes produced by {@code WearableMessage}, so nothing here + * has to understand the value model. + */ +public class CN1WearableBridge implements WearableBridge { + /** Data Layer paths must start with a slash, and so do Codename One paths by convention. */ + private static final String PATH_PREFIX = "/cn1"; + /** + * The capability this app advertises to say the counterpart is installed, declared in + * res/values/cn1_wearable.xml by the build. Named, rather than repeated as a literal, because + * the manifest filters CAPABILITY_CHANGED on the "/cn1" path prefix and Play services matches a + * capability name as the path -- so an app that declares any other capability starting with + * "cn1" is delivered here too, and the name is what tells the two apart. + */ + static final String CAPABILITY_NAME = "cn1_wearable"; + /** The key the payload bytes live under inside a DataItem. */ + private static final String PAYLOAD_KEY = "cn1.payload"; + /** The publication order of a value or transfer, so the newer of two items wins. */ + private static final String SEQUENCE_KEY = "cn1.seq"; + /** + * When an item was published, in wall-clock millis. + * + *

Separate from {@link #SEQUENCE_KEY} because the sequence is a logical clock: once this + * device has observed a peer running ahead, a sequence no longer corresponds to a time at all, + * and the transfer sweep -- which is genuinely about age -- would keep items until local time + * happened to reach the borrowed value. + */ + private static final String PUBLISHED_AT_KEY = "cn1.at"; + /** + * Transfers live under their own prefix, not under {@link #PATH_PREFIX}. Sharing the prefix made + * the two APIs collide: {@code transferFile("/inbox", "photo.png", ...)} built the same DataItem + * URI as {@code putData("/inbox/photo.png")}, so each could silently overwrite the other. + * + *

The trailing slash is what makes the namespace unambiguous rather than merely different. + * {@link #encode} escapes {@code '/'}, so a replicated value's path is {@code /cn1} followed by + * characters that never include a slash -- meaning no value can ever match {@code /cn1x/}. Without + * the delimiter, {@code putData("xstatus")} would produce {@code /cn1xstatus} and be misread as a + * transfer, its value dropped by the listener and hidden from {@code getDataPaths()}. + */ + private static final String TRANSFER_PREFIX = "/cn1x/"; + /** How long a blocking Data Layer call may take before we give up and answer "not available". */ + private static final long TIMEOUT_SECONDS = 5; + /** + * The Codename One EDT must never wait five seconds on Play services -- isPaired/isReachable are + * exactly the sort of thing an app calls from init() or a button handler. The node list is + * therefore cached and refreshed off the EDT; callers get the last known answer immediately. + */ + private static final long NODE_CACHE_MILLIS = 3000; + private volatile List cachedNodes = new ArrayList(); + private volatile long cachedNodesStamp; + private volatile boolean refreshingNodes; + /** + * Bumped on every write to the node cache, for the same reason as {@link #bondedGeneration}: an + * in-flight refresh must not overwrite a pushed onPeerConnected/Disconnected update with an + * older snapshot and stamp it fresh, which would leave isReachable() wrong until the cache + * expired. + */ + private final Object nodesLock = new Object(); + private long nodesGeneration; + + private final Context context; + private final MessageClient messageClient; + private final DataClient dataClient; + private final NodeClient nodeClient; + private final CapabilityClient capabilityClient; + + /** + * Reply blocks are not a Data Layer concept: MessageClient is one-way. A request carries its + * token in the path and the answer comes back on a reply path carrying the same token, which is + * what lets the Codename One reply handler work identically on both platforms. + */ + private static final String REPLY_PATH = PATH_PREFIX + "/reply/"; + private static final String REQUEST_PATH = PATH_PREFIX + "/request/"; + private static final String MESSAGE_PATH = PATH_PREFIX + "/message"; + + public CN1WearableBridge(Context context) { + this.context = context.getApplicationContext(); + this.messageClient = Wearable.getMessageClient(this.context); + this.dataClient = Wearable.getDataClient(this.context); + this.nodeClient = Wearable.getNodeClient(this.context); + this.capabilityClient = Wearable.getCapabilityClient(this.context); + current = this; + restoreClock(this.context); + // Sweep at startup as well as after each publish. An app that sends a few files and then + // stops would otherwise never run the sweep again, leaving its last transfers published + // indefinitely -- the post-publish sweep only helps an app that keeps transferring. + expireOwnTransfers(); + // The receiver's durable claims are pruned by the replay itself, once it has succeeded -- + // NOT here. Pruning first deleted an aged claim before the replay could see that its item + // is still published, and the replay then delivered that one-shot file a second time. + // Ordering matters more than promptness: the claim store is bounded by the periodic prune + // as well, so deferring it costs nothing. + replayOutstandingTransfers(); + // What to do when the pending-delivery cap has to discard one of our callbacks: forget that + // the path was delivered and resolve it again. Without forgetting, every replay skips it -- + // the stamp is recorded before the delivery is queued, so the path looks delivered even + // though nothing ran. Runs after the drain, so the re-offer meets a listener. + WearableConnection.setDroppedDeliveryHandler( + new WearableConnection.DroppedDeliveryHandler() { + public void deliveryDropped(String path) { + if (path == null) { + // More was discarded than could be named. Forget every delivery this + // process has recorded and re-enumerate: the replay then treats each + // still-published path as first sight and offers it again. + forgetAllDeliveredSequences(); + replayOutstandingTransfers(); + return; + } + // Only value changes arrive here; core re-announces a discarded removal + // itself, which is the only thing that can -- a deleted item is absent from + // every enumeration by definition. + forgetDeliveredSequence(path); + scheduleWinnerResolution(context, path); + } + }); + } + + // --- state -------------------------------------------------------------- + + public boolean isSupported() { + return true; + } + + public boolean isPaired() { + // Pairing, not reachability: a paired watch that is switched off or out of range reports no + // connected node, and the API promises these are different questions. The capability query + // behind bondedNodeIds() uses FILTER_ALL, so it still lists a paired peer that is currently + // disconnected, which is as close to "paired" as the Data Layer gets. A paired watch that + // has never run this app is invisible to both queries because Android exposes no such list; + // that limit is stated in the public contract rather than papered over here. + // Cold start: both caches are empty until the first query completes, and a latency-sensitive + // caller cannot be made to wait for it, so the honest answer here is "not known yet" and + // the only value this signature can carry for that is false. + // + // An earlier attempt to paper over it -- remembering that a peer had once been seen -- was + // no help at all, because on a cold start nothing has been seen yet; that is the whole + // situation. What actually closes the window is the refresh completing: it sets + // bondedQueryCompleted, and when the answer CHANGES it fires the state listeners. So the + // contract tells callers to decide from a WearableStateListener rather than from one call + // at startup, and this stays a plain report of what is currently known. + return !connectedNodes().isEmpty() || !bondedNodeIds().isEmpty(); + } + + /// True once the capability query has actually answered, so an empty cache can be told apart + /// from one that has not been filled yet. Guarded by the bonded cache's monitor. + private volatile boolean bondedQueryCompleted; + + /// Whether an event's source node may be trusted, for the listener service's caller check. + /// + /// A fresh blocking query would be the strictest answer and is also the wrong one: a peer that + /// disconnects between Play services queueing the callback and this check completing -- or a + /// query that transiently fails -- would make us discard a message the Data Layer already + /// validated and delivered. So the test is membership of a *recent* snapshot: nodes seen + /// connected in the last few minutes, plus this device itself (our own published data is echoed + /// back to us with the local node as its host). A forged intent from another app on the device + /// still carries a node id that was never in that snapshot. + /// + /// @param context any context; the Data Layer clients are cheap to obtain + /// @param sourceNodeId the node the event claims to come from + /// @return true when the id belongs to a node we have seen + static boolean isKnownNode(Context context, String sourceNodeId) { + if (sourceNodeId == null || sourceNodeId.length() == 0) { + return false; + } + if (recentlySeen(sourceNodeId) || sourceNodeId.equals(localNodeId(context))) { + return true; + } + // Nothing remembered yet -- this is the cold-start case, where the service process was + // created to deliver the very first event. Now a blocking query is both safe (we are on a + // Play services callback thread, never the EDT) and necessary. + List connected = connectedNodeIds(context); + for (String id : connected) { + rememberNode(id); + } + if (recentlySeen(sourceNodeId)) { + return true; + } + // The local node is checked again first: a peer snapshot can never contain this device, so + // rejecting on "populated but no match" would discard our own echoed putData() whenever any + // peer happens to be connected and the earlier getLocalNode() failed transiently. + if (sourceNodeId.equals(localNodeId(context))) { + return true; + } + // The capability set is consulted BEFORE this rejection, not after it. With several paired + // watches, a durable item from a disconnected watch A can arrive while watch B is online: + // the snapshot is then populated and simply does not contain A, so treating "populated but + // absent" as evidence dropped an event from a genuinely paired device. Connectivity says + // who is reachable now; the capability set says who is paired and running this app, and for + // a stored item that is the question. + boolean capabilityQueried = false; + List capable = capabilityNodeIds(context); + if (capable != null) { + capabilityQueried = true; + for (String id : capable) { + rememberNode(id); + } + } + if (recentlySeen(sourceNodeId)) { + return true; + } + if (!connected.isEmpty() && capabilityQueried) { + // Reachable peers exist, the sender is not among them, and it advertises no capability + // either. That is real evidence against it. + // + // Gated on the capability query having actually ANSWERED. With several paired watches, + // a durable item from disconnected watch A arriving while watch B is online leaves the + // connected snapshot populated -- so a transient failure of the one query that can see + // A would have been read as evidence against A and rejected it outright, and an + // unchanged item may raise no later callback to put that right. + return false; + } + // The query established nothing at all: the sender may have disconnected while we were + // starting, or Play services may not have been ready. That is not licence to trust an + // arbitrary id -- this service is exported, so an empty snapshot is exactly the state a + // forged intent would like to find. Retry a couple of times instead, which covers the + // transient case without ever admitting an unverified node. + for (int attempt = 0; attempt < NODE_QUERY_RETRIES; attempt++) { + try { + Thread.sleep(NODE_QUERY_RETRY_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + for (String id : connectedNodeIds(context)) { + rememberNode(id); + } + // Re-attempt the CAPABILITY set as well, not just the connected nodes. It is the only + // query that can see a paired-but-disconnected sender, so a single transient failure of + // it on a cold start left the item permanently unverifiable: retrying connected nodes + // can never rediscover a node that is not connected, and the unchanged item is then + // discarded with no later callback guaranteed. + List retryCapable = capabilityNodeIds(context); + if (retryCapable != null) { + for (String id : retryCapable) { + rememberNode(id); + } + } + // Re-attempt the local identity too. A peer query can never return this device, so if + // getLocalNode() failed transiently on the first pass, an event from our OWN putData() + // -- which the Data Layer echoes back with the local node as host -- would be rejected + // no matter how many times we asked about peers. + if (recentlySeen(sourceNodeId) || sourceNodeId.equals(localNodeId(context))) { + return true; + } + } + // The capability set was already consulted above, before the populated-snapshot rejection. + return recentlySeen(sourceNodeId); + } + + /// Ids of the nodes advertising this app's capability, reachable or not. Blocking. + /// Null when the query FAILED, which is not the same as a peer set that is genuinely empty -- + /// the caller uses the difference to decide whether a populated connected snapshot is evidence. + private static List capabilityNodeIds(Context context) { + List out = new ArrayList(); + try { + CapabilityInfo info = Tasks.await( + Wearable.getCapabilityClient(context.getApplicationContext()) + .getCapability(CAPABILITY_NAME, CapabilityClient.FILTER_ALL), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (info != null) { + for (Node n : info.getNodes()) { + out.add(n.getId()); + } + } + } catch (Throwable unavailable) { + // Nothing established. Reported as null so the caller can tell "could not ask" from + // "asked, and this node is not paired". + return null; + } + return out; + } + + /// Ids of the nodes the Data Layer currently reports. Blocking; never call on the EDT. + /// + /// @param context any context; the Data Layer clients are cheap to obtain + /// @return the connected node ids, never null + static List connectedNodeIds(Context context) { + List out = new ArrayList(); + try { + List nodes = Tasks.await( + Wearable.getNodeClient(context.getApplicationContext()).getConnectedNodes(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + for (Node n : nodes) { + out.add(n.getId()); + } + } catch (Throwable unavailable) { + // Nothing reachable: nothing is trusted. + } + return out; + } + + /** + * Node ids seen connected recently, and when. A message may legitimately arrive from a node that + * has just dropped off the connected list, so trust outlives the connection by a wide margin. + */ + private static final Map recentNodes = new HashMap(); + /** How long a node stays trusted after it was last seen. */ + private static final long RECENT_NODE_MILLIS = 10 * 60 * 1000L; + /** Retries for a cold-start node query that came back empty; see {@link #isKnownNode}. */ + private static final int NODE_QUERY_RETRIES = 2; + private static final long NODE_QUERY_RETRY_MILLIS = 750; + private static volatile String localNode; + + private static void rememberNode(String id) { + if (id == null || id.length() == 0) { + return; + } + synchronized (recentNodes) { + recentNodes.put(id, Long.valueOf(System.currentTimeMillis())); + } + } + + private static boolean recentlySeen(String id) { + synchronized (recentNodes) { + Long seen = recentNodes.get(id); + if (seen == null) { + return false; + } + if (System.currentTimeMillis() - seen.longValue() > RECENT_NODE_MILLIS) { + recentNodes.remove(id); + return false; + } + return true; + } + } + + /// This device's own node id, cached: the Data Layer echoes our own published values back to us + /// with the local node as the DataItem host, and dropping those would break putData locally. + private static String localNodeId(Context context) { + String known = localNode; + if (known != null) { + return known; + } + try { + known = Tasks.await( + Wearable.getNodeClient(context.getApplicationContext()).getLocalNode(), + TIMEOUT_SECONDS, TimeUnit.SECONDS).getId(); + localNode = known; + } catch (Throwable unavailable) { + return null; + } + return known; + } + + /** + * Whether a data item was published by THIS device. + * + *

Play services echoes an app's own {@code putData}/{@code removeData} back to it, with the + * local node as the item's authority. Those echoes have to stay visible to reads and to the + * ordering bookkeeping -- they are genuinely the current value of the path -- but they are not + * peer events, and {@code WearableDataListener} documents its callbacks as peer changes. iOS and + * the simulator already suppress self-authored changes, so forwarding them made identical app + * code fire an extra callback on Android only, and an app that acts on a change would process + * its own write twice. + * + * @param context any context + * @param host the item Uri's authority + * @return true when the item came from this device + */ + /** + * Last known value per path, and the last successful path enumeration. + * + *

Exists so a read from a latency-sensitive thread has something truthful to answer with. + * getData/getDataPaths reach Play services through a blocking await, and on the EDT that is up + * to five seconds of frozen painting and input -- the same stall the state queries in this + * class already refuse to take. Bounded like the other caches; a value falling out only costs + * an EDT caller a null it would otherwise have blocked five seconds for.

+ */ + private static final int VALUE_CACHE_MAX = 256; + private static final Map valueCache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > VALUE_CACHE_MAX; + } + }; + private static volatile String[] pathsCache; + /// Bumped whenever the path snapshot changes, so a blocking enumeration can tell whether a + /// delivery maintained it while the query was in flight. Guarded by {@link #valueCache}. + private static int pathsGeneration; + + /** + * Records what a delivery or a successful read saw, so a later EDT caller can be answered + * without blocking. + * + *

Maintains the path snapshot too. An enumeration-only {@code pathsCache} went stale the + * moment a peer published or removed anything and stayed that way until some background caller + * happened to enumerate again -- while the listener had already been told enough to keep it + * right. A path appearing or disappearing is exactly what these calls report.

+ */ + static void rememberValue(String path, byte[] payload) { + if (path == null) { + return; + } + synchronized (valueCache) { + // Any authoritative answer supersedes a remembered absence, in both directions: a + // publication makes the path exist, and a removal is itself the absence. + if (payload == null) { + knownAbsent.add(path); + valueCache.remove(path); + } else { + knownAbsent.remove(path); + // A copy, because the same array is handed to the application. A listener that + // mutates the payload it receives -- decrypting or unpacking in place, say -- would + // otherwise rewrite the bridge's own snapshot, and getData() would answer with + // bytes nobody ever published until the next event refreshed the path. + valueCache.put(path, payload.clone()); + } + String[] known = pathsCache; + if (known == null) { + // No enumeration has succeeded yet, so there is no snapshot to keep consistent; + // inventing a one-element one would claim this is the only path that exists. + // + // The GENERATION still moves. An enumeration already in flight may have captured + // the Data Layer before this change, and the generation is the only thing that + // tells it so -- leaving it untouched let that query install a snapshot missing a + // path just published, or still holding one just removed, and a latency-sensitive + // getDataPaths() would answer from it indefinitely because the callback that would + // have corrected it has already fired. + pathsGeneration++; + return; + } + boolean present = false; + for (String p : known) { + if (path.equals(p)) { + present = true; + break; + } + } + if (payload == null && present) { + List out = new ArrayList(known.length); + for (String p : known) { + if (!path.equals(p)) { + out.add(p); + } + } + pathsCache = out.toArray(new String[out.size()]); + pathsGeneration++; + } else if (payload != null && !present) { + String[] out = new String[known.length + 1]; + System.arraycopy(known, 0, out, 0, known.length); + out[known.length] = path; + pathsCache = out; + pathsGeneration++; + } + } + } + + private static byte[] cachedValue(String path) { + synchronized (valueCache) { + byte[] cached = valueCache.get(path); + // Also a copy on the way out: the caller owns what getData() returns and may do as it + // likes with it, which must not reach back into the snapshot. + return cached == null ? null : cached.clone(); + } + } + + static boolean isLocallyAuthored(Context context, String host) { + if (host == null) { + return false; + } + String local = localNodeId(context); + return local != null && local.equals(host); + } + + /** + * Whether the calling thread must not be blocked. + * + *

The Codename One EDT is the obvious one. Android's main thread matters just as much and was + * missed: Play services completion listeners run there unless given an executor, so a blocking + * Data Layer call reached from one is an ANR rather than a dropped frame. + * + * @return true when the caller needs an immediate answer + */ + private static boolean isCallerLatencySensitive() { + if (com.codename1.ui.CN.isEdt()) { + return true; + } + try { + return android.os.Looper.myLooper() == android.os.Looper.getMainLooper(); + } catch (Throwable notOnAndroidThread) { + return false; + } + } + + /// Nodes the Data Layer knows about whether or not they are currently connected. + private List bondedNodeIds() { + if (bondedStamp != 0 && System.currentTimeMillis() - bondedStamp <= NODE_CACHE_MILLIS) { + // Honour the cache lifetime on every thread. Refreshing on each EDT call would make a + // state listener that calls isPaired() or isReachable() start another refresh, whose + // completion notifies listeners again -- a self-sustaining loop. + return cachedBonded; + } + if (isCallerLatencySensitive()) { + // Never block the EDT -- or Android's main thread, which is where a Play services + // completion listener runs by default: fanOut() reaches here from the send-time refresh + // callback, and a five-second Tasks.await() there is an ANR, not a slow frame. + // + // The cache still has to be filled by someone, or an installed companion is reported + // absent forever. Kick off a refresh and answer with what is known so far; listeners + // are notified only when the answer actually changed. + refreshBondedAsync(); + return cachedBonded; + } + final long startedAt; + synchronized (bondedLock) { + startedAt = bondedGeneration; + } + List out = new ArrayList(); + try { + CapabilityInfo info = Tasks.await( + capabilityClient.getCapability(CAPABILITY_NAME, CapabilityClient.FILTER_ALL), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + for (Node n : info.getNodes()) { + out.add(n.getId()); + } + } catch (Throwable unavailable) { + // Keep the previous snapshot, as every other refresh path does. Falling through to the + // assignments below would replace a valid companion set with an empty one and stamp it + // fresh, so isCompanionAppInstalled(), isPaired() and isReachable() would all report no + // companion for a full cache lifetime because one query timed out. + return cachedBonded; + } + synchronized (bondedLock) { + if (bondedGeneration != startedAt) { + // A pushed onCapabilityChanged landed while this blocking query was out. It is more + // current than anything we asked for, so keep it rather than restoring the older + // answer and stamping it fresh. + return cachedBonded; + } + cachedBonded = out; + bondedStamp = System.currentTimeMillis(); + bondedQueryCompleted = true; + bondedKnown = true; + bondedGeneration++; + } + return out; + } + + private volatile List cachedBonded = new ArrayList(); + private volatile boolean refreshingBonded; + private volatile long bondedStamp; + /** + * Whether a capability query has ever completed. An empty {@link #cachedBonded} is ambiguous + * without it -- "not asked yet" and "asked, nobody runs the app" are opposite answers for + * {@link #fanOut}, and treating the second as the first sends to a watch that cannot receive. + */ + private volatile boolean bondedKnown; + /** Guards the {@link #cachedBonded} / {@link #bondedKnown} pair so they can be read together. */ + private final Object bondedLock = new Object(); + /** + * Bumped on every write to the capability cache. + * + *

An in-flight refresh and a pushed {@code onCapabilityChanged} can complete in either order. + * Without a version the older query result lands last, overwrites the newer pushed set AND gets + * a fresh timestamp -- so an install or removal that Play services told us about directly is + * discarded and the wrong answer is held for a full cache lifetime. + */ + private long bondedGeneration; + + /** + * The capability cache read as ONE value. + * + *

Two independent volatile reads cannot express "these belong together": reading the flag + * first let a completed query leave a true flag beside a stale empty list, and reading the list + * first let a query that completes during the read leave a populated list beside a false flag -- + * so the filter was skipped even though the answer was known. Both fields are written and read + * under one lock instead, so a caller always sees a consistent pair. + * + * @return the snapshot; {@code known} false means no query has completed yet + */ + private BondedSnapshot bondedSnapshot() { + // Take the list outside the lock: bondedNodeIds() may block on Play services, and holding + // the lock across that would stall every other reader. + List ids = bondedNodeIds(); + synchronized (bondedLock) { + return new BondedSnapshot(bondedKnown, bondedKnown ? cachedBonded : ids); + } + } + + /** A consistent view of the capability cache. */ + private static final class BondedSnapshot { + final boolean known; + final List ids; + + BondedSnapshot(boolean known, List ids) { + this.known = known; + this.ids = ids; + } + } + + /// Accepts a capability set pushed by Play services, so the cache tracks an install or + /// uninstall that happens while the device stays connected. + static void capabilityChanged(CapabilityInfo info) { + if (info != null && !CAPABILITY_NAME.equals(info.getName())) { + // Another of the app's capabilities whose name also begins with "cn1" (the manifest + // filters on that prefix and the capability name IS the path). Its node set says + // nothing about whether the counterpart app is installed, so adopting it would corrupt + // the cache behind isCompanionAppInstalled(); and no state of ours changed, so this + // must not notify either. + return; + } + CN1WearableBridge b = current; + if (b == null || info == null) { + // No bridge to update the cache on, but listeners are held by WearableConnection rather + // than by the bridge, so the state change still has to reach them -- this is the only + // notification for it, the caller does not send a second one. + if (info != null) { + WearableConnection.notifyStateChanged(); + } + return; + } + List out = new ArrayList(); + for (Node n : info.getNodes()) { + out.add(n.getId()); + } + boolean changed; + synchronized (b.bondedLock) { + changed = !sameIds(b.cachedBonded, out); + b.cachedBonded = out; + b.bondedStamp = System.currentTimeMillis(); + b.bondedQueryCompleted = true; + b.bondedKnown = true; + b.bondedGeneration++; + } + if (changed) { + WearableConnection.notifyStateChanged(); + } + } + + /// Order-insensitive comparison of two node-id lists, so a refresh that returns the same set does + /// not fire a state change (and cannot become a feedback loop through a listener). + private static boolean sameIds(List a, List b) { + if (a == null || b == null) { + return a == b; + } + return a.size() == b.size() && a.containsAll(b); + } + + /// A peer connected or disconnected. The caches have to be corrected *before* listeners run, + /// otherwise a listener that responds by calling isReachable() sees the node it was just told + /// about as still present (or still absent) for the rest of the cache lifetime. + static void peerChanged(Node peer, boolean connected) { + CN1WearableBridge b = current; + if (peer != null && connected) { + rememberNode(peer.getId()); + } + if (b == null) { + WearableConnection.notifyStateChanged(); + return; + } + synchronized (b.nodesLock) { + b.applyPeerChange(peer, connected); + } + // A disconnect can also mean the capability set shrank; let that refresh on its own clock. + WearableConnection.notifyStateChanged(); + } + + /// Applies a pushed peer change. Must hold {@link #nodesLock}: copying the cache outside it let + /// a refresh complete in between, after which this rebuilt the list from the OLD snapshot and + /// stamped it fresh -- dropping whatever peers that refresh had just discovered. + private void applyPeerChange(Node peer, boolean connected) { + List updated = new ArrayList(cachedNodes); + if (peer != null) { + for (int i = updated.size() - 1; i >= 0; i--) { + if (peer.getId().equals(updated.get(i).getId())) { + updated.remove(i); + } + } + if (connected) { + updated.add(peer); + } + } + cachedNodes = updated; + // Keep the stamp: this is a push from Play services, which is more current than any query + // we could make, so there is nothing to re-ask. A zero stamp would also make the next + // sendMessage() defer needlessly. Bumping the generation is what stops an in-flight refresh + // from undoing this. + cachedNodesStamp = System.currentTimeMillis(); + nodesGeneration++; + } + + /// The live bridge, so the listener service can push state into it. The service and the bridge + /// are created independently by Android, which is why this is not a constructor argument. + private static volatile CN1WearableBridge current; + + private void refreshBondedAsync() { + if (refreshingBonded) { + return; + } + refreshingBonded = true; + final long startedAt; + synchronized (bondedLock) { + startedAt = bondedGeneration; + } + capabilityClient.getCapability(CAPABILITY_NAME, CapabilityClient.FILTER_ALL) + .addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener() { + public void onComplete(com.google.android.gms.tasks.Task task) { + if (!task.isSuccessful() || task.getResult() == null) { + // A transient failure is not evidence the companion was uninstalled. + // Overwriting a good cache with an empty result -- and stamping it fresh + // -- would make isCompanionAppInstalled(), isPaired() and isReachable() + // all report "no companion" for a full cache lifetime. + refreshingBonded = false; + return; + } + List out = new ArrayList(); + for (Node n : task.getResult().getNodes()) { + out.add(n.getId()); + } + boolean changed; + synchronized (bondedLock) { + if (bondedGeneration != startedAt) { + // Something newer landed while this query was in flight -- typically + // a pushed onCapabilityChanged, which is more current than anything + // we could have asked for. Discard this result rather than reviving + // a pre-install/pre-removal answer and stamping it fresh. + refreshingBonded = false; + return; + } + changed = !sameIds(cachedBonded, out); + cachedBonded = out; + bondedStamp = System.currentTimeMillis(); + bondedQueryCompleted = true; + bondedKnown = true; + bondedGeneration++; + } + refreshingBonded = false; + if (changed) { + WearableConnection.notifyStateChanged(); + } + } + }); + } + + public boolean isReachable() { + // "Reachable" promises the peer app can receive a message, so a connected watch that does + // not run this app must not qualify: getConnectedNodes() lists physical devices, and only + // the capability set says which of them installed the counterpart. + List withApp = bondedNodeIds(); + for (Node n : connectedNodes()) { + if (n.isNearby() && withApp.contains(n.getId())) { + return true; + } + } + return false; + } + + public boolean isCompanionAppInstalled() { + // A connected node is a connected *device*, not a device running this app -- so the node + // list alone would report a bare watch as having the companion installed. The peer half + // advertises the "cn1_wearable" capability (declared in res/values/cn1_wearable.xml by the + // build), so asking who advertises it is the actual question. + return !bondedNodeIds().isEmpty(); + } + + public String[] getConnectedNodes() { + List nodes = connectedNodes(); + String[] out = new String[nodes.size()]; + for (int i = 0; i < out.length; i++) { + Node n = nodes.get(i); + // id \t displayName \t nearby -- the flat form the SPI documents. + out[i] = n.getId() + "\t" + n.getDisplayName() + "\t" + (n.isNearby() ? "1" : "0"); + } + return out; + } + + /** + * The nodes last seen, refreshed in the background. Blocking is only acceptable off the EDT -- + * on it, a stale answer now beats a correct answer after a five-second freeze. + */ + private List connectedNodes() { + long age = System.currentTimeMillis() - cachedNodesStamp; + if (age > NODE_CACHE_MILLIS) { + if (isCallerLatencySensitive()) { + refreshNodesAsync(); + } else { + refreshNodesNow(); + } + } + return cachedNodes; + } + + private void refreshNodesNow() { + final long startedAt; + synchronized (nodesLock) { + startedAt = nodesGeneration; + } + List fresh; + try { + fresh = Tasks.await(nodeClient.getConnectedNodes(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Throwable unavailable) { + // Keep the previous snapshot, exactly as the asynchronous and send-time refreshes do. + // Clearing it here would report every peer gone -- and stamp that fresh -- because one + // blocking query happened to time out. + return; + } + synchronized (nodesLock) { + if (nodesGeneration != startedAt) { + // A pushed peer connect/disconnect landed while this blocking query was out. Keep + // it: a push is more current than anything we could have asked for. + return; + } + cachedNodes = fresh; + cachedNodesStamp = System.currentTimeMillis(); + nodesGeneration++; + } + rememberAll(cachedNodes); + } + + private void refreshNodesAsync() { + if (refreshingNodes) { + return; + } + refreshingNodes = true; + final long nodesStartedAt; + synchronized (nodesLock) { + nodesStartedAt = nodesGeneration; + } + nodeClient.getConnectedNodes().addOnCompleteListener( + new com.google.android.gms.tasks.OnCompleteListener>() { + public void onComplete(com.google.android.gms.tasks.Task> task) { + if (!task.isSuccessful() || task.getResult() == null) { + // A transient Play services failure is not evidence that every peer + // vanished. Replacing a good snapshot with an empty list would make + // isReachable() and getConnectedNodes() report a disconnected pair for a + // full cache lifetime, and fire a spurious state change with it. Keep + // what we had; the next call retries. + refreshingNodes = false; + return; + } + List fresh = task.getResult(); + boolean changed; + synchronized (nodesLock) { + if (nodesGeneration != nodesStartedAt) { + // A pushed peer connect/disconnect landed while this was in flight. + refreshingNodes = false; + return; + } + changed = !sameIds(idsOf(cachedNodes), idsOf(fresh)); + cachedNodes = fresh; + cachedNodesStamp = System.currentTimeMillis(); + nodesGeneration++; + } + refreshingNodes = false; + rememberAll(fresh); + // Reachability may have changed; let listeners re-query. Only on an actual + // change, or a listener that re-queries here would refresh forever. + if (changed) { + WearableConnection.notifyStateChanged(); + } + } + }); + } + + private static void rememberAll(List nodes) { + for (Node n : nodes) { + rememberNode(n.getId()); + } + } + + private static List idsOf(List nodes) { + List out = new ArrayList(); + for (Node n : nodes) { + out.add(n.getId()); + } + return out; + } + + // --- messages ----------------------------------------------------------- + + public void sendMessage(final String path, final byte[] payload, final int replyToken) { + if (System.currentTimeMillis() - cachedNodesStamp > NODE_CACHE_MILLIS) { + // The cache is empty or stale. Sending now would fan out to a list that predates the + // current connection state and report "no nearby device" while a watch is sitting right + // there, so resolve the node list first -- a send is not a state query, and it is worth + // one round trip to address it correctly. (connectedNodes() would only *start* an async + // refresh on the EDT and then fan out to the stale list anyway.) + final long sendStartedAt; + synchronized (nodesLock) { + sendStartedAt = nodesGeneration; + } + nodeClient.getConnectedNodes().addOnCompleteListener( + new com.google.android.gms.tasks.OnCompleteListener>() { + public void onComplete(com.google.android.gms.tasks.Task> task) { + if (task.isSuccessful() && task.getResult() != null) { + synchronized (nodesLock) { + // A pushed peer connect/disconnect that landed while this query + // was out is more current than the query; keep it and fan out + // against it rather than reviving the older snapshot. + if (nodesGeneration == sendStartedAt) { + cachedNodes = task.getResult(); + cachedNodesStamp = System.currentTimeMillis(); + nodesGeneration++; + } + } + rememberAll(cachedNodes); + } + // On failure keep the previous snapshot rather than clearing it: a + // stale-but-real node list still addresses the peer, whereas an empty + // one silently drops this message (or fails its reply handler) purely + // because a refresh happened to time out. + fanOut(path, payload, replyToken); + } + }); + return; + } + fanOut(path, payload, replyToken); + } + + private void fanOut(String path, byte[] payload, final int replyToken) { + List nodes = connectedNodes(); + boolean sentToAnyone = false; + List> tasks = + new ArrayList>(); + // Prefer nodes that advertise the app capability, so a connected watch WITHOUT this app is + // not counted as a recipient -- otherwise a reply-bearing request "succeeds" against a watch + // that cannot answer and the caller waits out the full timeout instead of being told there + // is nobody to ask. This also stops fanOut and isReachable() disagreeing. + // + // Only once a capability query has actually completed. Before that an empty set means "not + // asked yet", not "nobody runs the app", and refusing to send on it would break the first + // send after a cold start -- so bondedKnown, not emptiness, is what gates the filter. + // One consistent (known, ids) pair -- see bondedSnapshot(). Sampling the two fields + // independently is wrong in both orders: flag-then-list can pair a true flag with a stale + // empty list (filtering out every node, so the send reaches nobody), and list-then-flag can + // pair a populated list with a false flag (skipping the filter although the answer is + // known, so a send goes to a watch without the app). + BondedSnapshot bonded = bondedSnapshot(); + for (Node n : nodes) { + if (!n.isNearby()) { + continue; + } + if (bonded.known && !bonded.ids.contains(n.getId())) { + continue; + } + // The peer needs both the CN1 path and, when an answer is wanted, the token to answer + // with. Both ride in the Data Layer path so the payload stays exactly the app's bytes. + // encode() escapes '/' as well, so the encoded app path is a single segment containing no + // delimiter: the '/' inserted here is unambiguously the separator, and a relative app + // path like "steps" survives instead of arriving as "/steps". + String wire = (replyToken == 0 ? MESSAGE_PATH : REQUEST_PATH + replyToken) + + "/" + encode(path); + tasks.add(messageClient.sendMessage(n.getId(), wire, payload)); + sentToAnyone = true; + } + if (!sentToAnyone) { + if (replyToken != 0) { + WearableConnection.deliverReply(replyToken, null, + "No nearby device is running the app"); + } + return; + } + if (replyToken != 0) { + // A send can succeed and still never be answered -- an older peer that does not know + // the path, or a cold start Android refused to allow. Without this the pending entry + // lives forever and neither handler method is ever called. + scheduleReplyTimeout(replyToken); + // Fail only when NO node accepted the request: one watch failing while another + // succeeds must not cancel the handler that the successful one is about to answer. + com.google.android.gms.tasks.Tasks.whenAllComplete(tasks).addOnCompleteListener( + new com.google.android.gms.tasks.OnCompleteListener>>() { + public void onComplete( + com.google.android.gms.tasks.Task>> all) { + if (all.getResult() == null) { + return; + } + for (com.google.android.gms.tasks.Task t : all.getResult()) { + if (t.isSuccessful()) { + return; + } + } + WearableConnection.deliverReply(replyToken, null, + "The message could not be delivered to any paired device"); + } + }); + } + } + + public void sendReply(int replyToken, byte[] payload) { + // Back to the node that asked, not to every watch on the wrist rack: tokens are allocated + // per node and routinely collide, so broadcasting would answer the wrong request. + // Two watches can allocate the same token before either is answered, so the origin is + // keyed by node AND token; the local token handed to Java is unique on its own. + InboundRequest req; + synchronized (inboundNodes) { + req = inboundNodes.remove(Integer.valueOf(replyToken)); + } + if (req == null) { + return; + } + messageClient.sendMessage(req.nodeId, REPLY_PATH + req.peerToken, payload); + } + + /// Records which node sent a request, so its answer can be routed back to it. Called by + /// {@link CN1WearableListenerService} as the request arrives. + static int rememberRequestOrigin(int peerToken, String nodeId) { + synchronized (inboundNodes) { + pruneInboundOrigins(); + int local = nextLocalToken++; + inboundNodes.put(Integer.valueOf(local), new InboundRequest(nodeId, peerToken)); + // Also swept on a timer. Pruning only here meant a FINAL request -- or a final burst -- + // to an app that never answers kept its origin records for the rest of the process, + // long after every sender had timed out. A TTL that needs future traffic to take effect + // is not a TTL. + scheduleInboundPrune(); + return local; + } + } + + /// Drops origins older than the TTL. Caller holds {@link #inboundNodes}. + private static void pruneInboundOrigins() { + // An app that never answers a path would otherwise grow this map for its whole life. + // The sender gives up after its own timeout, so an entry older than that can go. + long cutoff = System.currentTimeMillis() - INBOUND_TTL_MILLIS; + java.util.Iterator> it = + inboundNodes.entrySet().iterator(); + while (it.hasNext()) { + if (it.next().getValue().created < cutoff) { + it.remove(); + } + } + } + + private static boolean inboundPruneScheduled; + + /// Arms one prune, and only one: the task re-arms itself while anything is still remembered, so + /// a burst of requests does not queue a task each. Caller holds {@link #inboundNodes}. + private static void scheduleInboundPrune() { + if (inboundPruneScheduled || inboundNodes.isEmpty()) { + return; + } + inboundPruneScheduled = true; + replyTimer.schedule(new java.util.TimerTask() { + public void run() { + synchronized (inboundNodes) { + inboundPruneScheduled = false; + pruneInboundOrigins(); + scheduleInboundPrune(); + } + } + }, INBOUND_TTL_MILLIS + 1000L); + } + + /** How long an unanswered inbound request is remembered; outlives the sender's own timeout. */ + private static final long INBOUND_TTL_MILLIS = 60000; + + /// Who asked, and what token they used. Their token is theirs alone; ours identifies the + /// request locally so two nodes cannot collide. + private static final class InboundRequest { + final String nodeId; + final int peerToken; + final long created; + + InboundRequest(String nodeId, int peerToken) { + this.nodeId = nodeId; + this.peerToken = peerToken; + this.created = System.currentTimeMillis(); + } + } + + private static final Map inboundNodes = + new HashMap(); + private static int nextLocalToken = 1; + + /** How long an accepted request may go unanswered before the handler is failed. */ + private static final int REPLY_TIMEOUT_MILLIS = 30000; + + /** + * One daemon timer for every reply deadline in the process. A Timer per request would start a + * thread per request, and a burst of sends would hold all of them for the full timeout. + */ + private static final java.util.Timer replyTimer = new java.util.Timer("cn1-wearable-replies", true); + private static final Map replyTimeouts = + new HashMap(); + + /** + * Fails a pending request that is never answered. {@code deliverReply} removes the token on the + * first call, so a real answer arriving first makes this a no-op even if the task still runs; + * {@link #cancelReplyTimeout} additionally stops it being scheduled at all. + */ + private void scheduleReplyTimeout(final int replyToken) { + java.util.TimerTask task = new java.util.TimerTask() { + public void run() { + synchronized (replyTimeouts) { + replyTimeouts.remove(Integer.valueOf(replyToken)); + } + WearableConnection.deliverReply(replyToken, null, + "The peer did not answer within " + (REPLY_TIMEOUT_MILLIS / 1000) + + " seconds"); + } + }; + synchronized (replyTimeouts) { + replyTimeouts.put(Integer.valueOf(replyToken), task); + } + replyTimer.schedule(task, REPLY_TIMEOUT_MILLIS); + } + + /// Cancels the timeout for a request that has just been answered for real, so a burst of + /// requests does not keep one scheduled task per request alive for the full timeout. + static void cancelReplyTimeout(int replyToken) { + java.util.TimerTask task; + synchronized (replyTimeouts) { + task = replyTimeouts.remove(Integer.valueOf(replyToken)); + } + if (task != null) { + task.cancel(); + } + } + + // --- replicated data ---------------------------------------------------- + + public void putData(String path, byte[] payload) { + // The payload travels inside a DataMap rather than as the item's raw data so it can be + // stamped with a publication sequence. Both halves of a pair may publish the same logical + // path, which the Data Layer stores as two items under two node authorities; without an + // ordering stamp a reader has no way to tell which of them is the newer value. + PutDataMapRequest req = PutDataMapRequest.create(dataPath(path)); + req.getDataMap().putByteArray(PAYLOAD_KEY, payload == null ? new byte[0] : payload); + req.getDataMap().putLong(SEQUENCE_KEY, nextSequence()); + // Urgent: without it the system may sit on the change for minutes, which reads as "my watch + // never updated" even though the API did its job. + dataClient.putDataItem(req.asPutDataRequest().setUrgent()); + } + + /** + * A monotonic publication stamp. Wall-clock millis order correctly against the peer's stamps + * (both devices' clocks are network-synced within far less than a replication round trip), and + * the counter breaks ties between two puts inside the same millisecond on this device. + */ + private static synchronized long nextSequence() { + long now = System.currentTimeMillis(); + lastSequence = now > lastSequence ? now : lastSequence + 1; + persistClock(lastSequence); + return lastSequence; + } + + /** Preference store for the logical clock; see {@link #persistClock}. */ + private static final String CLOCK_PREFS = "cn1.wearable"; + private static final String CLOCK_KEY = "clock"; + private static volatile long persistedClock; + + /** + * Remembers the clock floor across process restarts. + * + *

Once this device has observed a peer sequence ahead of its own wall clock, that floor is + * the only thing keeping its next publish above the peer's existing item. Holding it in a static + * field alone means a restart drops back to local time and publishes something the peer will + * correctly judge older -- silently losing the write. + * + * @param value the clock value to remember + */ + private static void persistClock(long value) { + CN1WearableBridge b = current; + // The listener's context stands in when no bridge exists. A peer item can wake the service + // alone, and returning early there left the observation in static memory only: if Android + // then refused the background activity launch and killed the process, the next launch + // restored the OLDER floor, and an immediate local publication could draw a sequence below + // the peer item that is still published -- silently losing as the older replica. + Context c = b != null ? b.context : serviceContext; + if (c == null || value <= persistedClock) { + return; + } + persistedClock = value; + try { + c.getSharedPreferences(CLOCK_PREFS, Context.MODE_PRIVATE) + .edit().putLong(CLOCK_KEY, value).apply(); + } catch (Throwable unavailable) { + // Best effort: the in-memory floor still holds for this process. + } + } + + /// A context for a cold service process, where the clock still has to be durable. + private static volatile Context serviceContext; + + /// Called by the listener service before it handles anything, so a process that never starts an + /// activity can still restore and persist the logical clock. + static void noteServiceContext(Context context) { + if (context == null || serviceContext != null) { + return; + } + serviceContext = context.getApplicationContext(); + // Restore FIRST: an observation compared against an unrestored floor would look new and + // overwrite a higher stored value with a lower one. + restoreClock(serviceContext); + } + + /** Restores the persisted floor, so the first publish after a restart cannot regress. */ + private static synchronized void restoreClock(Context context) { + try { + long stored = context.getSharedPreferences(CLOCK_PREFS, Context.MODE_PRIVATE) + .getLong(CLOCK_KEY, 0); + persistedClock = stored; + if (stored > lastSequence) { + lastSequence = stored; + } + } catch (Throwable unavailable) { + // No stored floor: wall-clock millis seed the counter as before. + } + } + + /** + * Raises this device's clock past a stamp it has just seen from a peer. + * + *

Wall-clock millis alone are not a sound cross-device order: if one device's clock runs + * ahead -- automatic time switched off, or either clock corrected -- its stamps would beat every + * later write from the other device until real time caught up, which can be hours. + * + *

Observing fixes that without needing synchronised clocks. Every sequence we read from an + * item pushes our own counter past it, so the moment a behind device sees an ahead device's + * stamp it can publish a higher one. Millis remain the seed, which keeps stamps monotonic across + * a process restart and roughly meaningful as a time; the observation is what makes the ORDER + * correct. This is a Lamport clock with a wall-clock floor. + * + * @param seen a sequence read from a published item + */ + static synchronized void observeSequence(long seen) { + if (seen != Long.MIN_VALUE && seen > lastSequence) { + lastSequence = seen; + persistClock(lastSequence); + } + } + + private static long lastSequence; + + public byte[] getData(String path) { + // On the EDT (or Android's main thread) answer from the last known snapshot instead of + // blocking. resolveValue() waits on Play services for up to TIMEOUT_SECONDS, and taking + // that on the EDT freezes painting and input for the duration -- the state queries in this + // class already refuse to do it, and this public getter had no such guard. A caller that + // needs an authoritative read can make one off the EDT; a caller that is painting cannot + // afford five seconds either way. + if (isCallerLatencySensitive()) { + byte[] cached = cachedValue(path); + boolean absent; + synchronized (valueCache) { + absent = knownAbsent.contains(path); + } + if (cached == null && !absent) { + // An empty cache is not an authoritative absence. After a process restart the Data + // Layer has no reason to re-announce an item it already delivered, so nothing + // refills the cache on its own and the getter would report "nothing published" for + // durable state that still exists. Answer null now -- the EDT cannot wait -- but + // populate in the background so the next call is right. + primeValue(path); + } + return cached; + } + // Deliberately the same resolution the listener uses. This used to have its own loop, which + // kept whichever item the buffer yielded first -- so once resolveValue() gained the + // publisher tie-break, getData() could return a different value than the listener had just + // delivered for the same path. One implementation, one answer. + String before = deliveredStamp(path); + try { + ResolvedValue v = resolveValue(context, path); + byte[] out = v == null ? null : v.payload; + // Only if no delivery moved this path while the query was blocked -- otherwise this + // older snapshot would outlive the newer one a delivery has already recorded. + rememberValueIfStampUnchanged(path, before, out); + return out; + } catch (java.io.IOException unavailable) { + // A failed query is NOT an empty path, and the two must not collapse into the same + // answer: the public getter documents null as "no value here", so returning it after a + // timeout invites a caller to clear state for a path that is still published. + // resolveValue throws precisely to keep them apart. The last known snapshot is the + // honest answer -- it is what this device last saw -- and null only when there is not + // even that. + return cachedValue(path); + } + } + + /// Populates the cache for one path in the background, at most once per path per process. + /// + /// Only for the latency-sensitive path, which cannot block. The query itself is the ordinary + /// resolution, and its result is recorded exactly as a delivery would record it, so a + /// publication that lands meanwhile still wins. + private void primeValue(final String path) { + synchronized (primedValues) { + if (!primedValues.add(path)) { + return; + } + } + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + String before = deliveredStamp(path); + try { + ResolvedValue v = resolveValue(context, path); + // Both outcomes go through the SAME stamp-guarded recorder. An AUTHORITATIVE + // absence -- the query answered and the path is empty -- is worth recording, + // because it stops a polling UI re-asking forever: every repaint reading a path + // that genuinely does not exist otherwise scheduled another blocking query on + // the timer that transfer replay, retries and cleanup all share. resolveValue + // THROWS when it cannot ask, so a failure never reaches here and cannot + // masquerade as an absence. + // + // But it has to be anchored like any other stale answer. Writing it + // unconditionally let a query that started before a publication mark the path + // absent AFTER that publication had recorded its value -- masked while the + // cached payload survived, then permanent once the LRU evicted it, because the + // stale absence stopped anything from priming the path again. + rememberValueIfStampUnchanged(path, before, v == null ? null : v.payload); + } catch (Throwable unavailable) { + // Nothing to record; the marker is released below either way. + } finally { + // IN-FLIGHT only, released whatever happened. Holding it for the life of the + // process meant a path whose cached value was later evicted by the LRU could + // never be fetched again -- getData returned null and declined to ask, so a + // durable item stayed unreadable unless a new Data Layer event happened to + // arrive. Releasing it still bounds the work, because a path already being + // queried is not queried again and each query holds the marker for its whole + // duration, so a repainting caller cannot stack them up. + synchronized (primedValues) { + primedValues.remove(path); + } + } + } + }, 0); + } + + /// Enumerates in the background so a cold {@code getDataPaths} is only wrong once. + /// + /// Runs the off-EDT branch of {@code getDataPaths} itself, which already records the snapshot + /// and its generation, rather than duplicating the enumeration. + private void primePaths() { + synchronized (primedValues) { + if (!primedValues.add(PATHS_PRIMED_KEY)) { + return; + } + } + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + try { + getDataPaths(); + } catch (Throwable unavailable) { + // Not the usual case: getDataPaths swallows a Data Layer failure itself. + } + // So the OUTCOME is what decides, not an exception. A transient failure leaves + // pathsCache null, and marking the attempt done on that basis meant every later + // latency-sensitive call answered empty forever without ever asking again. + if (pathsCache == null) { + synchronized (primedValues) { + primedValues.remove(PATHS_PRIMED_KEY); + } + } + } + }, 0); + } + + /// Not a path: paths are prefixed, so this cannot collide with one. + private static final String PATHS_PRIMED_KEY = "\u0000paths"; + + /// Paths whose cold-start population has been attempted, so a repainting caller polling + /// {@code getData} does not queue a query per frame. + private static final java.util.Set primedValues = new java.util.HashSet(); + + /// The payload bytes out of a value's DataMap, never null. + /// + /// @param value a map obtained from {@link #valueMap} + /// @return the published bytes + static byte[] payloadOf(DataMap value) { + byte[] payload = value.getByteArray(PAYLOAD_KEY); + return payload == null ? new byte[0] : payload; + } + + /** + * The DataMap of an ordinary published value, or null when the item is not one -- a file transfer + * (which carries an Asset instead of a payload) or something not written by this API at all. + * This is what keeps transfers out of {@link #getDataPaths()} and out of {@link #getData}. + * + * @param item a received or queried data item + * @return the value's DataMap, or null + */ + static DataMap valueMap(DataItem item) { + try { + DataMap map = DataMapItem.fromDataItem(item).getDataMap(); + return map.containsKey(PAYLOAD_KEY) ? map : null; + } catch (Throwable notADataMap) { + return null; + } + } + + public void removeData(String path) { + // Remembered before the delete is issued. removeData targets wear://*/... -- a WILDCARD -- + // so Play services deletes every authority's replica and the resulting buffer can carry + // tombstones whose authority is a PEER node even though this app initiated the removal. + // Tombstone authorship therefore does not identify who asked, and suppressing only the + // local-authority one still reported the app's own removal back to it. + final String storagePath = dataPath(path); + final long generation = noteLocalRemoval(storagePath); + Uri uri = new Uri.Builder().scheme("wear").authority("*").path(storagePath).build(); + // The marker is dropped again if the delete FAILS. Left standing for its full window, it + // would swallow a genuine peer removal of the same path arriving inside it -- the listener + // reads the path as this device's own pending delete and stays silent, for a removal that + // never happened here. Nothing else would correct that. + // + // Only this operation's own marker: the generation makes a later removeData for the same + // path a different marker, so a failure reported after it cannot clear the newer one. + dataClient.deleteDataItems(uri).addOnFailureListener(new OnFailureListener() { + public void onFailure(Exception e) { + forgetLocalRemovalIfGeneration(storagePath, generation); + } + }); + } + + /// Drops a local-removal marker, but only while it is still the one the caller recorded. + private static void forgetLocalRemovalIfGeneration(String storagePath, long generation) { + synchronized (localRemovals) { + Removal r = localRemovals.get(storagePath); + if (r != null && r.generation == generation) { + localRemovals.remove(storagePath); + } + } + } + + /** + * Paths this device has just asked to remove, with the time it asked. + * + *

Time-bounded rather than cleared on first use: one wildcard delete produces one tombstone + * per replica, so the entry has to outlive all of them, while a peer's genuine removal of the + * same path later must still reach the app.

+ */ + /// Paths a completed query reported as empty. Guarded by {@link #valueCache}, and cleared for a + /// path the moment anything authoritative says it exists again. + private static final java.util.Set knownAbsent = + java.util.Collections.newSetFromMap(new java.util.LinkedHashMap() { + protected boolean removeEldestEntry(Map.Entry eldest) { + // Bounded like valueCache, and for the same reason. Only a later publication of + // the SAME path clears an entry, so an app using record-specific paths would + // otherwise retain every deleted path string for the life of the process. An + // evicted entry costs one extra background query the next time that path is + // read, which is exactly the state before this cache existed. + return size() > MAX_KNOWN_ABSENT; + } + }); + + private static final int MAX_KNOWN_ABSENT = 256; + + private static final Map localRemovals = new HashMap(); + private static final long LOCAL_REMOVAL_WINDOW_MILLIS = 30 * 1000L; + + /** + * A local removal, tagged with the order in which it was recorded. + * + *

The generation is what lets a queued event tell "the marker I saw" from "a marker recorded + * after me". Callbacks are handled on a worker, so handler time and event time are no longer + * the same instant, and a publication received BEFORE a {@code removeData} can run after it. + * Time alone cannot express that ordering safely -- {@code currentTimeMillis} ties and can go + * backwards -- so removals are numbered.

+ */ + private static final class Removal { + final long at; + final long generation; + + Removal(long at, long generation) { + this.at = at; + this.generation = generation; + } + } + + private static long removalGeneration; + + /// The current removal generation, captured by a listener when an event ARRIVES so it can later + /// tell whether the marker it is about to clear is the one it actually saw. + static long currentRemovalGeneration() { + synchronized (localRemovals) { + return removalGeneration; + } + } + + /// @return the generation recorded, so the caller can withdraw exactly this marker + private static long noteLocalRemoval(String storagePath) { + long now = System.currentTimeMillis(); + long generation; + synchronized (localRemovals) { + generation = ++removalGeneration; + localRemovals.put(storagePath, new Removal(now, generation)); + purgeExpiredRemovals(now); + } + return generation; + } + + /// Drops markers whose window has passed. Caller holds the localRemovals monitor. + /// + /// Called from the READ paths as well as from noteLocalRemoval, because expiry that happens + /// only when another removal arrives never happens at all for an app that deletes a burst of + /// record-specific paths and then stops: the markers were retained for the life of the process + /// and every later Data Layer event walked past them. + private static void purgeExpiredRemovals(long now) { + java.util.Iterator> it = localRemovals.entrySet().iterator(); + while (it.hasNext()) { + if (now - it.next().getValue().at > LOCAL_REMOVAL_WINDOW_MILLIS) { + it.remove(); + } + } + } + + /** + * Ends the local-removal window for a path. + * + *

The window exists to cover the tombstones of ONE wildcard delete, and a publication for + * the same path is proof that delete is over. Without this the marker stood for its full + * duration regardless of what happened next, so a peer that republished the path and then + * removed its new value inside the window had that genuine deletion classified as part of this + * device's earlier delete -- the removal was swallowed and the listener kept a value that no + * longer existed, with no later callback to correct it.

+ * + *

Only a marker the caller actually saw is cleared. Callbacks are handled on a worker, so a + * publication received before a {@code removeData} can be handled after it; clearing + * unconditionally then wiped a NEWER marker, and the wildcard tombstones that followed were no + * longer recognised as this device's own delete -- with a replica on each device the + * peer-authority tombstone bypasses the echo check and the app was handed a peer removal + * callback for its own operation.

+ * + * @param observedGeneration the value {@link #currentRemovalGeneration()} returned when the + * event being handled arrived + */ + static void clearLocalRemoval(String storagePath, long observedGeneration) { + synchronized (localRemovals) { + Removal r = localRemovals.get(storagePath); + if (r != null && r.generation <= observedGeneration) { + localRemovals.remove(storagePath); + } + } + } + + /** + * The set of paths whose local-removal window is open right now. + * + *

Captured when a callback ARRIVES, because the worker can run it much later -- two + * first-sight resolutions timing out and retrying is enough to push a handler past the 30-second + * window. Classifying with {@link #isLocallyRemoved} at handler time then found the marker + * expired and announced the app's own wildcard tombstone back to it as a peer removal. The + * window is measured from when the event arrived, which is when the removal was actually still + * in progress.

+ */ + static java.util.Set openRemovals() { + long now = System.currentTimeMillis(); + java.util.Set open = new java.util.HashSet(); + synchronized (localRemovals) { + // Purged rather than merely skipped: this walk is already O(size), so dropping the + // expired entries as it goes costs nothing and is what keeps the map from growing. + purgeExpiredRemovals(now); + for (Map.Entry e : localRemovals.entrySet()) { + open.add(e.getKey()); + } + } + return open; + } + + /** Whether a tombstone for this storage path came from a removal this device asked for. */ + static boolean isLocallyRemoved(String storagePath) { + long now = System.currentTimeMillis(); + synchronized (localRemovals) { + purgeExpiredRemovals(now); + Removal r = localRemovals.get(storagePath); + return r != null; + } + } + + public String[] getDataPaths() { + // Same reasoning as getData: this await can stall a painting thread for TIMEOUT_SECONDS. + // An EDT caller gets the last successful enumeration, or an empty array if there has not + // been one yet, rather than a frozen UI. + if (isCallerLatencySensitive()) { + String[] known = pathsCache; + if (known == null) { + // Never enumerated in this process. Same cold-start problem as getData: the Data + // Layer will not re-announce items it delivered before the restart, so this would + // keep answering "no paths" for state that exists. Enumerate in the background. + primePaths(); + return new String[0]; + } + return known.clone(); + } + int startedGeneration; + synchronized (valueCache) { + startedGeneration = pathsGeneration; + } + try { + DataItemBuffer items = Tasks.await(dataClient.getDataItems(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + List out = new ArrayList(); + for (DataItem item : items) { + String p = item.getUri().getPath(); + if (p == null || isTransferPath(p) || !p.startsWith(PATH_PREFIX) + || valueMap(item) == null) { + // Not ours, or a file transfer. A transfer lives in its own namespace and + // getData() on its storage path would answer with DataMap metadata rather + // than a payload, so it is not a readable replicated path. (The prefix test + // is explicit because the transfer prefix extends the value prefix.) + continue; + } + // Both halves may publish the same logical path, giving two items under two node + // authorities; the API contract is one path per value. + String logical = decode(p.substring(PATH_PREFIX.length())); + if (!out.contains(logical)) { + out.add(logical); + } + } + String[] enumerated = out.toArray(new String[out.size()]); + // Only if no delivery maintained the snapshot while this enumeration was blocked. + // rememberValue adds and removes paths as callbacks arrive, and those callbacks + // have already fired and may not repeat -- so an older enumeration landing on top + // would leave every EDT getDataPaths() answering from it indefinitely. + synchronized (valueCache) { + if (pathsGeneration == startedGeneration) { + pathsCache = enumerated; + pathsGeneration++; + } + } + return enumerated.clone(); + } finally { + items.release(); + } + } catch (Throwable unavailable) { + // A transport failure is not an authoritative "no paths". Returning an empty array let + // a caller clear valid replicated state on a timeout, the same conflation getData had. + // The last successful enumeration is the honest answer. + String[] known = pathsCache; + return known == null ? new String[0] : known.clone(); + } + } + + public void transferFile(String path, String name, byte[] contents) { + // A DataItem's inline payload is capped at about 100KB, which a real file routinely + // exceeds; an Asset is the Data Layer's own answer for bulk and is streamed in the + // background. The DataItem carries the name and the Asset, so the receiver still gets a + // WearableMessage rather than raw bytes. + String fileName = name == null ? "file" : name; + byte[] body = contents == null ? new byte[0] : contents; + long sequence = nextSequence(); + PutDataMapRequest req = PutDataMapRequest.create(transferPath(path, fileName, sequence)); + req.getDataMap().putString("name", fileName); + // The DataItem path is namespaced and filename-suffixed, so the caller's own path has to + // travel with the payload -- a listener routes on the path it was given, not on ours. + req.getDataMap().putString("cn1.path", path); + // A file transfer is a one-shot operation, but a DataItem is a *value*: sending the same + // bytes to the same name twice would produce an identical item, which the Data Layer treats + // as unchanged and never reports, silently dropping the second transfer. The sequence stamp + // makes every invocation a real change. + req.getDataMap().putLong(SEQUENCE_KEY, sequence); + req.getDataMap().putLong(PUBLISHED_AT_KEY, System.currentTimeMillis()); + req.getDataMap().putAsset("asset", Asset.createFromBytes(body)); + dataClient.putDataItem(req.asPutDataRequest().setUrgent()); + expireOwnTransfers(); + } + + /** How long one of our own published transfer items is kept before it is swept. */ + private static final long TRANSFER_RETENTION_MILLIS = 24 * 60 * 60 * 1000L; + + /// The outer bound on a transfer nobody has acknowledged. + /// + /// Retention alone was age-only, and age is not evidence of delivery: a watch offline for more + /// than a day, or one of several watches that had not synced yet, came back to nothing. The + /// receiver never deletes a transfer and the sender kept no record of who had taken it, so the + /// file was simply gone. + /// + /// Retirement now needs an acknowledgement from every peer this device knows about. This cap is + /// the backstop for the peer that never returns -- an uninstalled watch app, a watch unpaired + /// and forgotten -- because without one such a device would pin every file the phone has ever + /// sent, forever. A week is far past any sync window and still bounded. + private static final long TRANSFER_HARD_CAP_MILLIS = 7 * 24 * 60 * 60 * 1000L; + + /// Namespace for delivery acknowledgements. A receiver publishes one per transfer it has + /// actually handed to a listener; the sender reads them to decide what it may retire. + /// + /// Deliberately OUTSIDE the "/cn1" namespace rather than a suffix of it. PATH_PREFIX has no + /// trailing slash, so anything beginning "/cn1" -- "/cn1xk/..." included -- passes the value + /// filter and would have been handed to the app as a replicated change at a path it never + /// published. Transfers escape that only because they are excluded by name a line earlier. + private static final String TRANSFER_ACK_PREFIX = "/cnxk"; + + /// The acknowledgement path for a transfer, scoped to the node that PUBLISHED it. + /// + /// The transfer path alone is not an identity. Two senders -- two watches reacting to the same + /// event -- can produce the same logical path, file name and per-device sequence, and their + /// item URIs then differ only in authority. An acknowledgement keyed on the path alone would + /// answer for both, and the sender that had not been delivered to would delete a transfer + /// nobody had taken. + static String transferAckPath(String publisherNode, String transferPath) { + return TRANSFER_ACK_PREFIX + "/" + publisherNode + transferPath; + } + + /// The publisher-scoped transfer key an acknowledgement refers to, or null when this path is + /// not an acknowledgement. Compared against {@link #transferKey}. + static String ackedTransferKey(String ackPath) { + return ackPath != null && ackPath.startsWith(TRANSFER_ACK_PREFIX + "/") + ? ackPath.substring(TRANSFER_ACK_PREFIX.length() + 1) : null; + } + + /// The identity of a transfer item: who published it, and at what path. + static String transferKey(String publisherNode, String transferPath) { + return publisherNode + transferPath; + } + /** Floor between sweeps; retention is a day, so sweeping more often than this buys nothing. */ + private static final long SWEEP_MIN_INTERVAL_MILLIS = 5 * 60 * 1000L; + private final Object sweepLock = new Object(); + private boolean sweepScheduled; + private long lastSweepAt; + + /** + * Deletes transfer items this device published long enough ago that the peer has had every + * reasonable chance to take them. + * + *

Putting the sequence in the item path is what stops a second transfer replacing a first + * that has not synced yet -- but it also means nothing ever reuses a URI, so without a sweep an + * app that transfers regularly would grow its Data Layer storage without bound. Only our own + * items are touched, and only old ones: a receiver still never deletes, because that would + * propagate and rob a second watch of the file. + */ + private void expireOwnTransfers() { + // The deadline is armed UNCONDITIONALLY, before the coalescing check, because it belongs to + // the item that was just published rather than to this call. Arming it after the check + // meant a transfer published inside the five-minute coalescing window never got one: the + // earlier task woke at the FIRST item's deadline, found the later item still too young, and + // put its next sweep a full day out -- so that item could stay published for nearly 48 + // hours while receiver claims are pruned at 24, and a reconnect could redeliver a supposedly + // one-shot file. Retention is a promise about the item. + // + // ONE task for the earliest outstanding deadline, not one per item. Every task did the same + // global sweep, so an app sending continuously retained tens of thousands of them for a day + // and then woke the shared timer in a burst. When the task fires, the sweep rearms it for + // the oldest item that is still published -- which is exact, needs no queue of deadlines, + // and stops on its own when nothing is left. + armSweepDeadline(System.currentTimeMillis() + TRANSFER_RETENTION_MILLIS + 1000L); + sweepOwnTransfers(); + } + + /// The absolute time the pending deadline task will fire, or 0 when none is pending. + private long sweepDeadlineAt; + + private java.util.TimerTask sweepDeadlineTask; + + /// Ensures a deadline sweep happens no later than {@code at}. + /// + /// An existing task that already fires by then is left alone -- that is what collapses a burst + /// of transfers into a single timer entry, since their deadlines only ever move later. A task + /// is replaced only when something genuinely needs an EARLIER sweep, which is why the cancelled + /// one is purged rather than left to expire in the queue. + private void armSweepDeadline(long at) { + synchronized (sweepLock) { + if (sweepDeadlineTask != null && sweepDeadlineAt <= at) { + return; + } + if (sweepDeadlineTask != null) { + sweepDeadlineTask.cancel(); + transferTimer.purge(); + } + sweepDeadlineAt = at; + sweepDeadlineTask = new java.util.TimerTask() { + public void run() { + synchronized (sweepLock) { + sweepDeadlineTask = null; + sweepDeadlineAt = 0; + } + sweepOwnTransfers(); + } + }; + long delay = at - System.currentTimeMillis(); + transferTimer.schedule(sweepDeadlineTask, delay < 0 ? 0 : delay); + } + } + + private boolean deferredSweepScheduled; + + /// Re-runs a sweep once the coalescing interval has elapsed. At most one is pending; it is not + /// a per-call chain. Caller holds {@link #sweepLock}. + private void scheduleDeferredSweep(long delay) { + if (deferredSweepScheduled) { + return; + } + deferredSweepScheduled = true; + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + synchronized (sweepLock) { + deferredSweepScheduled = false; + } + sweepOwnTransfers(); + } + }, delay < 0 ? 0 : delay + 1000L); + } + + /** + * Offers, once per app start, anything still published that this process has not delivered: + * inbound transfers that were never claimed, and replicated values with no delivery stamp. + * + *

Nothing else covers this. A transfer can wake the listener service in a cold process, and + * if Android refuses the background activity launch the payload exists only in + * WearableConnection's in-memory queue -- so killing that process before the user opens the app + * loses it. The DataItem itself is untouched, but it is also UNCHANGED, and an unchanged item + * raises no callback for a process that starts later, so the app would never see it and the + * sender would eventually sweep the only durable copy. + * + *

The claim is what makes this safe to run unconditionally: an item already handed over has + * a durable claim, so {@code claimTransfer} refuses it here and nothing is delivered twice.

+ */ + private void replayOutstandingTransfers() { + replayOutstandingTransfers(1, System.currentTimeMillis()); + } + + /// Linear backoff, capped, so a long outage is cheap but a short one recovers quickly. + private static long replayDelay(int attempt) { + long linear = REPLAY_RETRY_MILLIS * (long) attempt; + return linear > REPLAY_RETRY_CAP_MILLIS ? REPLAY_RETRY_CAP_MILLIS : linear; + } + + /// Replay retries are bounded by the sender's RETENTION WINDOW, not by an attempt count. + /// + /// Five tries spanned about twenty seconds, and an outage longer than that ended the only + /// recovery this transfer has -- the item stays published for a day, raises no callback because + /// it never changes, and is then swept. So the chain runs until an enumeration actually + /// succeeds or the window has passed, backing off to a minute so a long outage costs one + /// failing query an hour rather than a busy loop. + private static final long REPLAY_RETRY_MILLIS = 5000; + private static final long REPLAY_RETRY_CAP_MILLIS = 60 * 1000L; + + private void replayOutstandingTransfers(final int attempt, final long startedAt) { + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + boolean replayFailed = false; + try { + // The local identity FIRST, and no replay at all without it. A transient + // getLocalNode() failure returning null used to read as "not local", so the + // replay claimed this device's OWN outbound transfers and handed the sender its + // own one-shot file through its own data listener. + // Named apart from the `localNode` FIELD, which is a cache: this must be the + // value just resolved. + String replayNode = localNodeId(context); + if (replayNode == null) { + throw new java.io.IOException("local node identity unavailable"); + } + DataItemBuffer items = Tasks.await(dataClient.getDataItems(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + for (DataItem item : items) { + final Uri uri = item.getUri(); + String p = uri == null ? null : uri.getPath(); + if (p == null) { + continue; + } + if (!isTransferPath(p)) { + // An ordinary replicated VALUE. The same loss applies to it: the + // event that woke the service can die with that process when + // Android refuses the activity launch, and the DataItem is then + // unchanged, so nothing re-announces it and a listener registered + // from init() stays stale until the peer publishes again -- which + // contradicts the cold-start replay this API promises. + // + // Routed through the ordinary winner resolution rather than + // delivered from here: that is the code that decides between two + // publishers and records the delivery stamp, and a path this + // process HAS already delivered has a stamp, so nothing is + // delivered twice. + if (p.startsWith(PATH_PREFIX) && valueMap(item) != null + && !replayNode.equals(uri.getHost())) { + // Items THIS device published are not worth a resolution: the + // resolver now suppresses a local winner anyway, so asking + // would cost a blocking query to reach the same silence. A path + // a peer also published is scheduled by that peer's item, which + // is in this same enumeration. + String appPath = decode(p.substring(PATH_PREFIX.length())); + if (!hasDeliveredStamp(appPath)) { + scheduleReplayResolution(context, appPath); + } + } + continue; + } + // Our own outbound transfer: handing it back would deliver the sender + // its own file. + if (replayNode.equals(uri.getHost())) { + continue; + } + // The claim is refreshed BEFORE the payload is decoded, because a + // decode can fail: an unreadable asset returns early, and the prune at + // the end of this pass would then drop an aged claim for an item nobody + // had examined -- so the retry that decodeTransfer schedules would find + // no claim and deliver the one-shot file a second time. Refreshing + // first also skips reading a potentially large asset we have already + // handed over. + if (hasAnyClaim(context, uri)) { + refreshClaim(context, uri); + continue; + } + Transfer t = decodeTransfer(context, item); + if (t == null || t.payload == null) { + continue; + } + final long seq = sequenceOf(valueOrTransferMap(item)); + // claimTransfer applies the age-independent rule for both paths. + if (!claimTransfer(context, uri, seq)) { + // Already delivered, in this process or a previous one. + continue; + } + WearableConnection.deliverDataChangedTracked( + t.logicalPath, t.payload, new Runnable() { + public void run() { + confirmTransferDelivered(context, uri, seq, true); + } + }, new Runnable() { + public void run() { + relinquishTransfer(context, uri); + } + }); + } + } finally { + items.release(); + } + } catch (Throwable unavailable) { + replayFailed = true; + } + if (!replayFailed) { + // Only now, with every still-published item examined and its claim refreshed, + // is it safe to drop the aged ones: whatever is left describes an item that is + // genuinely gone. This is the startup prune the constructor used to do first. + pruneClaims(context); + } + if (replayFailed + && System.currentTimeMillis() - startedAt < TRANSFER_RETENTION_MILLIS) { + // Retried for as long as the sender may still be holding the item. This pass is + // the ONLY cover for a transfer whose cold-start delivery died with the service + // process: the DataItem is unchanged, so no normal callback is guaranteed, and + // an app that stays open through an outage would otherwise lose the file when + // the sender eventually sweeps it. + replayOutstandingTransfers(attempt + 1, startedAt); + } + } + }, attempt == 1 ? 0 : replayDelay(attempt)); + } + + /// The sweep itself, coalesced. Schedules no follow-up beyond a deferred retry when coalescing + /// suppressed it: see [#expireOwnTransfers]. + private void sweepOwnTransfers() { + // One sweep at a time, and not more often than the interval. A burst of transfers used to + // schedule one immediate task per call, each blocking on a full DataItem query and scan -- + // on the same single timer the unreadable-asset retries use, so transfer traffic starved + // the retries it was most likely to need. + synchronized (sweepLock) { + long now = System.currentTimeMillis(); + if (sweepScheduled || now - lastSweepAt < SWEEP_MIN_INTERVAL_MILLIS) { + // Deferred, not dropped. An item's own deadline task can land inside the coalescing + // window opened by a neighbouring transfer -- the earlier sweep ran just before + // this deadline and found this item too young -- and simply returning left the next + // guaranteed sweep at some other item's deadline up to a day later. The item then + // outlived its retention window while receiver claims expired on time, which is + // exactly the stale-redelivery case retention exists to prevent. + if (!sweepScheduled) { + scheduleDeferredSweep(SWEEP_MIN_INTERVAL_MILLIS - (now - lastSweepAt)); + } + return; + } + sweepScheduled = true; + lastSweepAt = now; + } + final long cutoff = System.currentTimeMillis() - TRANSFER_RETENTION_MILLIS; + final long hardCutoff = System.currentTimeMillis() - TRANSFER_HARD_CAP_MILLIS; + // The earliest ABSOLUTE time a decision could change for something this sweep keeps. + // Tracking the oldest publish time instead assumed every kept item was waiting on the + // retention window, but an unacknowledged one waits on the hard cap -- so the deadline + // fired at once, found nothing to do and rearmed on the same instant. + final long[] nextDue = {Long.MAX_VALUE}; + // Every peer this device knows of, connected or merely paired. A transfer is retired once + // ALL of them have taken it: one watch of several having synced is not enough, which is + // exactly the case a single acknowledgement would get wrong. Never ourselves -- our own + // items are echoed back, and waiting for an acknowledgement this device will never write + // would pin every transfer to the cap. + final java.util.Set expected = new java.util.HashSet(bondedNodeIds()); + for (Node connected : connectedNodes()) { + expected.add(connected.getId()); + } + expected.remove(localNodeId(context)); + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + boolean failed = false; + try { + // Resolved BEFORE the enumeration, and an unavailable identity is a FAILED + // sweep. Treating null as "every item is remote" skipped them all while + // reporting success, so the retry never armed and an outbound transfer could + // stay published past the point where receiver claims expire. + // + // Named apart from the `localNode` FIELD on purpose: this must be the value + // just resolved, not whatever the cache happens to hold. + String sweepNode = localNodeId(context); + if (sweepNode == null) { + throw new java.io.IOException("local node identity unavailable"); + } + DataItemBuffer items = Tasks.await(dataClient.getDataItems(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + // Acknowledgements first, in a pass of their own: the enumeration has no + // useful order, so a transfer can be seen before the acknowledgement that + // retires it. + java.util.Map> acked = + new java.util.HashMap>(); + java.util.List ownAcks = new java.util.ArrayList(); + java.util.Set livePaths = new java.util.HashSet(); + for (DataItem item : items) { + String ackPath = item.getUri().getPath(); + if (ackPath == null) { + continue; + } + if (isTransferPath(ackPath)) { + livePaths.add(transferKey(item.getUri().getHost(), ackPath)); + continue; + } + String forPath = ackedTransferKey(ackPath); + if (forPath == null) { + continue; + } + String acker = item.getUri().getHost(); + if (sweepNode.equals(acker)) { + // OUR acknowledgement, for something a peer sent us. Only its + // author may delete it -- deleting another node's item propagates + // and would rob a second watch -- so ours are cleaned up here, + // once the transfer they vouch for is gone. + ownAcks.add(item.getUri()); + continue; + } + java.util.Set ackers = acked.get(forPath); + if (ackers == null) { + ackers = new java.util.HashSet(); + acked.put(forPath, ackers); + } + ackers.add(acker); + } + boolean retainedAck = false; + for (Uri ownAck : ownAcks) { + if (!livePaths.contains(ackedTransferKey(ownAck.getPath()))) { + // The item's OWN uri, not one rebuilt from parts: it already + // carries the authority the Data Layer expects. + Tasks.await(dataClient.deleteDataItems(ownAck), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + } else { + retainedAck = true; + } + } + if (retainedAck) { + // An acknowledgement we are KEEPING, because the transfer it vouches + // for is still published. Only its author can delete it, and nothing + // else will schedule that: the sender's eventual deletion of the + // transfer raises a callback the listener's deletion branch does not + // sweep on, so a receive-only process that stays alive would keep it + // for good. + // + // A slow poll rather than a precise deadline, because the receiver + // cannot know when the sender will retire its item -- the sender may be + // offline for days. One sweep a day over a handful of small items. + long recheck = System.currentTimeMillis() + TRANSFER_RETENTION_MILLIS; + if (recheck < nextDue[0]) { + nextDue[0] = recheck; + } + } + for (DataItem item : items) { + String p = item.getUri().getPath(); + if (p == null || !isTransferPath(p)) { + continue; + } + // Only our OWN items. getDataItems() also returns transfers replicated + // from other nodes, and deleting one of those propagates -- which is + // precisely how a second watch loses a file it has not collected yet. + // A publisher is responsible for its own items and nobody else's. + if (!sweepNode.equals(item.getUri().getHost())) { + continue; + } + DataMap map = valueOrTransferMap(item); + // Age, not order: the sequence is a logical clock and may have been + // raised far past local time by a peer, so it says nothing about when + // this item was published. + long publishedAt = map == null + ? Long.MIN_VALUE : map.getLong(PUBLISHED_AT_KEY, Long.MIN_VALUE); + // Age is not delivery. Retired once every known peer has said it + // took the file, or -- for the peer that never comes back -- once the + // hard cap has passed. A transfer with no known peers waits for the cap + // too: nobody can acknowledge it, and deleting on age alone is what + // lost files to an offline watch. + java.util.Set ackers = acked.get(transferKey(sweepNode, p)); + boolean allTook = !expected.isEmpty() && ackers != null + && ackers.containsAll(expected); + boolean retire = publishedAt != Long.MIN_VALUE + && ((allTook && publishedAt < cutoff) + || publishedAt < hardCutoff); + if (!retire && publishedAt != Long.MIN_VALUE) { + // The deadline that actually applies to THIS item. + long due = publishedAt + (allTook + ? TRANSFER_RETENTION_MILLIS : TRANSFER_HARD_CAP_MILLIS); + if (due < nextDue[0]) { + nextDue[0] = due; + } + } + if (retire) { + // Awaited, so a deletion that fails is not counted as a sweep that + // succeeded. Firing and forgetting left `failed` false while the + // item stayed published, and for the LAST transfer there may be no + // later sweep -- so it outlived the receiver claims that expire at + // the same age, and a reconnect could redeliver a one-shot file. + Tasks.await(dataClient.deleteDataItems(item.getUri()), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + } finally { + items.release(); + } + } catch (Throwable unavailable) { + failed = true; + } finally { + synchronized (sweepLock) { + sweepScheduled = false; + if (!failed && nextDue[0] != Long.MAX_VALUE) { + // Rearmed for what is still published. Outside the failure branch: a + // sweep that threw enumerated nothing reliable, and its retry below + // rearms this when it succeeds. + armSweepDeadline(nextDue[0] + 1000L); + } + if (failed) { + // Retried, not abandoned. "The next transfer sweeps again" assumes + // there IS a next transfer: an app that sends its last file and then + // hits a transient getDataItems() failure at that item's deadline left + // it published for good, and once the receiver's claim is pruned at 24 + // hours a reconnect can redeliver a one-shot file. Retrying costs one + // task on a timer that is otherwise idle. + // + // lastSweepAt was set when this attempt started, so the deferred sweep + // is not suppressed by its own coalescing window. + scheduleDeferredSweep(SWEEP_MIN_INTERVAL_MILLIS); + } + } + } + } + }, 0); + } + + /** + * Rebuilds the {@code WearableMessage} form of a file transfer, or null when the item is an + * ordinary published value rather than a transfer. + * + * @param context any context + * @param item the received data item + * @return the encoded payload, or null + */ + static Transfer decodeTransfer(Context context, DataItem item) { + Transfer t = decodeTransferOnce(context, item); + if (t == Transfer.UNREADABLE) { + scheduleTransferRetry(context, item.getUri()); + } + return t; + } + + /// One attempt, with no retry scheduling -- the form the retry itself uses. + private static Transfer decodeTransferOnce(Context context, DataItem item) { + DataMap map; + Asset asset; + try { + map = DataMapItem.fromDataItem(item).getDataMap(); + asset = map.getAsset("asset"); + } catch (Throwable notADataMap) { + return Transfer.NOT_A_TRANSFER; + } + if (asset == null) { + return Transfer.NOT_A_TRANSFER; + } + try { + java.io.InputStream in = Tasks.await( + Wearable.getDataClient(context.getApplicationContext()).getFdForAsset(asset), + TIMEOUT_SECONDS, TimeUnit.SECONDS).getInputStream(); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } finally { + // The stream is backed by a ParcelFileDescriptor. A read that throws part way -- + // an interrupted or corrupt transfer -- used to skip the close and go straight to + // the retry, so a file that kept failing leaked a descriptor per attempt. + try { + in.close(); + } catch (java.io.IOException alreadyBroken) { + // Nothing useful to do; the descriptor is released either way. + } + } + // The caller's own path, not the namespaced DataItem one; returned alongside the payload + // because the delivery path routes on the path it is given, which would otherwise be the + // filename-suffixed storage path. + String logical = map.getString("cn1.path", item.getUri().getPath()); + return Transfer.of(logical, new WearableMessage(logical) + .put("name", map.getString("name", "file")) + .put("contents", out.toByteArray()) + .toByteArray()); + } catch (Throwable assetUnreadable) { + // A transient download failure -- typically getFdForAsset timing out while the system + // is still streaming the bytes. Forwarding DataItem.getData() here would hand the + // listener DataMap metadata dressed up as a payload, so retry instead: keeping the item + // published is not by itself enough, because an unchanged item produces no further + // callback and the transfer would be lost for good. + return Transfer.UNREADABLE; + } + } + + /** How many times, and how far apart, an unreadable asset is re-fetched before giving up. */ + private static final int TRANSFER_RETRIES = 4; + private static final long TRANSFER_RETRY_MILLIS = 3000; + + /** + * Re-reads a transfer whose asset could not be resolved, on the shared timer. Each attempt goes + * back to the Data Layer for the item, so a transfer that was still streaming lands as soon as + * it is complete; after the last attempt the transfer is genuinely dropped. + */ + private static void scheduleTransferRetry(final Context context, final Uri uri) { + scheduleTransferRetry(context, uri, 1); + } + + /** + * Retries run on their own timer, not on {@link #replyTimer}. A retry blocks on + * {@code Tasks.await} and then reads the whole asset stream, and the reply timer is a single + * thread that also owns every pending 30-second reply deadline -- a slow or large asset would + * delay those deadlines, so a request that timed out would be reported late or not at all. + */ + private static final java.util.Timer transferTimer = + new java.util.Timer("cn1-wearable-transfers", true); + + private static void scheduleTransferRetry(final Context context, final Uri uri, final int attempt) { + if (uri == null) { + return; + } + // Bounded by the sender's RETENTION WINDOW, not by an attempt count. A fixed four tries + // abandoned a transfer whose Asset was merely slow -- a large file on a poor connection -- + // while the sender keeps the DataItem for 24 hours and the item never changes, so the + // delivery model provides no later callback to pick it up again. The one-shot file was + // simply lost. Retrying until either the Asset reads or the item is gone matches the + // lifetime the sender actually promises. + if (attempt > TRANSFER_RETRIES && retryElapsed(uri) > TRANSFER_RETENTION_MILLIS) { + forgetRetryStart(uri); + return; + } + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + try { + DataItemBuffer items = Tasks.await( + Wearable.getDataClient(context.getApplicationContext()).getDataItems(uri), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + if (items.getCount() == 0) { + // An authoritative EMPTY answer: the item is gone. Typically the + // sender's retention sweep reached it while its asset was still + // unreadable here. Rescheduling would then poll a deleted URI every few + // minutes for the rest of the retention window, on the single timer + // that startup replay and cleanup also use, and no attempt could ever + // succeed. Stop and forget the chain. + forgetRetryStart(uri); + return; + } + for (DataItem item : items) { + Transfer t = decodeTransferOnce(context, item); + // Recheck authorship. The listener suppressed this as our own echo and + // only an unreadable Asset sent it down the retry path -- arriving here + // does not make our own transfer someone else's. + if (t.payload != null && isLocallyAuthored(context, uri.getHost())) { + // Decoded, and it is ours. Nothing to deliver -- but the chain must + // STOP here rather than fall through and reschedule: it would + // otherwise reopen and re-read our own file every few minutes for + // the whole 24-hour retention window, occupying the shared transfer + // timer and re-reading a potentially large asset each time. + forgetRetryStart(uri); + return; + } + if (t.payload != null) { + long tseq = sequenceOf(valueOrTransferMap(item)); + if (claimTransfer(context, uri, tseq)) { + // Confirmed from INSIDE the delivery, not from its dispatch. + // deliverDataChangedTracked returning true only means the + // runnable reached the EDT; a process death before it ran would + // persist a claim for a file the app never saw and suppress the + // redelivery that would have replaced it. + final Uri claimed = uri; + final long claimedSeq = tseq; + WearableConnection.deliverDataChangedTracked( + t.logicalPath, t.payload, new Runnable() { + public void run() { + confirmTransferDelivered(context, claimed, claimedSeq, true); + } + }, new Runnable() { + public void run() { + // The direct callback path releases an evicted + // claim; this one has to as well. It is in fact + // the worse case: the retry chain stops right + // below, so without this nothing would look at + // this item again for the life of the process. + relinquishTransfer(context, claimed); + } + }); + } + // Released on SUCCESS as well as on give-up. Every transfer has a + // sequence-suffixed URI, so a receiver that handles many slow + // transfers would otherwise keep one entry per transfer forever. + forgetRetryStart(uri); + return; + } + } + } finally { + items.release(); + } + scheduleTransferRetry(context, uri, attempt + 1); + } catch (Throwable stillUnavailable) { + scheduleTransferRetry(context, uri, attempt + 1); + } + } + }, retryDelay(attempt)); + } + + /** + * Backoff for transfer retries: linear while the failure may be momentary, then capped. + * + *

Capped rather than exponential because the wait has to stay short enough to catch an + * Asset that finishes downloading hours in -- an unbounded doubling would be sleeping for + * hours by then and the retention window would expire mid-sleep.

+ */ + private static long retryDelay(int attempt) { + long linear = TRANSFER_RETRY_MILLIS * attempt; + return linear > TRANSFER_RETRY_CAP_MILLIS ? TRANSFER_RETRY_CAP_MILLIS : linear; + } + + private static final long TRANSFER_RETRY_CAP_MILLIS = 5 * 60 * 1000L; + private static final Map retryStarts = new HashMap(); + + private static long retryElapsed(Uri uri) { + String key = uri.toString(); + long now = System.currentTimeMillis(); + synchronized (retryStarts) { + Long started = retryStarts.get(key); + if (started == null) { + retryStarts.put(key, Long.valueOf(now)); + return 0L; + } + return now - started.longValue(); + } + } + + private static void forgetRetryStart(Uri uri) { + synchronized (retryStarts) { + retryStarts.remove(uri.toString()); + } + } + + /** + * The outcome of inspecting a received DataItem: an ordinary published value, a decoded file + * transfer, or a transfer whose asset could not be read this time. + */ + static final class Transfer { + static final Transfer NOT_A_TRANSFER = new Transfer(null, null, false); + static final Transfer UNREADABLE = new Transfer(null, null, true); + + final byte[] payload; + /** The path the sender passed to {@code transferFile}, which is what listeners route on. */ + final String logicalPath; + final boolean isTransfer; + + private Transfer(String logicalPath, byte[] payload, boolean isTransfer) { + this.logicalPath = logicalPath; + this.payload = payload; + this.isTransfer = isTransfer; + } + + static Transfer of(String logicalPath, byte[] payload) { + return new Transfer(logicalPath, payload, true); + } + } + + // --- paths -------------------------------------------------------------- + + static String dataPath(String path) { + return PATH_PREFIX + encode(path); + } + + /// The DataItem path a file transfer is stored at: its own namespace, and suffixed with the file + /// name so two files sent to one logical path do not overwrite each other. The logical path + /// travels in the DataMap, because this is not it. + /// + /// @param path the path the sender passed to transferFile + /// @param fileName the file's name + /// @return the DataItem path + static String transferPath(String path, String fileName, long sequence) { + // The sequence is part of the URI, not just the payload. A transfer is one-shot, so two + // sends to the same path and name while the peer is offline have to queue as two items -- + // sharing a URI meant the second Asset replaced the first before it could ever sync. + return TRANSFER_PREFIX + encode(path + "/" + fileName) + "/" + Long.toHexString(sequence); + } + + /// True when a DataItem path belongs to the transfer namespace. + /// + /// @param path a DataItem path + /// @return true for a transfer item + static boolean isTransferPath(String path) { + return path != null && path.startsWith(TRANSFER_PREFIX); + } + + static String transferPrefix() { + return TRANSFER_PREFIX; + } + + + /** + * Forgets a path's delivery stamp, so a value republished after a removal is delivered even if + * the publisher's clock produced a lower stamp than the removed value carried. + * + * @param path the logical path + */ + /** + * Records a delivery stamp outright, replacing whatever was there. + * + *

Distinct from the outranks-guarded delivery, which refuses to go backwards. After a + * deletion the surviving item can legitimately carry a LOWER sequence than the winner that was + * just removed, so the newer-than test would decline to record it and leave the dead winner's + * stamp in place -- filtering out a later item that sits between the two. + * + * @param path the application path + * @param sequence the surviving item's sequence + * @param node the surviving item's publishing node + * @return true when this differs from what was last delivered, and so is worth delivering + */ + /** + * Records a delivery stamp unconditionally, reporting whether it changed. + * + *

Unconditional on purpose, and only correct where the stamp being replaced describes an + * item that is now GONE -- the deletion-survivor path, where the survivor routinely carries a + * lower sequence than the winner just removed. Anywhere the recorded stamp may still describe + * a live newer value, use {@link #setDeliveredSequenceIfOutranks} instead: this method will + * happily overwrite newer state with older.

+ */ + static boolean setDeliveredSequence(String path, long sequence, String node) { + String stamp = sequence + "|" + (node == null ? "" : node); + synchronized (deliveredSequences) { + String previous = deliveredSequences.put(path, stamp); + return !stamp.equals(previous); + } + } + + /** + * Records a delivery stamp only when it outranks the one recorded now, atomically. + * + *

This is what a resolution that BLOCKED needs. Between {@code resolveValue()} returning and + * the caller acting on it, an ordinary Data Layer callback can deliver a newer publication for + * the same path; replacing the stamp then hands the app an older payload and leaves the older + * stamp recorded, so the newer value stays hidden behind it. The compare and the replace have + * to happen under one lock, or the check is just a smaller window.

+ * + * @return true when the stamp was taken and the payload should be delivered + */ + static boolean setDeliveredSequenceIfOutranks(String path, long sequence, String node) { + String stamp = sequence + "|" + (node == null ? "" : node); + synchronized (deliveredSequences) { + String previous = deliveredSequences.get(path); + if (previous != null && !outranks(sequence, node, + stampSequence(previous), stampNode(previous))) { + return false; + } + String old = deliveredSequences.put(path, stamp); + return !stamp.equals(old); + } + } + + /** + * Replaces a path's stamp only while it still matches {@code expected}, atomically. + * + *

The rule the deletion paths need, and neither of the other two primitives expresses it. + * {@link #setDeliveredSequence} would clobber a newer publication that landed mid-query; + * {@link #setDeliveredSequenceIfOutranks} would refuse the survivor, because after a deletion + * the recorded stamp belongs to the item that was just removed and a survivor is very often + * older than it. Anchoring on the pre-query snapshot gets both: the dead item's stamp is + * replaced whatever its number, and anything that arrived while we were asking wins instead.

+ * + * @param expected the stamp read before the query, or null if the path had none + * @return true when the stamp was taken and the payload should be delivered + */ + static boolean setDeliveredSequenceIfStampUnchanged(String path, String expected, + long sequence, String node) { + String stamp = sequence + "|" + (node == null ? "" : node); + synchronized (deliveredSequences) { + String current = deliveredSequences.get(path); + boolean unchanged = current == null ? expected == null : current.equals(expected); + if (!unchanged) { + return false; + } + String old = deliveredSequences.put(path, stamp); + return !stamp.equals(old); + } + } + + /** + * Commits an ordering decision AND the delivery it authorises as one step. + * + *

Doing the compare-and-replace atomically is not enough on its own: between committing the + * stamp and calling deliverDataChanged, an ordinary callback for a NEWER publication can run, + * advance the stamp and emit its payload -- and then this caller emits its older payload after + * it. The cache is left holding the newer stamp, so the newer value is rejected if it is ever + * seen again, and nothing is left to correct the listener. Ordering the stamps without ordering + * the deliveries just moves the race one line down.

+ * + *

Safe to hold the monitor across the dispatch because + * {@link WearableConnection#deliverDataChanged} does not run listener code on this thread -- it + * either parks the delivery on the pending queue or hands it to the EDT. No application code + * runs under this lock.

+ */ + static boolean deliverIfOutranks(String path, long sequence, String node, byte[] payload) { + synchronized (deliveredSequences) { + if (!setDeliveredSequenceIfOutranks(path, sequence, node)) { + return false; + } + rememberValue(path, payload); + WearableConnection.deliverDataChanged(path, payload); + return true; + } + } + + /** + * Records a suppressed local echo: stamp and snapshot together, or neither. + * + *

Doing the two separately let them disagree. The stamp update is conditional -- a newer + * publication recorded meanwhile makes it decline -- while the snapshot write was + * unconditional, so a rejected echo still replaced the cached payload with its older bytes and + * a latency-sensitive getData() answered from it indefinitely, even though the delivery stamp + * already tracked the newer peer value. A newer update landing between the two calls did the + * same.

+ */ + /** + * Records a locally authored winner after a deletion: the anchored replacement, and the + * snapshot, as ONE step. + * + *

Two separate anchored calls would leave a window between them. A peer publication landing + * there advances the stamp and caches its own payload, and the second call then finds the + * anchor stale and does nothing -- or, worse, an unanchored one would overwrite the value + * alone, so {@code getData} answered with local bytes while the stamp and the listener tracked + * the peer's. Nothing is dispatched: this is the suppressed local-winner path.

+ */ + static boolean recordLocalWinnerIfStampUnchanged(String path, String expected, long sequence, + String node, byte[] payload) { + synchronized (deliveredSequences) { + if (!setDeliveredSequenceIfStampUnchanged(path, expected, sequence, node)) { + return false; + } + rememberValue(path, payload); + return true; + } + } + + static boolean recordLocalEcho(String path, long sequence, String node, byte[] payload) { + synchronized (deliveredSequences) { + if (!setDeliveredSequenceIfOutranks(path, sequence, node)) { + return false; + } + rememberValue(path, payload); + return true; + } + } + + /** + * Stores a read result only while the path's delivery stamp is still the one the read started + * from. + * + *

An off-EDT getData() blocks, and a delivery landing while it does has already advanced the + * stamp and may not fire again. Writing the query's older snapshot afterwards would leave every + * later latency-sensitive read answering with it.

+ */ + static void rememberValueIfStampUnchanged(String path, String expected, byte[] payload) { + synchronized (deliveredSequences) { + String current = deliveredStamp(path); + boolean unchanged = current == null ? expected == null : current.equals(expected); + if (unchanged) { + rememberValue(path, payload); + } + } + } + + /** As {@link #deliverIfOutranks}, for the deletion paths that anchor on a pre-query stamp. */ + static boolean deliverIfStampUnchanged(String path, String expected, long sequence, String node, + byte[] payload) { + synchronized (deliveredSequences) { + if (!setDeliveredSequenceIfStampUnchanged(path, expected, sequence, node)) { + return false; + } + rememberValue(path, payload); + WearableConnection.deliverDataChanged(path, payload); + return true; + } + } + + /** + * Drops the stamp and announces the removal as one step, and only while the stamp is still the + * one read before the query. + * + *

{@code expected == null} returns false rather than removing: it means either that the app + * was never told this path had a value (so a removal would be an event that never happened) or + * that another deletion event in the same buffer already announced it -- which is what keeps + * one logical removal from being reported once per replica.

+ */ + static boolean deliverRemovalIfStampUnchanged(String path, String expected) { + synchronized (deliveredSequences) { + if (expected == null || !forgetDeliveredSequenceIfUnchanged(path, expected)) { + return false; + } + // Drop the snapshot with the stamp, or an EDT getData would keep answering with a value + // the app has just been told was removed. + rememberValue(path, null); + // A sentinel in its place, so the SIBLINGS of this delete find one. Consuming the + // stamp and leaving the path absent meant the next tombstone of the same wildcard + // delete -- a replica published by another node -- read as first sight and announced + // the same logical removal a second time; only the third onwards were suppressed. The + // stamped branch and the first-sight branch now leave the path in the same state. + // + // Harmless to ordering: a sentinel carries no sequence, so stampSequence reads it as + // the weakest possible value and any real republication outranks it -- which is the + // same reason dropping the stamp was safe. + markRemovalAnnounced(path); + WearableConnection.deliverDataRemoved(path); + return true; + } + } + + /** + * Drops the ordering baseline and the cached snapshot together after this device's own removal, + * but only while the baseline is still the one the caller observed. + * + *

The two have to move as one, anchored on a stamp captured BEFORE anything is cleared. A + * deferred resolution can deliver a peer republish concurrently with a local tombstone: reading + * the stamp after clearing the snapshot would read the REPUBLISH's stamp, find it unchanged, + * and remove it -- so the app holds a value that {@code getData} no longer returns and a later + * stale replica faces no baseline at all.

+ * + * @param expected the stamp observed before the tombstone was processed, or null if there was + * none + * @return true when nothing had changed and both were cleared + */ + static boolean forgetAfterLocalRemoval(String path, String expected) { + synchronized (deliveredSequences) { + if (expected == null) { + // Nothing to remove -- unless a delivery installed a baseline in the meantime, in + // which case that delivery owns the path now and its snapshot must survive. + if (hasDeliveredStamp(path)) { + return false; + } + } else if (!forgetDeliveredSequenceIfUnchanged(path, expected)) { + return false; + } + rememberValue(path, null); + return true; + } + } + + /** + * Records a deletion against a resolution that is ALREADY pending, without scheduling one. + * + *

Used when an inline deletion query succeeds while an older first-sight resolution is still + * in flight for the same path. That older query may have captured the item before it was + * deleted; without this it finishes as a non-deletion, reports no missed upgrade, and delivers + * a deleted item against the now-empty baseline, with no later callback guaranteed to correct + * it. Bumping the generation makes the finishing task see that it did not act on the latest + * deletion and resolve again.

+ */ + static void notePendingDeletion(String path) { + if (path == null) { + return; + } + synchronized (pendingWinnerPaths) { + if (pendingWinnerPaths.contains(path)) { + deletionPaths.put(path, Long.valueOf(++deletionGeneration)); + } + } + } + + /** + * Records that a removal has been announced for a path with no stamp of its own, returning + * false when one already was. + * + *

For the cold-process case: the first event a fresh process handles is a deletion, so there + * is no delivery stamp to drop and the ordinary atomic path declines. Announcing needs a way to + * happen exactly once all the same, because one wildcard delete produces a tombstone per + * replica. The sentinel is a stamp like any other, so those later tombstones take the ordinary + * route and dedupe against it.

+ */ + static boolean markRemovalAnnounced(String path) { + synchronized (deliveredSequences) { + if (deliveredSequences.containsKey(path)) { + return false; + } + deliveredSequences.put(path, REMOVAL_ANNOUNCED); + // Removed before it is put back, so the entry moves to the END of the eviction order. + // An insertion-ordered LinkedHashMap does not reposition an existing key on put, so a + // path removed, republished and removed again kept the FIRST removal's place in the + // queue -- and could be evicted by the next unrelated deletion, leaving a delayed + // sibling of the second delete to read as first sight and announce a duplicate. + removalSentinels.remove(path); + // Tracked so it can be retired. Unlike a real stamp, a sentinel describes a path that + // is GONE, so nothing will ever publish over it -- on an app that deletes + // record-specific paths, every one of them would sit in the map for the life of the + // process. The stamps themselves stay uncapped for the reason above; it is only these + // that need a bound, and they are the only entries safe to have one. + removalSentinels.put(path, Boolean.TRUE); + return true; + } + } + + /// Announces a removal that a resolution has just confirmed, by whichever of the two rules + /// applies. + /// + /// Shared because the inline handler and the deferred resolver both end here and only one of + /// them knew the first-sight rule. The deferred path called + /// {@link #deliverRemovalIfStampUnchanged} alone, which refuses a null expected stamp -- so a + /// fresh process whose first callback for a path was a deletion, and whose inline attempts had + /// failed, retired the resolution silently. A deleted item is absent from the startup + /// enumeration and produces no further callback, so an app that persisted the value across the + /// restart kept showing it indefinitely. + static void announceResolvedRemoval(String path, String before) { + // Under the delivery-stamp monitor for the whole first-sight branch, as the stamped path + // already was. Taking the lock only for markRemovalAnnounced let a publication commit + // between the sentinel and the dispatch: it replaced the sentinel, cached its value and + // queued the change, and then this branch cleared that fresh cache entry and queued a + // removal behind it -- leaving the listener removed while the durable value existed, with + // the publication's own callback already spent. + // + // Safe to hold across the dispatch for the same reason the other paths are: + // deliverDataRemoved runs no listener code on this thread, it queues or hands to the EDT. + synchronized (deliveredSequences) { + if (before == null) { + if (markRemovalAnnounced(path)) { + rememberValue(path, null); + WearableConnection.deliverDataRemoved(path); + } + } else if (!isRemovalAnnounced(before)) { + // A sentinel means this logical removal has already been reported, by an earlier + // tombstone of the same wildcard delete. The ordinary path CONSUMES the stamp it + // finds and announces, so passing one in would report the removal again, once per + // replica. + deliverRemovalIfStampUnchanged(path, before); + } + } + } + + /// Stands in for "this path's removal has been reported" where no real stamp exists. A real + /// stamp is "sequence|node", so a value with neither cannot collide with one. + private static final String REMOVAL_ANNOUNCED = "removed"; + + /// Whether a stamp is the removal sentinel rather than a real delivery. + /// + /// The remaining tombstones of one wildcard delete must not be passed to the ordinary removal + /// path: that path CONSUMES the stamp it finds and announces, so it would report the same + /// logical removal again -- and re-announce once per replica, alternating between recreating + /// and consuming the sentinel. + static boolean isRemovalAnnounced(String stamp) { + return REMOVAL_ANNOUNCED.equals(stamp); + } + + /** The recorded stamp for a path, or null -- an opaque snapshot for {@link #forgetDeliveredSequenceIfUnchanged}. */ + static String deliveredStamp(String path) { + synchronized (deliveredSequences) { + return deliveredSequences.get(path); + } + } + + /** + * Drops a path's stamp only if it still matches {@code expected}. + * + *

Guards the other half of the same race: a resolution that came back empty may be reporting + * a path that a concurrent publication has since refilled, and announcing a removal for it + * would be wrong in the one direction the app cannot recover from.

+ * + * @return true when the stamp was unchanged and has now been dropped + */ + static boolean forgetDeliveredSequenceIfUnchanged(String path, String expected) { + synchronized (deliveredSequences) { + String current = deliveredSequences.get(path); + boolean unchanged = current == null ? expected == null : current.equals(expected); + if (unchanged) { + deliveredSequences.remove(path); + } + return unchanged; + } + } + + private static long stampSequence(String stamp) { + int bar = stamp.indexOf('|'); + try { + return Long.parseLong(bar < 0 ? stamp : stamp.substring(0, bar)); + } catch (RuntimeException unparsable) { + // Treat an unreadable stamp as the weakest possible, so a real value outranks it rather + // than being refused by a record nobody can interpret. + return Long.MIN_VALUE; + } + } + + private static String stampNode(String stamp) { + int bar = stamp.indexOf('|'); + if (bar < 0 || bar + 1 >= stamp.length()) { + return null; + } + return stamp.substring(bar + 1); + } + + /** + * Whether this process has delivered anything for a path yet. + * + *

An empty baseline is not the same as "this event is newer". After a restart the map is + * empty, so the first event for a path would be accepted whatever it is -- including a + * lower-ranked replica while a higher-ranked one exists on another node. + * + * @param path the application path + * @return true when a delivery stamp is already recorded + */ + static boolean hasDeliveredStamp(String path) { + synchronized (deliveredSequences) { + return deliveredSequences.containsKey(path); + } + } + + /// Forgets every recorded delivery, so a full replay treats each path as first sight. + /// + /// Only for the overflow case: the pending-delivery record lost track of which paths were + /// discarded, and re-offering everything still published is the honest recovery. A path the app + /// already has arrives again -- a duplicate an app can recognise, against a missing update it + /// cannot. + static void forgetAllDeliveredSequences() { + synchronized (deliveredSequences) { + deliveredSequences.clear(); + removalSentinels.clear(); + } + } + + static void forgetDeliveredSequence(String path) { + synchronized (deliveredSequences) { + deliveredSequences.remove(path); + } + } + + /** + * Drops a path's stamp and reports whether there was one, so a removal is announced exactly + * once. + * + *

Deleting a replicated path deletes every authority's copy, and the Data Layer can hand + * back one TYPE_DELETED event per item in a single buffer. Announcing per event repeats one + * logical removal N times, and a listener that treats removal as an event -- clearing a cache, + * cancelling something -- would act on it N times. Making the announcement conditional on this + * drop coalesces them: the first event takes the stamp, the rest find nothing and stay quiet. + * + *

It also refuses to invent removals. No recorded stamp means the app was never told this + * path had a value, and telling it the value went away would be an event that never happened.

+ * + * @return true when a stamp was present and has now been dropped + */ + static boolean forgetDeliveredSequenceIfPresent(String path) { + synchronized (deliveredSequences) { + return deliveredSequences.remove(path) != null; + } + } + + /** + * Delivery stamps, bounded. + * + *

Replicated paths are few, but every transfer contributes a key -- transfers are addressed + * by a sequence-suffixed URI so that repeated sends queue instead of replacing each other, which + * means their keys are all distinct and none is ever superseded. Left unbounded this map grows + * for the life of the process on a phone that receives files regularly. + * + *

Access-ordered with an eviction cap: the only cost of evicting a transfer claim is that a + * re-synced copy of a very old transfer could be delivered twice, and the sender's own sweep + * removes those items long before that many newer ones accumulate. + */ + /** + * Delivery stamps per path. A plain HashMap, deliberately SEPARATE from {@link #transferClaims} + * and deliberately NOT size-capped. + * + *

Recorded because it has been read the other way: transfer claims live in their own bounded + * LRU, so a burst of file transfers cannot evict a replicated path's ordering stamp. They are + * different lifetimes -- a claim is one-shot and expendable once the sender's retention window + * passes, an ordering stamp must outlive anything that could arrive for that path -- which is + * exactly why they are not one map. Evicting a stamp would let a stale item pass the ordering + * test, so this one grows with the number of distinct paths an app uses, which is bounded by + * the app rather than by traffic.

+ */ + private static final Map deliveredSequences = new HashMap(); + + /// How many removal sentinels are kept. Generous: the sentinel exists to dedupe the tombstones + /// of ONE wildcard delete, which is one per replica -- a handful, arriving in one buffer -- so + /// nothing real is riding on the two hundred and fifty-sixth oldest. + private static final int MAX_REMOVAL_SENTINELS = 256; + + /// The paths currently holding a removal sentinel, oldest first, so the oldest can be retired. + /// + /// Sentinels are the one kind of entry in {@link #deliveredSequences} that a later publication + /// does not replace -- the path is deleted -- so on an app that deletes record-specific paths + /// they accumulate for the life of the process. Retiring the oldest costs at worst a second + /// announcement of a removal that happened long enough ago for 256 other paths to have been + /// deleted since, which is not the same logical delete and so not the duplicate the sentinel + /// exists to prevent. + /// + /// Guarded by the deliveredSequences monitor like every other access to that map. + private static final Map removalSentinels = + new LinkedHashMap(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() <= MAX_REMOVAL_SENTINELS) { + return false; + } + // Only while it is still a sentinel. A real stamp can have replaced it -- a + // path deleted and later republished -- and evicting THAT would drop an + // ordering baseline, which is what lets a stale item through. + if (REMOVAL_ANNOUNCED.equals(deliveredSequences.get(eldest.getKey()))) { + deliveredSequences.remove(eldest.getKey()); + } + return true; + } + }; + + /** + * Transfer claims, bounded separately from the replicated ordering stamps. + * + *

They shared one map, which meant a burst of transfers could evict a replicated path's + * ordering stamp -- and losing that is a correctness bug, because a reconnect supplying an older + * item for the path would then pass the newer-than test and overwrite the current value. + * Replicated paths are few and application-defined, so they are held unbounded; transfer keys + * are unbounded by nature (every transfer has its own URI) and are what needs the cap. Evicting + * a transfer claim only risks delivering a very old re-synced transfer twice. + */ + private static final Map transferClaims = + new java.util.LinkedHashMap(64, 0.75f, true) { + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_TRANSFER_CLAIMS; + } + }; + + private static final int MAX_TRANSFER_CLAIMS = 2048; + + /** + * The value a path still resolves to, or null when nothing is left under it. + * + *

Used when one authority's DataItem is deleted: both nodes may have published the path, so a + * deletion event is not on its own evidence that the value is gone. Answering from a fresh query + * keeps the listener and {@code getData} telling the same story. + * + * @param context any context + * @param path the application path + * @return the winning payload, or null when the path is genuinely empty + */ + static byte[] currentValue(Context context, String path) throws java.io.IOException { + ResolvedValue v = resolveValue(context, path); + return v == null ? null : v.payload; + } + + /** A path's winning value together with the sequence it was published at. */ + static final class ResolvedValue { + final byte[] payload; + final long sequence; + /** The node that published it, which is also how a sequence tie is broken. */ + final String node; + + ResolvedValue(byte[] payload, long sequence, String node) { + this.payload = payload; + this.sequence = sequence; + this.node = node; + } + } + + /** + * Whether one publication beats another, ties included. + * + *

Two devices that publish the same path in the same millisecond before observing each other + * produce identical sequences -- the logical clock only orders them once one has seen the other. + * Without a tiebreak, {@code getData()} keeps whichever item the buffer happened to yield first + * while the delivery path keeps whichever arrived first, so the getter and the listener can + * disagree and two watches can settle on different values for the same path. + * + *

The publishing node id is the tiebreak: it is stable, it is visible to every device, and + * comparing it lexicographically makes every device pick the same winner. + * + * @param seq the candidate's sequence + * @param node the candidate's publishing node + * @param bestSeq the incumbent's sequence + * @param bestNode the incumbent's publishing node + * @return true when the candidate should win + */ + static boolean outranks(long seq, String node, long bestSeq, String bestNode) { + if (seq != bestSeq) { + return seq > bestSeq; + } + if (node == null) { + return false; + } + return bestNode == null || node.compareTo(bestNode) > 0; + } + + /** + * The winning value for a path and the sequence it carries, or null when the path is empty. + * + *

The sequence matters to the caller: after a deletion the surviving item has to be recorded + * as delivered, or an older item still queued under another authority would later pass the + * newer-than-delivered test and overwrite it. + * + * @param context any context + * @param path the application path + * @return the winner, or null when nothing is published there + * @throws java.io.IOException when the query failed, which is NOT the same as an empty path + */ + /** + * {@link #resolveValue} with a couple of retries. + * + *

For the first event after a restart the answer matters more than the latency: falling back + * to the delivered item can hand the app a lower-ranked replica, and the winning item -- being + * unchanged -- may never produce another callback, so the listener would stay wrong while + * {@code getData()} said otherwise. Callers are Play services callback threads, never the EDT. + * + * @param context any context + * @param path the application path + * @return the winner, or null when the path is genuinely empty + * @throws java.io.IOException when every attempt failed + */ + static ResolvedValue resolveValueWithRetry(Context context, String path) + throws java.io.IOException { + java.io.IOException last = null; + for (int attempt = 0; attempt <= RESOLVE_RETRIES; attempt++) { + if (attempt > 0) { + try { + Thread.sleep(RESOLVE_RETRY_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } + } + try { + return resolveValue(context, path); + } catch (java.io.IOException failed) { + last = failed; + } + } + throw last == null ? new java.io.IOException("could not resolve " + path) : last; + } + + private static final int RESOLVE_RETRIES = 2; + private static final long RESOLVE_RETRY_MILLIS = 500; + + /** Deferred winner resolution: attempts after the inline ones, and the gap between them. */ + private static final int WINNER_RETRIES = 4; + private static final long WINNER_RETRY_MILLIS = 3000; + + /** + * Paths with a deferred resolution already in flight. Several events can arrive for one path + * while the Data Layer is unreachable -- each would otherwise start its own retry chain against + * the same path. + */ + private static final java.util.Set pendingWinnerPaths = new java.util.HashSet(); + + /** + * Resolves the winning item for a path later, after the inline attempts have all failed. + * + *

The alternative -- handing the app the event we happened to receive -- is not safe here. + * That event may be a lower-ranked replica, and the winning item, being unchanged, may never + * produce another callback: the listener would then disagree with {@link #getData} for the life + * of the process, with nothing to correct it. Waiting delivers late; guessing delivers wrong and + * stays wrong. + * + *

Runs on the transfer timer rather than {@link #replyTimer} for the reason given there: each + * attempt blocks on a Data Layer query, and the reply timer also owns every pending reply + * deadline. + */ + static void scheduleWinnerResolution(Context context, String path) { + scheduleWinnerResolution(context, path, false); + } + + /** + * @param afterDeletion the pending event was a deletion, so "nothing there" is itself the + * answer and has to be delivered as a removal. On the first-sight path an empty result + * means only that there is nothing to announce, and announcing a removal for a path the + * app was never told about would invent an event. + */ + static void scheduleWinnerResolution(Context context, String path, boolean afterDeletion) { + if (context == null || path == null) { + return; + } + synchronized (pendingWinnerPaths) { + boolean fresh = pendingWinnerPaths.add(path); + // Upgrade to deletion state even when a resolution is already pending, and do it + // BEFORE the early return. Coalescing on the path alone loses the reason: a first-sight + // change can schedule a non-deletion resolution, a deletion for the same path can + // arrive before that timer runs, and the flag would be dropped -- so an empty result + // would be read as "nothing to announce" while the deletion was the LAST event the + // Data Layer will send, leaving the listener holding a value that no longer exists. + // + // Only ever set, never cleared: once a deletion is folded into a pending resolution, + // a later non-deletion caller must not downgrade it back. + if (afterDeletion) { + // Numbered, not flagged. A second deletion arriving while a resolution is in flight + // used to re-add an entry that was already there, so it left no trace: the running + // task had itself acted on a deletion, reported no missed upgrade, and cleared the + // only pending marker -- while its query predated the republication and could not + // have seen the newer deletion at all. The generation lets the finishing task + // notice that the deletion it handled is no longer the latest one. + deletionPaths.put(path, Long.valueOf(++deletionGeneration)); + } + if (!fresh) { + return; + } + } + scheduleWinnerResolution(context, path, 1); + } + + private static void scheduleWinnerResolution(final Context context, final String path, + final int attempt) { + // Its OWN timer, not the transfer timer. Deletion chains now run until they resolve, and + // each attempt can block for the full Tasks.await timeout -- so a batch of deletions during + // an outage would hold the shared timer thread for seconds per round and stall the file + // transfer retries that share it. Two independent failure domains should not queue behind + // each other. + winnerTimer.schedule(new java.util.TimerTask() { + public void run() { + try { + // Snapshot BEFORE the query. resolveValue() blocks, and an ordinary callback can + // deliver a newer publication for this path while it does; both branches below + // have to notice that rather than act on a view of the world that has expired. + String before = deliveredStamp(path); + // Read ONCE, and remember what we acted on. Calling wasAfterDeletion() again + // below would let an upgrade landing mid-task change the rule half way through. + // The generation is captured with it, so a SECOND deletion arriving while this + // query is in flight is distinguishable from the one being handled. + long actedGeneration; + synchronized (pendingWinnerPaths) { + actedGeneration = deletionGenerationFor(path); + } + boolean afterDeletion = actedGeneration != 0L; + ResolvedValue winner = resolveValue(context, path); + if (winner != null) { + // Two different rules, because the recorded stamp means two different things. + // + // After a DELETION the stamp describes the item that was just removed, and + // the survivor routinely carries a LOWER sequence than it -- so an outranks + // test rejects the survivor, nothing is delivered, and the listener keeps + // showing the deleted value while later publications stay suppressed until + // they climb past a dead item's number. It has to replace instead. But it + // still must not clobber a NEWER live delivery that landed while this query + // was in flight, so it replaces only while the stamp is still the one we + // saw before asking. + // + // Otherwise the stamp describes a live value, and the ordinary monotonic + // rule is right. + // A winner published by THIS device is recorded, never dispatched. The + // inline callback path applies that rule and this shared resolver did not, + // so any route into it -- a retry after an inline failure, a lower-ranked + // peer replica scheduling a resolution that our own item then wins, the + // startup replay -- could hand the app its own write as a peer change. + // Recording the stamp still matters: it is what stops the same item being + // resolved again and again. + if (isLocallyAuthored(context, winner.node)) { + // Recorded by the SAME rule the dispatching branches use -- only the + // dispatch is suppressed, not the bookkeeping. + // + // After a deletion that means the anchored REPLACEMENT, not the + // monotonic one: the stamp describes the item that was just removed and + // a survivor routinely carries a lower sequence, so an outranks test + // would leave the baseline sitting on a dead item and filter out every + // later peer publication beneath it. And both updates go through one + // atomic call, because splitting the stamp from the snapshot let a peer + // publication land in between -- advancing the stamp and caching its + // payload -- after which this older resolver overwrote the value alone + // and getData answered with stale local bytes the listener never saw. + if (afterDeletion) { + recordLocalWinnerIfStampUnchanged(path, before, winner.sequence, + winner.node, winner.payload); + } else { + recordLocalEcho(path, winner.sequence, winner.node, winner.payload); + } + } else if (afterDeletion) { + deliverIfStampUnchanged(path, before, winner.sequence, winner.node, + winner.payload); + } else { + deliverIfOutranks(path, winner.sequence, winner.node, winner.payload); + } + } else if (afterDeletion) { + // Empty, and the announcement is anchored on the stamp read before the + // query. Announcing a removal for a path a concurrent publication has since + // refilled is the one error the app cannot recover from, so the drop, the + // check and the announcement are one atomic step -- and dropping the stamp + // stays coupled to the removal, since a value republished later with a + // lower sequence must not be filtered as older. + // + // Through the shared rule, so first sight is handled here too: this branch + // called deliverRemovalIfStampUnchanged directly, which refuses a null + // expected stamp, and retired the resolution without telling anyone. + announceResolvedRemoval(path, before); + } + if (finishPendingWinner(path, actedGeneration)) { + // A deletion upgrade arrived while this task was running and we applied the + // non-deletion rule, so the deleted winner may still be recorded as + // delivered. The scheduler that set the flag saw the path already pending + // and scheduled nothing, so if we simply cleared the state here that + // deletion would have no task left to resolve it, and no later callback + // either -- a deleted value shown indefinitely. Run again for it. + scheduleWinnerResolution(context, path, true); + } + } catch (Throwable stillUnavailable) { + if (attempt >= WINNER_RETRIES) { + // A DELETION is retried until it RESOLVES, with no deadline. + // + // Borrowing TRANSFER_RETENTION_MILLIS here was wrong twice over: it is the + // lifetime of a transfer DataItem, which has nothing to do with a deletion, + // and any deadline at all reintroduces the same permanent staleness. The + // deleted item produces no further callback once connectivity returns, so + // whatever bound is chosen, an outage that outlasts it leaves the dead + // value's stamp and cached payload in place with nothing left to correct + // them. + // + // The cost of retrying is one task per affected path on a shared daemon + // timer, waking at the capped backoff and doing nothing but a failing query + // while the Data Layer is down. It stops the moment the query answers -- + // survivor or empty, both are resolutions -- so a reachable Data Layer ends + // it immediately. That is a cheap price for not showing deleted data. + if (retireUnlessDeletion(path)) { + scheduleWinnerResolution(context, path, attempt + 1); + return; + } + // A non-deletion resolution can stop: the path keeps whatever it already + // had, stays unstamped where it was, and the next event resolves afresh. + return; + } + scheduleWinnerResolution(context, path, attempt + 1); + } + } + }, winnerDelay(attempt)); + } + + /// One daemon timer for deferred winner resolution, separate from the transfer retries. + private static final java.util.Timer winnerTimer = + new java.util.Timer("cn1-wearable-resolve", true); + + /** + * Backoff for a deferred resolution, capped. + * + *

Capped because a deletion chain has no attempt limit any more: multiplying an unbounded + * counter gave an unbounded delay, so after a long outage the next query would be an hour or + * more away and restored connectivity would NOT clear the stale deletion promptly -- which is + * the whole point of keeping the chain alive.

+ */ + private static long winnerDelay(int attempt) { + long linear = WINNER_RETRY_MILLIS * (long) attempt; + return linear > WINNER_RETRY_CAP_MILLIS ? WINNER_RETRY_CAP_MILLIS : linear; + } + + private static final long WINNER_RETRY_CAP_MILLIS = 60 * 1000L; + + /** + * Retires an exhausted resolution unless a deletion is owed one, in a single step. + * + *

The terminal-failure branch used to test {@link #wasAfterDeletion} and then call + * clear the markers in a separate step. A deletion arriving between the two saw the path + * still pending and therefore deliberately scheduled nothing, and the clear that followed + * dropped both markers -- so the only resolution that deletion would ever get was thrown away + * and the deleted value stayed on screen indefinitely. This is the same hazard the success + * path already handles through {@link #finishPendingWinner}.

+ * + *

When a deletion is owed the markers are deliberately LEFT in place, so the path never + * stops looking pending and a concurrent deletion cannot slip a duplicate task in behind the + * caller's rescheduled one.

+ * + * @return true when a deletion is owed and the caller must schedule the next attempt + */ + private static boolean retireUnlessDeletion(String path) { + synchronized (pendingWinnerPaths) { + if (deletionPaths.containsKey(path) || replayPaths.contains(path)) { + return true; + } + pendingWinnerPaths.remove(path); + deletionPaths.remove(path); + return false; + } + } + + /** + * Schedules the winner resolution for a value recovered by the startup replay. + * + *

Identical to the ordinary first-sight resolution except that it is not allowed to give up. + * The ordinary policy retires after {@code WINNER_RETRIES} because another event will come + * along; that is exactly what is NOT true here -- the item is unchanged, so nothing else will + * announce it, and the enclosing replay pass has already counted its enumeration as successful + * and will not run again. Retiring on a transient failure would lose the very value this path + * exists to recover.

+ */ + private static void scheduleReplayResolution(Context context, String path) { + if (path == null) { + return; + } + synchronized (pendingWinnerPaths) { + replayPaths.add(path); + } + scheduleWinnerResolution(context, path); + } + + /// Paths whose resolution came from the startup replay and must therefore keep retrying. + /// Guarded by {@link #pendingWinnerPaths}; cleared when the resolution finally completes. + private static final java.util.Set replayPaths = new java.util.HashSet(); + + /** + * Retires a finished resolution, reporting whether a deletion upgrade arrived too late to be + * honoured by it. + * + *

The read of the flag and the release of the pending marker have to be one step. Between a + * task reading "not a deletion" and clearing its pending state, a deletion whose inline + * attempts failed can set the flag -- and because the path still looks pending, that scheduler + * deliberately schedules nothing. Clearing the flag on the way out would then discard a + * deletion that has no task left to resolve it and no callback coming, leaving a deleted value + * on screen indefinitely.

+ * + * @param actedOnDeletion whether this task applied the deletion rule + * @return true when a deletion upgrade was missed and a fresh resolution is owed + */ + private static boolean finishPendingWinner(String path, long actedOnGeneration) { + synchronized (pendingWinnerPaths) { + // A resolution that COMPLETED discharges the replay's obligation, whatever it found. + replayPaths.remove(path); + long current = deletionGenerationFor(path); + // Missed when a deletion is recorded that this task did not act on -- either it acted + // on none (generation 0) or it acted on an OLDER one, which is the republish-then-delete + // case: the task's query predates the second deletion, so its result cannot speak for + // it, and treating "I handled a deletion" as "I handled every deletion" discarded the + // newer one's only resolution. + boolean missed = current != 0L && current != actedOnGeneration; + pendingWinnerPaths.remove(path); + deletionPaths.remove(path); + return missed; + } + } + + /** + * Paths whose pending resolution came from a deletion. Kept beside + * {@link #pendingWinnerPaths} and under the same monitor so the flag cannot outlive the + * resolution that owns it. + */ + private static final Map deletionPaths = new HashMap(); + + /// Ever-increasing, so two deletions of the same path are distinguishable. Guarded by + /// {@link #pendingWinnerPaths}. + private static long deletionGeneration; + + /// The deletion generation a task is acting on, or 0 when it is not a deletion resolution. + /// Guarded by {@link #pendingWinnerPaths}. + private static long deletionGenerationFor(String path) { + Long g = deletionPaths.get(path); + return g == null ? 0L : g.longValue(); + } + + private static boolean wasAfterDeletion(String path) { + synchronized (pendingWinnerPaths) { + return deletionPaths.containsKey(path); + } + } + + static ResolvedValue resolveValue(Context context, String path) throws java.io.IOException { + try { + Uri uri = new Uri.Builder().scheme("wear").authority("*").path(dataPath(path)).build(); + DataItemBuffer items = Tasks.await( + Wearable.getDataClient(context.getApplicationContext()).getDataItems(uri), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + byte[] best = null; + long bestSeq = Long.MIN_VALUE; + String bestNode = null; + for (DataItem item : items) { + DataMap map = valueMap(item); + if (map == null) { + continue; + } + long seq = sequenceOf(map); + String node = item.getUri().getHost(); + if (best == null || outranks(seq, node, bestSeq, bestNode)) { + best = payloadOf(map); + bestSeq = seq; + bestNode = node; + } + } + return best == null ? null : new ResolvedValue(best, bestSeq, bestNode); + } finally { + items.release(); + } + } catch (Throwable unavailable) { + // Not the same as "the path is empty": saying so would let a timed-out query be reported + // to the app as a removal of a path another node may still be publishing. + throw new java.io.IOException("could not resolve " + path, unavailable); + } + } + + /// The stamp an item was published at, or {@code Long.MIN_VALUE} for an item that predates + /// stamping (which then always counts as older than anything stamped). + /// + /// @param map a value or transfer DataMap + /// @return the publication stamp + /// The DataMap of a value or a transfer, whichever this item is, or null when it is neither. + /// + /// @param item a received or queried data item + /// @return the item's DataMap, or null + static DataMap valueOrTransferMap(DataItem item) { + try { + return DataMapItem.fromDataItem(item).getDataMap(); + } catch (Throwable notADataMap) { + return null; + } + } + + static long sequenceOf(DataMap map) { + long seq = map == null ? Long.MIN_VALUE : map.getLong(SEQUENCE_KEY, Long.MIN_VALUE); + // Every stamp we read raises our own clock, so a peer whose clock is ahead cannot keep + // winning; reading one of our own items is a no-op because it can never exceed our counter. + observeSequence(seq); + return seq; + } + + /** + * Records that a transfer has been handed to the app, so a re-sync of the same item does not + * deliver the same one-shot file twice. + * + *

Deliberately NOT a delete. A DataItem belongs to the node that published it, and deleting it + * from a receiver propagates the deletion to every other node: with two watches paired to one + * phone, the first to connect would consume the item and the second would receive the tombstone + * instead of the file. Suppressing the duplicate locally keeps the Data Layer's own multi-peer + * replication intact, which is the property that makes a transfer reach every watch at all. + * + *

The sender bounds the storage instead -- see {@link #transferFile}, where republishing the + * same path and name replaces the item rather than adding one. + * + * @param uri the delivered transfer's item Uri + * @param sequence the transfer's publication stamp + * @return true when this is the first delivery of that transfer + */ + static boolean claimTransfer(Context context, Uri uri, long sequence) { + if (uri == null) { + return true; + } + // Any record at all counts, whatever its age -- the same rule the startup replay uses, and + // for the same reason. Reaching here means the item is in front of us, so it is still + // published; the sender retries a failed retention deletion indefinitely, so a claim that + // merely aged out says nothing about whether the app already received the payload. Age + // alone would have let a normal callback redeliver a one-shot file after a cold start. + // Restamped so pruning cannot drop it while the item it describes is demonstrably alive. + if (hasAnyClaim(context, uri)) { + refreshClaim(context, uri); + return false; + } + // Keyed by the publishing node as well as the path. Two devices may transfer the same + // logical path and file name; their items differ only in the Uri authority, so dropping it + // would treat the two as one stream and discard the second sender's file whenever its + // sequence did not happen to exceed the first's. + String key = uri.getHost() + ":" + uri.getPath(); + synchronized (transferClaims) { + String previous = transferClaims.get(key); + if (previous == null) { + // Not in memory does not mean not delivered. A transfer stays published as a + // durable DataItem for the sender's whole retention window, and the Data Layer + // re-delivers it on the next connection -- so a receiver that restarted in between + // would hand the app the same one-shot file again and repeat whatever the app does + // with it. The claim has to outlive the process for the contract to mean anything. + previous = persistedClaim(context, key); + } + if (previous != null) { + // seq|node in memory, seq|node|receivedAt on disk -- so the node is delimited on + // BOTH sides, not "everything after the first bar". Taking the rest of the string + // would fold the receipt time into the node id and make every persisted claim + // compare unequal to the live one. + int split = previous.indexOf('|'); + int nodeEnd = previous.indexOf('|', split + 1); + if (nodeEnd < 0) { + nodeEnd = previous.length(); + } + long prevSeq = Long.parseLong(previous.substring(0, split)); + String prevNode = previous.substring(split + 1, nodeEnd); + if (!outranks(sequence, uri.getHost(), prevSeq, + prevNode.length() == 0 ? null : prevNode)) { + return false; + } + } + transferClaims.put(key, sequence + "|" + (uri.getHost() == null ? "" : uri.getHost())); + // Claimed in memory only. The DURABLE claim waits until the payload has actually + // reached a listener -- see confirmTransferDelivered. + return true; + } + } + + /** + * Makes a transfer's claim durable, once the payload is no longer only in this process. + * + *

Persisting at claim time was wrong in the one direction that cannot be recovered from. A + * transfer can wake this service in a cold process, and if Android refuses the background + * activity launch the payload sits in WearableConnection's in-memory pending queue. Kill the + * process before the user opens the app and the payload is gone -- while the persisted claim + * suppresses the Data Layer redelivery that would have replaced it, losing a transfer that the + * durable-item design exists to guarantee. + * + *

So the claim is only written once delivery reached a registered listener. If it was + * parked, the in-memory claim still prevents a duplicate within this process, and a redelivery + * after a restart is allowed through. That can hand the app a file twice if it did drain the + * queue before dying -- deliberately the direction to err in, because a duplicate is something + * an app can recognise and a lost one-shot file is not.

+ */ + static void confirmTransferDelivered(Context context, Uri uri, long sequence, + boolean reachedListener) { + if (uri == null || !reachedListener) { + return; + } + String key = uri.getHost() + ":" + uri.getPath(); + synchronized (transferClaims) { + // The persisted form carries a RECEIPT TIME that the in-memory form does not need. The + // stamp is a Lamport sequence, and observeSequence deliberately drags that ahead of + // wall time whenever a peer's clock is ahead -- so comparing it against a wall-clock + // cutoff would keep such a claim until real time caught up with a fabricated future, + // and since every transfer gets a unique sequence-suffixed URI the store would grow + // without bound. Pruning needs a clock that measures elapsed time; the sequence is not + // one. + persistClaim(context, key, sequence + "|" + + (uri.getHost() == null ? "" : uri.getHost()) + + "|" + System.currentTimeMillis()); + } + publishTransferAck(context, uri); + } + + /// Tells the SENDER that this device has handed the transfer to a listener. + /// + /// The claim above is local, so it answers "have I already taken this?" and nothing else. The + /// sender needs the same fact, and the Data Layer only replicates items, so the acknowledgement + /// has to be an item of our own. Published from the same place the claim is persisted -- after + /// the payload actually reached the listener -- so it never vouches for a delivery that did not + /// happen. + /// + /// Fire and forget: a lost acknowledgement costs the sender an item kept until the hard cap, + /// while blocking here would stall the delivery path on a network round trip. + private static void publishTransferAck(Context context, Uri uri) { + String path = uri.getPath(); + if (path == null || !isTransferPath(path)) { + return; + } + try { + String publisher = uri.getHost(); + if (publisher == null) { + return; + } + PutDataMapRequest ack = + PutDataMapRequest.create(transferAckPath(publisher, path)); + ack.getDataMap().putLong(PUBLISHED_AT_KEY, System.currentTimeMillis()); + Wearable.getDataClient(context.getApplicationContext()) + .putDataItem(ack.asPutDataRequest()); + // A sweep is what deletes these again, once the transfer they vouch for is gone -- and + // on a RECEIVE-ONLY device nothing else ever arms one. The constructor sweeps at + // startup and publishing a transfer sweeps after; a device that only takes files does + // neither, so its acknowledgements accumulated for the life of the process. The + // sender's deletion of the transfer does not help: the listener's deletion branch does + // not run a sweep. + // + // Coalesced like every other caller, so a burst of deliveries adds no timer entries. + CN1WearableBridge live = current; + if (live != null) { + live.expireOwnTransfers(); + } + } catch (Throwable unavailable) { + // The transfer has been delivered either way. Losing the acknowledgement only means the + // sender holds its copy until the hard cap. + } + } + + /** + * Gives up the in-memory claim on a transfer that was never delivered. + * + *

Called when the pending-delivery queue evicts a parked transfer to stay under its cap. + * Dropping the runnable alone left the claim standing, and the claim is precisely what stops + * the next scan offering the payload again -- so within a live process the file was gone for + * good, and the sender's retention window could expire before a restart cleared it.

+ * + *

Only the in-memory claim is released. The durable one is written on delivery, so an + * undelivered transfer never had one. A fresh read of the item is then scheduled, because + * releasing the claim alone does not make the Data Layer say anything new.

+ */ + static void relinquishTransfer(Context context, Uri uri) { + if (uri == null) { + return; + } + String key = uri.getHost() + ":" + uri.getPath(); + synchronized (transferClaims) { + transferClaims.remove(key); + } + // Releasing the claim is necessary but not sufficient. The DataItem has not changed, and an + // unchanged item produces no further callback -- the unreadable-asset path above exists for + // exactly that reason -- so on a connection that simply stays up, nothing would ever offer + // this payload again and it could expire at the sender. Go back and read it. + scheduleTransferRetry(context, uri); + } + + private static boolean claimPruneScheduled; + + /// Arms one prune of the durable claim store. + /// + /// The store was pruned only from {@code persistClaim}, so a receiver that handled a finite + /// burst of transfers and then saw no more kept every one of those unique-URI claims forever -- + /// an unbounded permanent SharedPreferences store, despite a documented 24-hour bound. The task + /// re-arms itself while anything is left, so it stops on its own once the store is empty. + private static void scheduleClaimPrune(final Context context) { + if (context == null) { + return; + } + final Context app = context.getApplicationContext(); + synchronized (transferClaims) { + if (claimPruneScheduled) { + return; + } + claimPruneScheduled = true; + } + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + synchronized (transferClaims) { + claimPruneScheduled = false; + } + // A REPLAY PASS, not a bare prune. Pruning by age alone here bypassed the + // live-item check the startup path was given: a sender retrying a failed deletion + // can hold an item well past the claim's grace, and dropping the claim while the + // item is still published lets the next restart deliver that one-shot file again. + // The replay refreshes the claim of everything still there and prunes only + // afterwards, so age retires a record exactly when its item is genuinely gone. + CN1WearableBridge b = current; + if (b != null) { + b.replayOutstandingTransfers(); + } else { + // No bridge in this process (a cold service): nothing can enumerate, so leave + // the claims alone rather than expiring records that may still be needed. + // Storage stays bounded because the next process with a bridge sweeps them. + scheduleClaimPrune(app); + return; + } + if (hasAnyStoredClaim(app)) { + scheduleClaimPrune(app); + } + } + }, TRANSFER_RETENTION_MILLIS + 1000L); + } + + /** Preference store for durable transfer claims; see {@link #claimTransfer}. */ + private static final String CLAIM_PREFS = "cn1.wearable.claims"; + + /** + * The claim recorded for a transfer key in a previous process, or null. + * + *

Read only on an in-memory miss, so the common path stays a map lookup and the disk read + * happens once per key per process.

+ */ + private static String persistedClaim(Context context, String key) { + // Takes a Context rather than reading `current`. A redelivered transfer arrives in a COLD + // service process where no bridge exists yet -- ensureAppRunning only starts the activity, + // asynchronously -- so keying off `current` returned null and the durable claim was invisible + // exactly when it matters, handing the app a one-shot file it already received. + Context c = context; + if (c == null) { + CN1WearableBridge b = current; + c = b == null ? null : b.context; + } + if (c == null) { + return null; + } + try { + String recorded = + c.getSharedPreferences(CLAIM_PREFS, Context.MODE_PRIVATE).getString(key, null); + // Expiry is checked on the way OUT, not only by the sweep. The prune runs on a daemon + // timer, which dies with the process while the claims outlive it -- so a receiver that + // took a burst of transfers and then restarted would honour records long past the + // retention window they were bounded by. An entry older than the window is treated as + // absent, which is what the sweep would have made it. + if (recorded == null || !claimExpired(recorded)) { + return recorded; + } + // Also drop it, so the store shrinks on read even if the sweep never runs. + c.getSharedPreferences(CLAIM_PREFS, Context.MODE_PRIVATE).edit().remove(key).apply(); + return null; + } catch (Throwable unavailable) { + return null; + } + } + + /// True when a stored {@code seq|node|receivedAt} record is past the retention window, or is + /// malformed -- an entry with no receipt time cannot be bounded and is not worth trusting. + /// How long a durable claim is kept BEYOND the sender's retention window. + /// + /// The sender does not delete at exactly the window: its deadline sweep fires at retention + + /// 1s, and coalescing can defer that by another SWEEP_MIN_INTERVAL. Expiring a claim at exactly + /// the window therefore left a gap in which the item is still published and no longer claimed, + /// so a receiver restarting inside it replayed a one-shot file the app already had. The grace + /// covers the whole worst case with room to spare. + private static final long CLAIM_GRACE_MILLIS = 2 * SWEEP_MIN_INTERVAL_MILLIS + 60 * 1000L; + + private static boolean claimExpired(String recorded) { + int lastBar = recorded.lastIndexOf('|'); + int firstBar = recorded.indexOf('|'); + if (lastBar <= 0 || lastBar == firstBar) { + return true; + } + try { + return Long.parseLong(recorded.substring(lastBar + 1)) + < System.currentTimeMillis() - TRANSFER_RETENTION_MILLIS - CLAIM_GRACE_MILLIS; + } catch (NumberFormatException unparsable) { + return true; + } + } + + /** + * Records a claim durably, and prunes claims older than the sender's retention window. + * + *

Bounded by the same window the sender sweeps its transfers on: once the item itself is + * gone there is nothing left to re-deliver, so the claim has no one to stop and keeping it + * would grow this store without limit.

+ */ + private static void persistClaim(Context context, String key, String stamp) { + Context c = context; + if (c == null) { + CN1WearableBridge b = current; + c = b == null ? null : b.context; + } + if (c == null) { + return; + } + try { + android.content.SharedPreferences prefs = + c.getSharedPreferences(CLAIM_PREFS, Context.MODE_PRIVATE); + android.content.SharedPreferences.Editor edit = prefs.edit(); + edit.putString(key, stamp); + edit.apply(); + // NO inline prune. Writing one claim says nothing about whether OTHER items are still + // published, and the sender retries a failed deletion indefinitely -- so age-pruning + // here could drop a live transfer's claim while recording an unrelated one, and the + // next replay or restart would hand the app that one-shot file again. Every prune now + // goes through a replay pass that has just refreshed what still exists; the + // maintenance timer below is what keeps the store bounded. + scheduleClaimPrune(c); + } catch (Throwable unavailable) { + // Best effort: the in-memory claim still holds for this process. + } + } + + /// Whether ANY durable claim is recorded for this item, expired or not. + /// + /// Used only by the startup replay, which has the item in front of it: if the DataItem is still + /// published then the sender has not finished retiring it, and a claim that merely aged out + /// says nothing about whether the app already has the payload. + private static boolean hasAnyClaim(Context context, Uri uri) { + if (uri == null || context == null) { + return false; + } + try { + String key = uri.getHost() + ":" + uri.getPath(); + return context.getSharedPreferences(CLAIM_PREFS, Context.MODE_PRIVATE) + .getString(key, null) != null; + } catch (Throwable unavailable) { + return false; + } + } + + /// Restamps a claim's receipt time, so a claim cannot be pruned out from under an item that is + /// demonstrably still published. + private static void refreshClaim(Context context, Uri uri) { + if (uri == null || context == null) { + return; + } + try { + String key = uri.getHost() + ":" + uri.getPath(); + android.content.SharedPreferences prefs = + context.getSharedPreferences(CLAIM_PREFS, Context.MODE_PRIVATE); + String recorded = prefs.getString(key, null); + if (recorded == null) { + return; + } + int lastBar = recorded.lastIndexOf('|'); + int firstBar = recorded.indexOf('|'); + if (lastBar <= 0 || lastBar == firstBar) { + return; + } + prefs.edit().putString(key, recorded.substring(0, lastBar + 1) + + System.currentTimeMillis()).apply(); + } catch (Throwable unavailable) { + // Best effort: the claim simply keeps its old receipt time. + } + } + + /// Whether the durable store holds anything at all, so the maintenance timer knows to re-arm. + private static boolean hasAnyStoredClaim(Context context) { + try { + return !context.getSharedPreferences(CLAIM_PREFS, Context.MODE_PRIVATE) + .getAll().isEmpty(); + } catch (Throwable unavailable) { + return false; + } + } + + /// Prunes expired claims. + /// + /// Called ONLY from a successful replay pass, which has just refreshed the claim of every item + /// still published -- so anything left aged out describes an item that is genuinely gone. There + /// is deliberately no age-only caller: expiring a claim while its item still exists is what + /// lets a restart redeliver a one-shot transfer. + private static void pruneClaims(Context context) { + if (context == null) { + return; + } + try { + android.content.SharedPreferences prefs = + context.getSharedPreferences(CLAIM_PREFS, Context.MODE_PRIVATE); + android.content.SharedPreferences.Editor edit = prefs.edit(); + pruneInto(prefs, edit); + edit.apply(); + } catch (Throwable unavailable) { + // Best effort: the next replay prunes again. + } + } + + /// Marks every expired entry for removal on the editor, returning how many survive. + /// + /// Reachable ONLY from a replay pass that has already refreshed the claims of items still + /// published. There is no age-only caller by design: expiring a claim whose item still exists + /// is what lets a restart redeliver a one-shot transfer. + private static int pruneInto(android.content.SharedPreferences prefs, + android.content.SharedPreferences.Editor edit) { + int kept = 0; + // Same grace as claimExpired: the two must agree, or the sweep would drop a record the + // lookup still considers live. + long cutoff = System.currentTimeMillis() - TRANSFER_RETENTION_MILLIS - CLAIM_GRACE_MILLIS; + for (Map.Entry e : prefs.getAll().entrySet()) { + Object v = e.getValue(); + if (!(v instanceof String)) { + continue; + } + // seq|node|receivedAt. Pruned on receivedAt, never on seq -- see + // confirmTransferDelivered for why the sequence cannot serve as a timestamp. An + // entry written before this field existed has no receipt time and is dropped: + // it is at most one retention window old, and keeping an unprunable record + // forever is the failure being fixed. + String recorded = (String) v; + int lastBar = recorded.lastIndexOf('|'); + int firstBar = recorded.indexOf('|'); + if (lastBar <= 0 || lastBar == firstBar) { + edit.remove(e.getKey()); + continue; + } + try { + if (Long.parseLong(recorded.substring(lastBar + 1)) < cutoff) { + edit.remove(e.getKey()); + } else { + kept++; + } + } catch (NumberFormatException unparsable) { + edit.remove(e.getKey()); + } + } + return kept; + } + + /** + * Data Layer paths allow a restricted character set and are matched by prefix, so a Codename One + * path is percent-escaped into it and unescaped on the way back. + * + *

{@code '/'} is escaped along with everything else, which is what makes an encoded path a + * single segment carrying no delimiter of its own. A request's wire form can then separate its + * reply token from the application path with a literal slash, and an application path is + * reproduced exactly -- whether or not the app gave it a leading slash. + */ + static String encode(String path) { + if (path == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_') { + sb.append(c); + } else { + sb.append('%').append(Integer.toHexString(0x10000 | c).substring(1)); + } + } + return sb.toString(); + } + + static String decode(String path) { + if (path == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '%' && i + 4 < path.length()) { + sb.append((char) Integer.parseInt(path.substring(i + 1, i + 5), 16)); + i += 4; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** The wire path prefixes, shared with the listener service. */ + static String messagePath() { + return MESSAGE_PATH; + } + + static String requestPath() { + return REQUEST_PATH; + } + + static String replyPath() { + return REPLY_PATH; + } + + static String pathPrefix() { + return PATH_PREFIX; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java new file mode 100644 index 00000000000..040cbac557d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java @@ -0,0 +1,541 @@ +/* + * 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 com.codename1.wearable.WearableConnection; + +import com.google.android.gms.wearable.DataEvent; +import com.google.android.gms.wearable.DataEventBuffer; +import com.google.android.gms.wearable.MessageEvent; +import com.google.android.gms.wearable.WearableListenerService; + +/** + * Receives Wearable Data Layer traffic and hands it to {@code com.codename1.wearable}. Injected into + * the generated project alongside {@link CN1WearableBridge} only when the app references the + * wearable API. + * + *

Android starts this service to deliver a message even when the app is not running, which is + * exactly the case the Codename One API's cold-start queue exists for: everything here forwards + * straight to {@code WearableConnection}, which parks the delivery until the app registers a + * listener and then replays it on the EDT. + */ +public class CN1WearableListenerService extends WearableListenerService { + + /** + * The service has to be exported for Play services to bind it, and there is no binding + * permission that would narrow that to Play services alone. So rather than trust the caller, + * every event is checked against the nodes the Data Layer has actually reported: a crafted intent + * from another app on the device carries a source node that was never among them and is dropped. + * + *

The check is against a recent snapshot rather than a fresh query, so a peer that drops off + * between Play services queueing the callback and the check running does not cost us a message + * the Data Layer already accepted -- see {@code CN1WearableBridge.isKnownNode}. + */ + private boolean isFromAKnownNode(String sourceNodeId) { + return CN1WearableBridge.isKnownNode(this, sourceNodeId); + } + + /** + * The node that published a data item. The Data Layer puts it in the item's Uri authority + * ({@code wear:///}), which is the same provenance {@code onMessageReceived} gets + * from the message event -- and this service is exported, so it is checked the same way. + */ + private boolean isFromAKnownHost(android.net.Uri uri) { + return uri != null && isFromAKnownNode(uri.getHost()); + } + + /** + * Brings the app process up so its {@code init()} runs and its listeners exist. + * + *

Android starts this service in a dead process to deliver traffic. Queueing the delivery is + * only half the answer: without the app itself starting, nothing ever registers a listener and + * the queue is never drained. Launching is a no-op when the app is already running. + */ + private void ensureAppRunning() { + try { + if (com.codename1.ui.Display.isInitialized()) { + return; + } + android.content.Intent launch = getPackageManager() + .getLaunchIntentForPackage(getApplicationInfo().packageName); + if (launch != null) { + launch.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(launch); + } + } catch (Throwable notPermitted) { + // Background activity starts are restricted on newer Android. The delivery stays in the + // in-memory queue and is replayed if the app opens while this process is still alive. + // + // That is a convenience, not a durability guarantee, and the two transports differ on + // purpose: replicated data and file transfers are durable in the Data Layer itself -- the + // item stays published and the next connection re-delivers it -- whereas a live message + // is best-effort by contract and needs both apps awake, which is what isReachable() and + // the sender's reply timeout exist to tell it. + } + } + + @Override + public void onMessageReceived(final MessageEvent event) { + // Same reasoning as onDataChanged: isFromAKnownNode can block on a cold start. + final MessageEvent frozen = event.freeze(); + MESSAGE_WORKER.execute(new Runnable() { + public void run() { + handleMessageReceived(frozen); + } + }); + } + + private void handleMessageReceived(MessageEvent event) { + CN1WearableBridge.noteServiceContext(this); + String path = event.getPath(); + if (path == null || !isFromAKnownNode(event.getSourceNodeId())) { + return; + } + ensureAppRunning(); + if (path.startsWith(CN1WearableBridge.replyPath())) { + // An answer to a request we sent. The token rides in the path. + String token = path.substring(CN1WearableBridge.replyPath().length()); + try { + int replyToken = Integer.parseInt(token); + // A real answer arrived, so the deadline that would have failed this request is no + // longer needed; leaving it scheduled holds a task per request for the full timeout. + CN1WearableBridge.cancelReplyTimeout(replyToken); + WearableConnection.deliverReply(replyToken, event.getData(), null); + } catch (NumberFormatException malformed) { + // Not ours, or a peer running a different build. + } + return; + } + if (path.startsWith(CN1WearableBridge.requestPath())) { + // A message that wants an answer. The token and the CN1 path are both in the wire path: + // /cn1/request// + String rest = path.substring(CN1WearableBridge.requestPath().length()); + int slash = rest.indexOf('/'); + if (slash < 0) { + return; + } + try { + int peerToken = Integer.parseInt(rest.substring(0, slash)); + // The peer's token is unique only on the peer, so trade it for a locally unique one + // keyed to the node that asked; two watches can otherwise pick the same number. + int localToken = CN1WearableBridge.rememberRequestOrigin( + peerToken, event.getSourceNodeId()); + // Past the delimiter, not onto it: the encoded application path escapes its own + // slashes, so this one belongs to the wire format and is not part of the app's path. + WearableConnection.deliverMessage( + CN1WearableBridge.decode(rest.substring(slash + 1)), + event.getData(), localToken); + } catch (NumberFormatException malformed) { + // Not ours. + } + return; + } + if (path.startsWith(CN1WearableBridge.messagePath() + "/")) { + WearableConnection.deliverMessage( + CN1WearableBridge.decode( + path.substring(CN1WearableBridge.messagePath().length() + 1)), + event.getData(), 0); + } + } + + /** + * Where the Data Layer callbacks do their work. + * + *

Provenance checking blocks: on a cold start it runs {@code Tasks.await} for the local node, + * the connected nodes and the capability set. Play services REFUSES a main-thread await, so if + * these callbacks arrive on the main thread every one of those throws, each failure becomes an + * empty snapshot, and the first legitimate event of a cold start is discarded as unverified -- + * while the retry loop sleeps the main thread on the way. + * + *

The documentation is not unambiguous about which thread {@code WearableListenerService} + * uses, so this does not rely on the answer: the work is moved to a background thread either + * way, which is correct under both readings and removes the ANR risk outright. Events are + * frozen first because the buffer is recycled as soon as the callback returns.

+ */ + private static final java.util.concurrent.ExecutorService WORKER = + java.util.concurrent.Executors.newSingleThreadExecutor(); + + /// Live messages get their OWN thread. + /// + /// A data event can hold the data worker for a long time and legitimately so: a cold-start + /// resolution runs several five-second identity and capability queries and then + /// resolveValueWithRetry, which is three more Data Layer attempts. Behind that, an unrelated + /// request could sit long enough for the SENDER's thirty-second reply deadline to expire on a + /// message that had already arrived -- the one kind of delivery where being late is the same as + /// being lost. The two kinds of traffic have no ordering relationship with each other, so there + /// is nothing to serialise between them. + private static final java.util.concurrent.ExecutorService MESSAGE_WORKER = + java.util.concurrent.Executors.newSingleThreadExecutor(); + + @Override + public void onDataChanged(DataEventBuffer events) { + final java.util.List frozen = new java.util.ArrayList(); + for (DataEvent e : events) { + frozen.add(e.freeze()); + } + // Captured HERE, at arrival, not in the handler. The handler may run after a removeData + // that this event predates, and clearing whatever marker it finds by then would wipe a + // newer one. The generation pins the handler to the state it actually observed. + final long removalGeneration = CN1WearableBridge.currentRemovalGeneration(); + // Which removals were in progress AT ARRIVAL. The 30-second window has to be judged from + // here: a worker delayed past it -- two first-sight resolutions timing out is enough -- + // would otherwise find the marker expired and announce this device's own wildcard tombstone + // back to the app as a peer removal. + final java.util.Set openRemovals = CN1WearableBridge.openRemovals(); + WORKER.execute(new Runnable() { + public void run() { + handleDataChanged(frozen, removalGeneration, openRemovals); + } + }); + } + + private void handleDataChanged(java.lang.Iterable events, long removalGeneration, + java.util.Set openRemovals) { + // Before anything is read: in a cold service process this is the only context there is, and + // the logical clock has to be restored from it before observations are compared, and + // persisted through it afterwards. + CN1WearableBridge.noteServiceContext(this); + // NOT before the loop. This service is exported and there is no binding permission that + // would narrow it to Play services, so starting the app first let an untrusted caller bring + // the UI forward with an empty or forged callback -- the provenance check below stops the + // payload but cannot undo a launch that already happened. The app is started on the first + // event that proves it came from a known node, which is also the first event that could + // give the app anything to do. + boolean started = false; + for (DataEvent event : events) { + android.net.Uri uri = event.getDataItem().getUri(); + String path = uri.getPath(); + // An echo of this device's own publish. It stays fully visible to getData() and to the + // ordering bookkeeping below -- it IS the path's current value -- but it is not a peer + // event, and WearableDataListener documents its callbacks as peer changes. iOS and the + // simulator already suppress self-authored changes; forwarding them here made the same + // app code fire an extra callback on Android only, so an app that acts on a change + // processed its own write twice. + boolean transferItem = CN1WearableBridge.isTransferPath(path); + if (path == null || !isFromAKnownHost(uri) + || (!transferItem && !path.startsWith(CN1WearableBridge.pathPrefix()))) { + continue; + } + // Computed AFTER the provenance check, not before it. isFromAKnownHost retries the + // identity queries, so a getLocalNode() that failed on the first attempt can succeed + // inside it -- and an ownEcho decided beforehand was still false, so this device's own + // putData came back through the peer-change path and an app acting on changes + // processed its own write twice. + boolean ownEcho = CN1WearableBridge.isLocallyAuthored(this, uri.getHost()); + if (!started) { + ensureAppRunning(); + started = true; + } + if (transferItem) { + // A file transfer arrives as a DataMap carrying an Asset rather than an inline + // payload. Turn it back into the WearableMessage the receiver expects; this callback + // already runs off the main thread, so resolving the asset here is fine. + if (event.getType() == DataEvent.TYPE_DELETED) { + // Our own consumeTransfer, or the sender clearing up. Not an app-visible removal: + // the logical path may well still hold a replicated value. + continue; + } + CN1WearableBridge.Transfer transfer = + CN1WearableBridge.decodeTransfer(this, event.getDataItem()); + long transferSeq = CN1WearableBridge.sequenceOf( + CN1WearableBridge.valueOrTransferMap(event.getDataItem())); + if (transfer.payload != null && !ownEcho + && CN1WearableBridge.claimTransfer(this, uri, transferSeq)) { + // On the path the sender passed to transferFile, not the filename-suffixed + // storage path this item happens to live at: a listener routes on what it asked + // for. The decoded payload carries the same path internally. + // + // A transfer is one-shot, so a re-sync of the same item must not deliver twice -- + // but the duplicate is suppressed locally rather than by deleting the item. The + // item belongs to the sender, and deleting it here would propagate: with two + // watches paired to one phone, the first to connect would consume the file and + // the second would get the tombstone. + // + // The claim is made DURABLE only if this reached a live listener. Parked in the + // cold-start queue it is not safe yet: a process death would lose the payload + // while a persisted claim suppressed the redelivery that would replace it. + // Confirmed from inside the delivery: dispatched is not delivered, and a + // claim persisted for a file the app never received suppresses the redelivery + // that would have replaced it. + final android.net.Uri claimed = uri; + final long claimedSeq = transferSeq; + final android.content.Context svc = this; + WearableConnection.deliverDataChangedTracked( + transfer.logicalPath, transfer.payload, new Runnable() { + public void run() { + CN1WearableBridge.confirmTransferDelivered( + svc, claimed, claimedSeq, true); + } + }, new Runnable() { + public void run() { + // Evicted from the pending queue before any listener existed. + // The in-memory claim would otherwise keep suppressing this + // payload for the life of the process, and an unchanged + // DataItem raises no new callback -- so this both releases the + // claim and schedules a fresh read of the item. + CN1WearableBridge.relinquishTransfer(svc, claimed); + } + }); + } + // An unreadable asset delivers nothing now; decodeTransfer has scheduled a re-read, + // which beats handing the listener DataMap bytes dressed up as a payload. + continue; + } + String appPath = CN1WearableBridge.decode( + path.substring(CN1WearableBridge.pathPrefix().length())); + // Read before anything is cleared, so the reset below can tell this device's own + // removal from a republish that landed while it was being processed. + String beforeTombstone = event.getType() == DataEvent.TYPE_DELETED + ? CN1WearableBridge.deliveredStamp(appPath) : null; + if (event.getType() == DataEvent.TYPE_DELETED && openRemovals.contains(path)) { + // This device's own removeData coming back, for an ordinary value. The app made the + // call, so announcing it would break the peer-only contract in the other direction. + // + // The arrival snapshot is the ONLY test here, deliberately. ownEcho asks who published + // the item, which for a tombstone is the wrong question twice over: a wildcard + // removal produces a tombstone per replica and the peer-authority ones are not + // locally authored (so ownEcho misses them), while a peer deleting a path THIS + // device published leaves our authority on the tombstone (so ownEcho claims it as + // ours and swallowed a genuine peer removal, with no later event guaranteed to + // correct it). What matters is what this device ASKED to remove, which is what the + // removal markers record -- read at arrival, not at handler time. + // + // The cached value goes, or a latency-sensitive getData() would keep answering with + // a value this device has just deleted -- and so does the ordering baseline. + // Keeping the baseline was wrong for a peer whose logical clock never caught up + // with ours: an offline peer republishing the path draws a sequence LOWER than the + // winner we removed, deliverIfOutranks rejects it against a stamp describing an + // item that no longer exists anywhere, and both the listener and getData() stay + // empty with no later event to correct them. After a successful removal the path + // holds nothing, so anything that arrives next is by definition the new winner. + // + // Both are cleared as ONE step, anchored on a stamp read BEFORE either moves. A + // deferred resolution can deliver a peer republish concurrently with this + // tombstone, and clearing first and reading after would remove the republish's own + // stamp and snapshot -- leaving the app holding a value getData() no longer + // returns, with no baseline against a later stale replica. + CN1WearableBridge.forgetAfterLocalRemoval(appPath, beforeTombstone); + continue; + } + if (event.getType() == DataEvent.TYPE_DELETED) { + // The ordering stamp is dropped only once we know the path is genuinely empty -- + // see the branches below. Dropping it here, before the query, meant a query that + // then FAILED left the path with no stamp at all, so an older item from another + // authority arriving next would pass the newer-than-delivered test and win. + // One authority's item going away does not mean the path is gone: both nodes may + // have published it, and the other item can still be there. Reporting a removal on + // the strength of this event alone would tell the listener the value disappeared + // while getData(path) still returned it. Ask what is left and report that instead. + try { + // Snapshot BEFORE the query: resolveValueWithRetry blocks and retries, so a + // newer publication can be delivered and stamped while it runs. + String beforeQuery = CN1WearableBridge.deliveredStamp(appPath); + // Retry, because a deletion is the ONLY callback for this state: if the path is + // now empty, or an unchanged lower-ranked replica is the survivor, nothing else + // will fire and staying silent leaves the listener permanently wrong while + // getData() reports otherwise. Same reasoning as the first-sight path. + CN1WearableBridge.ResolvedValue remaining = + CN1WearableBridge.resolveValueWithRetry(this, appPath); + // Recorded for EVERY deletion that resolves, survivor or not, and before the + // stamp tests below rather than inside either of them. + // + // Both restrictions were wrong. An older first-sight resolution may be in + // flight holding the item this deletion removed: if it captured the now-deleted + // HIGHER-ranked replica it can still pass deliverIfOutranks afterwards and + // restore a deleted value, which the survivor branch did nothing about. And + // when such a resolver is pending the path has no delivery stamp yet, so + // anything placed inside a stamp test never ran at all. + CN1WearableBridge.notePendingDeletion(appPath); + if (remaining != null) { + // Record the survivor's stamp outright -- the state of the path IS this + // item now. Using the newer-than test here would decline whenever the + // survivor carries a lower sequence than the winner just deleted (which is + // ordinary: the winner is gone precisely because it was removed), leaving + // the dead item's higher stamp recorded and filtering out any later item + // that falls between the two. So the outranks-guarded form is wrong here. + // + // But an UNconditional replace is wrong too: the query above blocks, and a + // newer publication delivered while it ran would be overwritten by this + // older survivor -- the newer callback has already fired and may not + // repeat, so the listener would sit regressed while getData() answered with + // the newer item. Anchoring on the pre-query stamp gets both: the dead + // winner's stamp is replaced whatever its number, and anything that landed + // meanwhile wins instead. + // + // Only when the winner actually changed. Deleting a lower-ranked SHADOW + // replica leaves the same item winning, and re-announcing a value the app + // already holds is a spurious change -- listeners re-render, and anything + // that treats a change as an event would act on it twice. + CN1WearableBridge.deliverIfStampUnchanged(appPath, beforeQuery, + remaining.sequence, remaining.node, remaining.payload); + } else { + // Genuinely empty, so the stamp can go: a value republished here later + // with a lower stamp than the removed one carried must not be filtered as + // older. + // + // Announced only when that drop actually took a stamp. Deleting a + // replicated path deletes every authority's copy, and one buffer can carry + // a TYPE_DELETED event per item -- each would otherwise resolve empty and + // announce the same logical removal again, so a listener that treats + // removal as an event would act on it once per replica. The first event + // takes the stamp and reports; the rest find nothing and stay quiet. + // + // Anchored on beforeQuery, not merely "was something there": the query + // above blocks, so a newer publication delivered while it ran would + // otherwise have its stamp removed and a removal announced for a path + // that has already been refilled -- the newer callback has already fired + // and may not repeat. + // + // Inside this branch, never outside it: hoisting the call meant deleting a + // lower-ranked SHADOW replica -- where the winner survives and + // deliverIfStampUnchanged leaves the stamp as it found it -- dropped the + // LIVE winner's stamp, cleared its cached value and told the app the value + // had gone. + // + // Both rules live in the bridge, because the deferred resolver reaches the + // same point and knew only one of them. + CN1WearableBridge.announceResolvedRemoval(appPath, beforeQuery); + } + } catch (java.io.IOException couldNotResolve) { + // The follow-up query failed rather than answering "nothing here". Still do not + // report a removal on that: it would tell the app a path had gone while another + // node may be publishing it, and a removal is not recoverable from the app's + // side. + // + // But staying silent is not safe either, which is what this used to do. A + // deletion is the ONLY callback for this state -- if the path is now empty, or + // an unchanged lower-ranked replica is the survivor, nothing else is guaranteed + // to fire, and the listener stays wrong for the life of the process while + // getData() reports otherwise. Resolve it later instead, and pass + // afterDeletion so an empty result is delivered as the removal it is rather + // than being read as "nothing to announce". + CN1WearableBridge.scheduleWinnerResolution(this, appPath, true); + } + continue; + } + com.google.android.gms.wearable.DataMap value = + CN1WearableBridge.valueMap(event.getDataItem()); + if (value == null) { + // Under our prefix but not written by this API -- nothing to deliver. + continue; + } + // A publication for this path ends any local-removal window: the wildcard delete it + // covered is demonstrably over, so a peer's later removal must not be mistaken for it. + // + // Hoisted ABOVE the first-sight branch, which exits by `continue` -- leaving it below + // meant a publication arriving with no delivery stamp recorded took that exit and never + // cleared the marker, so a peer removing its brand-new value inside the window still + // had that removal swallowed. + CN1WearableBridge.clearLocalRemoval(path, removalGeneration); + if (!CN1WearableBridge.hasDeliveredStamp(appPath)) { + // First sight of this path in this process -- after a restart there is no baseline, + // so accepting the event on the strength of "nothing recorded" would hand the app a + // lower-ranked replica while a higher-ranked item exists on another node, and + // getData() would immediately disagree. Resolve the actual winner instead. + try { + CN1WearableBridge.ResolvedValue winner = + CN1WearableBridge.resolveValueWithRetry(this, appPath); + // Compare-and-replace AND dispatch as one step. resolveValueWithRetry blocks + // (and retries), so another callback can stamp this path with a newer + // publication while it runs -- and committing the stamp separately from the + // delivery leaves the same race one line further down: the newer payload goes + // out first, this older one after it, and the cache keeps the newer stamp so + // nothing is left that can correct the listener. + if (winner != null) { + // The resolved winner may be OUR OWN item -- a clean start whose first + // callback is the echo of this device's putData resolves to exactly that. + // Checking ownEcho on the event is not enough here: what matters is who + // published the winner, which resolution may have taken from a different + // authority than the event that triggered it. + if (CN1WearableBridge.isLocallyAuthored(this, winner.node)) { + CN1WearableBridge.recordLocalEcho(appPath, winner.sequence, + winner.node, winner.payload); + } else { + CN1WearableBridge.deliverIfOutranks(appPath, winner.sequence, + winner.node, winner.payload); + } + } + continue; + } catch (java.io.IOException couldNotResolve) { + // Every inline attempt failed. Do NOT fall back to the event we were handed: it + // may be a lower-ranked replica, and the winning item, being unchanged, may + // never produce another callback -- so the listener would disagree with + // getData() for the life of the process, with nothing left to correct it. That + // is the one outcome worse than a late delivery. + // + // Resolve it later instead, on a backoff, and leave the path unstamped so the + // next event for it still resolves from scratch. + CN1WearableBridge.scheduleWinnerResolution(this, appPath); + continue; + } + } + // Ordering test and dispatch as ONE step, the same as the resolution paths. Testing + // first and dispatching after leaves the window between them: a deferred resolution or + // another callback can advance this path in between, emit the newer payload, and then + // this callback emits the older one after it -- with the cache holding the newer stamp, + // so that publication is rejected if seen again and nothing corrects the listener. + // + // An older item arriving after a newer one is ordinary, not exotic: a reconnect does it + // whenever both nodes publish the same path. deliverIfOutranks declines those silently. + if (ownEcho) { + // The ONE deliberate use of the raw setter. Bookkeeping only: the stamp still has + // to move so a later peer item is judged against what this device actually + // published, but no listener is told, so there is no dispatch to order it with. + // Every other stamp mutation goes through the deliver* helpers, which commit the + // stamp and the delivery together. + // Stamp and snapshot together, or neither. The stamp update declines when a newer + // publication was recorded meanwhile, and an unconditional snapshot write then put + // this echo's older bytes in front of it -- so a latency-sensitive getData() kept + // answering with them while the delivery stamp already tracked the newer value. + CN1WearableBridge.recordLocalEcho(appPath, CN1WearableBridge.sequenceOf(value), + uri.getHost(), CN1WearableBridge.payloadOf(value)); + } else { + CN1WearableBridge.deliverIfOutranks(appPath, CN1WearableBridge.sequenceOf(value), + uri.getHost(), CN1WearableBridge.payloadOf(value)); + } + } + } + + @Override + public void onCapabilityChanged(com.google.android.gms.wearable.CapabilityInfo info) { + // The companion was installed or removed while the device stayed connected. Nothing else + // would notice: the capability cache would keep answering with the previous result. + // + // capabilityChanged() notifies listeners itself, and only when the set actually changed, so + // there is deliberately no second notifyStateChanged() here -- it would deliver the same + // state change twice, and would fire even when nothing changed. + CN1WearableBridge.capabilityChanged(info); + } + + @Override + public void onPeerConnected(com.google.android.gms.wearable.Node peer) { + // Correct the bridge's node cache before listeners run: one that responds by calling + // isReachable() must not be told about a peer the cache has not heard of yet. + CN1WearableBridge.peerChanged(peer, true); + } + + @Override + public void onPeerDisconnected(com.google.android.gms.wearable.Node peer) { + CN1WearableBridge.peerChanged(peer, false); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java new file mode 100644 index 00000000000..06e59122083 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java @@ -0,0 +1,65 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The legacy {@code android.wear} / {@code android.wear.standalone} pair. + * + *

The relationship is directional and was inverted once already: Wear mode implied standalone, + * but the standalone sub-hint never implied Wear mode. Inverting it hands a legacy PHONE project + * the API 23 floor and a required {@code android.hardware.type.watch} feature, which makes Play + * filter the APK off every phone -- a shipping app made undeliverable, with no build error to + * show for it.

+ */ +class AndroidLegacyWearHintTest { + + /** The regression: a stray standalone sub-hint must not turn a phone build into a Wear build. */ + @Test + void standaloneAloneDoesNotEnableWearMode() { + assertFalse(AndroidGradleBuilder.legacyWearMode("false")); + assertFalse(AndroidGradleBuilder.legacyWearStandalone("false", "true")); + assertFalse(AndroidGradleBuilder.legacyWearStandalone("", "true")); + } + + @Test + void androidWearEnablesWearMode() { + assertTrue(AndroidGradleBuilder.legacyWearMode("true")); + assertFalse(AndroidGradleBuilder.legacyWearMode("TRUE")); + assertFalse(AndroidGradleBuilder.legacyWearMode("")); + } + + /** android.wear=true implied standalone, so an absent sub-hint keeps that behaviour. */ + @Test + void wearImpliesStandaloneUnlessExplicitlyOptedOut() { + assertTrue(AndroidGradleBuilder.legacyWearStandalone("true", "")); + assertTrue(AndroidGradleBuilder.legacyWearStandalone("true", null)); + assertTrue(AndroidGradleBuilder.legacyWearStandalone("true", "true")); + assertFalse(AndroidGradleBuilder.legacyWearStandalone("true", "false")); + assertFalse(AndroidGradleBuilder.legacyWearStandalone("true", " false ")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java new file mode 100644 index 00000000000..03e5687def1 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java @@ -0,0 +1,592 @@ +/* + * 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 org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Pins the watch build's contract with the project: declaring a watch lifecycle +/// class is the entire opt-in, everything else is derived, and a project that +/// declares none must be left completely alone. The wearable build deliberately +/// carries no build hints, so these tests also guard against re-introducing one +/// by accident. +class WatchNativeBuilderTest { + + private static final String WATCH_MAIN = "com.mycompany.myapp.MyWatchMain"; + + // ------------------------------------------------------------------ + // Enablement + // ------------------------------------------------------------------ + + @Test + void projectWithoutAWatchMainBuildsNoWatchApp() { + WatchNativeBuilder b = parse(request()); + assertFalse(b.isEnabled(), + "A project that declares no watch lifecycle class must leave the iOS build untouched"); + } + + @Test + void declaringAWatchMainIsTheEntireOptIn() { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + WatchNativeBuilder b = parse(req); + + assertTrue(b.isEnabled()); + assertEquals(WATCH_MAIN, b.getWatchMain()); + } + + @Test + void retiredEnablementHintsAreIgnored() { + // These named the old hint surface. Nothing may resurrect the watch + // build without a watch lifecycle class to root it at. + BuildRequest req = request(); + req.putArgument("watchNative.enabled", "true"); + req.putArgument("watchNative.mainClass", WATCH_MAIN); + + assertFalse(parse(req).isEnabled()); + } + + @Test + void blankWatchMainBuildsNoWatchApp() { + BuildRequest req = request(); + req.putArgument("watchMain", " "); + + assertFalse(parse(req).isEnabled()); + } + + // ------------------------------------------------------------------ + // Distribution + // ------------------------------------------------------------------ + + @Test + void watchAppIsACompanionByDefault() { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + assertFalse(parse(req).isStandalone()); + } + + @Test + void watchStandaloneMakesTheWatchAppTheProduct() { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("watchStandalone", "true"); + + assertTrue(parse(req).isStandalone()); + } + + // ------------------------------------------------------------------ + // Info.plist + // ------------------------------------------------------------------ + + @Test + void companionPlistPinsTheWatchAppToThePhoneApp(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("WKApplication"), + "Modern single-target watch apps are marked with WKApplication"); + assertTrue(plist.contains("WKCompanionAppBundleIdentifier"), + "A companion watch app installs with the phone app it names"); + assertTrue(plist.contains("com.mycompany.myapp")); + } + + @Test + void standalonePlistNamesNoCompanion(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("watchStandalone", "true"); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("WKApplication")); + assertFalse(plist.contains("WKCompanionAppBundleIdentifier"), + "A standalone watch app has no phone app to pair with"); + } + + @Test + void plistUsesTheProjectDisplayNameAndVersion(@TempDir Path tmp) throws IOException { + // Derived rather than configured: the watch app name and version come + // from the settings the project already has. + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("My App")); + assertTrue(plist.contains("2.5")); + } + + // ------------------------------------------------------------------ + // Bundle versions + // ------------------------------------------------------------------ + + /// Apple rejects an archive whose embedded watch app disagrees with its container on either + /// version key, and companion mode embeds by default -- so a hardcoded "1" here would fail + /// distribution for every project that sets a version at all. + @Test + void watchVersionsFollowThePhone(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("CFBundleShortVersionString\n 2.5"), + "The marketing version is the project's, not a placeholder: " + plist); + assertTrue(plist.contains("CFBundleVersion\n 2.5"), + "CFBundleVersion defaults to the same value the phone plist uses: " + plist); + assertFalse(plist.contains("CFBundleVersion\n 1")); + } + + @Test + void explicitBundleVersionOverrideIsHonoured(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.bundleVersion", "417"); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("CFBundleVersion\n 417"), + "ios.bundleVersion drives both halves of the pair: " + plist); + assertTrue(plist.contains("CFBundleShortVersionString\n 2.5"), + "The override is the build number only, as on the phone: " + plist); + } + + /// The phone reformats its version when ios.twoDigitVersion is set, so the watch has to apply the + /// same transformation or the two disagree digit for digit. + @Test + void twoDigitVersionMatchesThePhoneReformatting(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.twoDigitVersion", "true"); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("2.50"), + "2.5 becomes 2.50 exactly as IPhoneBuilder derives it: " + plist); + } + + // ------------------------------------------------------------------ + // Generated entry point + // ------------------------------------------------------------------ + + @Test + void watchEntryPointIsGenerated(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + WatchNativeBuilder b = parse(req); + + File dir = tmp.toFile(); + b.writeWatchEntry(req, dir); + + String swift = read(new File(dir, "CN1WatchApp.swift")); + assertTrue(swift.contains("@main"), "The watch app is rooted in a SwiftUI @main shell"); + assertTrue(swift.contains("#if os(watchOS)"), + "The shell is globbed into the iOS target too, so it must compile away there"); + assertTrue(swift.contains("digitalCrownRotation")); + + String bootstrap = read(new File(dir, "CN1WatchBootstrap.m")); + assertTrue(bootstrap.contains("#if TARGET_OS_WATCH")); + assertTrue(bootstrap.contains("cn1_watch_app_main")); + // The declared class reaches cn1_watch_bootstrap, but note what this does NOT assert: the + // runtime does not yet root the app at it (cn1_watch_runtime_start discards the argument and + // cn1_watch_app_main enters the phone's Stub.main). Rooting a second translation at watchMain + // is scoped separately; see the "What the watch app runs today" section of the guide. + assertTrue(bootstrap.contains(WATCH_MAIN), + "The declared watch lifecycle class is passed to the watch runtime"); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static BuildRequest request() { + BuildRequest req = new BuildRequest(); + req.setMainClass("MyApp"); + req.setPackageName("com.mycompany.myapp"); + req.setDisplayName("My App"); + req.setVersion("2.5"); + return req; + } + + /// An embedded watch app whose versions differ from its container is rejected by App Store + /// validation, and ios.plistInject REPLACES the phone's default version injection rather than + /// adding to it -- so a project that sets the version there must not leave the watch behind. + @Test + void watchVersionsFollowPlistInject(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + "CFBundleShortVersionString9.9.9" + + "CFBundleVersion4242"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("9.9.9"), + "watch CFBundleShortVersionString must follow the injected phone value: " + plist); + assertTrue(plist.contains("4242"), + "watch CFBundleVersion must follow the injected phone value: " + plist); + } + + /// ios.bundleVersion still applies when the injection does not name CFBundleVersion. + @Test + void watchVersionsFallBackWhenNotInjected(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + "CFBundleShortVersionString7.7.7"); + req.putArgument("ios.bundleVersion", "31"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("7.7.7"), + "injected short version must reach the watch plist: " + plist); + assertTrue(plist.contains("31"), + "ios.bundleVersion must still win for CFBundleVersion: " + plist); + } + + /// The two version keys are independent. An injected marketing version must NOT become the + /// watch's CFBundleVersion, because the phone's still comes from the build version -- deriving + /// it from the injected string reintroduces the mismatch as phone 1.0 against watch 9.9. + @Test + void injectedShortVersionDoesNotBecomeTheBundleVersion(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + "CFBundleShortVersionString9.9"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("CFBundleShortVersionString\n 9.9"), + "injected short version must reach the watch plist: " + plist); + assertFalse(plist.contains("CFBundleVersion\n 9.9"), + "CFBundleVersion must not follow the injected marketing version: " + plist); + } + + /// A standalone bundle has to declare itself watch-only; omitting the companion key is not the + /// same statement, and the difference shows up at install and App Store validation. + @Test + void standaloneWatchAppIsMarkedWatchOnly(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("watchStandalone", "true"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("WKWatchOnly"), + "a standalone watch app must declare WKWatchOnly: " + plist); + assertFalse(plist.contains("WKCompanionAppBundleIdentifier"), + "a standalone watch app must not name a companion: " + plist); + } + + /// And the companion build must NOT claim to be watch-only. + @Test + void companionWatchAppIsNotWatchOnly(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + String plist = writeInfoPlist(req, tmp); + assertFalse(plist.contains("WKWatchOnly"), + "a companion watch app must not declare WKWatchOnly: " + plist); + assertTrue(plist.contains("WKCompanionAppBundleIdentifier"), + "a companion watch app must name its container: " + plist); + } + + /// The watch team must follow ios.buildType exactly as the phone's does. Pairing a debug + /// profile with the release team's DEVELOPMENT_TEAM fails manual signing of the embedded target. + @Test + void watchTeamFollowsBuildType() { + BuildRequest debug = request(); + debug.putArgument("watchMain", WATCH_MAIN); + debug.putArgument("ios.debug.teamId", "DEBUGTEAM"); + debug.putArgument("ios.release.teamId", "RELTEAM"); + debug.putArgument("ios.buildType", "debug"); + assertEquals("DEBUGTEAM", parse(debug).getTeamId(), + "a debug build must use the debug team"); + + BuildRequest release = request(); + release.putArgument("watchMain", WATCH_MAIN); + release.putArgument("ios.debug.teamId", "DEBUGTEAM"); + release.putArgument("ios.release.teamId", "RELTEAM"); + release.putArgument("ios.buildType", "release"); + assertEquals("RELTEAM", parse(release).getTeamId(), + "a release build must use the release team"); + + BuildRequest plain = request(); + plain.putArgument("watchMain", WATCH_MAIN); + plain.putArgument("ios.teamId", "PLAINTEAM"); + assertEquals("PLAINTEAM", parse(plain).getTeamId(), + "ios.teamId remains the fallback for both"); + } + + /// The watch bundle needs its OWN purpose strings: the phone's plist does not cover an API the + /// watch app exercises, and watchOS terminates the app rather than merely refusing access. + @Test + void watchPlistCarriesEveryPrivacyDescription(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.NSLocationWhenInUseUsageDescription", "Shows nearby stops"); + req.putArgument("ios.NSMicrophoneUsageDescription", "Records a voice note"); + req.putArgument("ios.NSHealthShareUsageDescription", "Reads your heart rate"); + req.putArgument("ios.NSMotionUsageDescription", " "); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("NSLocationWhenInUseUsageDescription"), + "location description must reach the watch plist: " + plist); + assertTrue(plist.contains("NSMicrophoneUsageDescription"), + "microphone description must reach the watch plist: " + plist); + assertTrue(plist.contains("NSHealthShareUsageDescription"), + "the HealthKit pair must still be carried: " + plist); + assertFalse(plist.contains("NSMotionUsageDescription"), + "a whitespace-only description is absent, not blank: " + plist); + } + + /// ios.locationUsageDescription is a supported hint the phone translates into an NS key later, + /// after this plist is written -- so the watch has to translate it itself or ship without one. + @Test + void watchPlistTranslatesTheLocationFallback(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.locationUsageDescription", "Finds nearby stops"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("NSLocationWhenInUseUsageDescription"), + "the location fallback must become an NS key in the watch plist: " + plist); + assertTrue(plist.contains("Finds nearby stops"), plist); + } + + /// And an explicit NS key wins, rather than being emitted twice. + @Test + void explicitLocationKeyIsNotDuplicatedByTheFallback(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.locationUsageDescription", "fallback text"); + req.putArgument("ios.NSLocationWhenInUseUsageDescription", "explicit text"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("explicit text"), plist); + assertFalse(plist.contains("fallback text"), + "the explicit key wins and the fallback is not also emitted: " + plist); + } + + /// ios.plistInject is a supported way to set a purpose string, and it is a raw fragment rather + /// than an argument -- so a loop over ios.NS* never sees it and the watch ships without one. + @Test + void watchPlistCarriesInjectedPrivacyStrings(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + "NSMicrophoneUsageDescriptionRecords a note" + + "UIRequiresFullScreen"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("NSMicrophoneUsageDescription"), + "an injected purpose string must reach the watch plist: " + plist); + assertTrue(plist.contains("Records a note"), plist); + assertFalse(plist.contains("UIRequiresFullScreen"), + "only privacy keys are mirrored, not the whole fragment: " + plist); + } + + /// An injected NSLocation key must suppress the ios.locationUsageDescription fallback, or the + /// plist carries the key twice and a default overwrites the developer's own disclosure. + @Test + void injectedLocationKeySuppressesTheFallback(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + "NSLocationWhenInUseUsageDescriptioninjected text"); + req.putArgument("ios.locationUsageDescription", "fallback text"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("injected text"), plist); + assertFalse(plist.contains("fallback text"), + "the injected key wins over the fallback: " + plist); + assertEquals(1, countOccurrences(plist, "NSLocationWhenInUseUsageDescription"), + "the key must appear exactly once: " + plist); + } + + /// A HealthKit purpose string supplied through the injection satisfies the validation that + /// otherwise aborts the build -- it used to read arguments only and reject its own plist. + @Test + void injectedHealthDescriptionSatisfiesValidation(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("watchNative.health", "true"); + req.putArgument("ios.plistInject", + "NSHealthShareUsageDescriptionReads heart rate" + + "NSHealthUpdateUsageDescriptionSaves workouts"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("Reads heart rate"), + "the injected HealthKit string must reach the plist: " + plist); + } + + /// A value read out of ios.plistInject is serialized text, so re-emitting it through the + /// escaper without decoding first shows the entity literally in the permission dialog. + @Test + void injectedEntitiesAreNotDoubleEscaped(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", "NSHealthShareUsageDescription" + + "Uses Health & Fitness data"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("Uses Health & Fitness data"), + "the ampersand must be escaped exactly once: " + plist); + assertFalse(plist.contains("&amp;"), + "the entity must not be escaped a second time: " + plist); + } + + /// Numeric references are ordinary XML, and a single left-to-right pass must not decode its + /// own output -- "&#38;" is an author writing a literal "&", not an escaped ampersand. + @Test + void injectedNumericReferencesAreDecodedOnce(@TempDir Path tmp) throws IOException { + assertEquals("Health & Fitness", + WatchNativeBuilder.decodeXmlEntities("Health & Fitness")); + assertEquals("Health & Fitness", + WatchNativeBuilder.decodeXmlEntities("Health & Fitness")); + assertEquals("Health & Fitness", + WatchNativeBuilder.decodeXmlEntities("Health & Fitness")); + assertEquals("a & b", WatchNativeBuilder.decodeXmlEntities("a &#38; b"), + "a literal reference must survive, not be decoded a second time"); + assertEquals("100% & more", WatchNativeBuilder.decodeXmlEntities("100% & more"), + "a bare ampersand is left exactly as written"); + + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", "NSHealthShareUsageDescription" + + "Health & Fitness"); + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("Health & Fitness"), plist); + assertFalse(plist.contains("&#38;"), plist); + } + + /// The plist pass and the code-signing setting must reach the SAME HealthKit verdict. They + /// used to resolve it from different inputs, so a purpose string supplied only through + /// ios.plistInject produced a bundle that declared HealthKit and was signed without the + /// entitlement -- authorization then fails on device. Detected usage is the single source of + /// truth, matching the BuildDaemon mirror, so a stale privacy string entitles nothing. + @Test + void plistAndEntitlementsAgreeOnHealth(@TempDir Path tmp) throws IOException { + BuildRequest stale = request(); + stale.putArgument("watchMain", WATCH_MAIN); + stale.putArgument("ios.plistInject", "NSHealthShareUsageDescription" + + "Reads your heart rate"); + String stalePlist = writeInfoPlist(stale, tmp); + assertTrue(stalePlist.contains("NSHealthShareUsageDescription"), + "the description is still carried -- it is the ENTITLEMENT that needs evidence"); + assertFalse(stalePlist.contains("com.apple.developer.healthkit"), stalePlist); + assertEquals("", parse(stale).watchEntitlementsSetting(stale, stale.getMainClass()), + "a privacy string alone must not sign the watch target with HealthKit"); + + BuildRequest declared = request(); + declared.putArgument("watchMain", WATCH_MAIN); + declared.putArgument("watchNative.health", "true"); + declared.putArgument("ios.NSHealthShareUsageDescription", "Reads your heart rate"); + assertTrue(parse(declared).watchEntitlementsSetting(declared, declared.getMainClass()) + .contains("CODE_SIGN_ENTITLEMENTS"), + "declared health usage must sign the watch target with the entitlements file"); + } + + /// A project whose health access lives in native code declares it through the capability + /// hints, not through anything the bytecode scan can see. The phone builder has always treated + /// those hints as HealthKit use; when the watch read the scanner flags alone the same app -- + /// running the SAME lifecycle class on both slices -- got an entitled phone and an unentitled + /// watch, and only the watch failed authorization. + @Test + void explicitHealthCapabilitiesEntitleTheSharedLifecycleWatchToo() { + for (String hint : new String[] { + "ios.health.backgroundDelivery", + "ios.health.recalibrateEstimates", + "ios.entitlements.com.apple.developer.healthkit.background-delivery", + "ios.entitlements.com.apple.developer.healthkit.recalibrate-estimates", + // The plainest declaration of all, and the one the sub-capability list missed. + "ios.entitlements.com.apple.developer.healthkit"}) { + BuildRequest req = request(); + req.putArgument("watchMain", "com.mycompany.myapp.MyApp"); + req.putArgument(hint, "true"); + assertTrue(parse(req).watchEntitlementsSetting(req, req.getMainClass()) + .contains("CODE_SIGN_ENTITLEMENTS"), + hint + " must entitle the watch target as it does the phone"); + + // A watch with its OWN root shakes from that root, so the phone's usage says nothing + // about it -- entitling it anyway fails codesigning for an ordinary watch app whose + // App ID has no HealthKit capability. Unchanged by this fix, and worth pinning next + // to it so the two rules are not confused for each other. + BuildRequest distinct = request(); + distinct.putArgument("watchMain", WATCH_MAIN); + distinct.putArgument(hint, "true"); + assertEquals("", + parse(distinct).watchEntitlementsSetting(distinct, distinct.getMainClass()), + hint + " must not entitle a watch app with its own lifecycle class"); + } + BuildRequest none = request(); + none.putArgument("watchMain", "com.mycompany.myapp.MyApp"); + assertEquals("", parse(none).watchEntitlementsSetting(none, none.getMainClass()), + "a project that asks for nothing is still not entitled"); + } + + /// "false" is the established opt-out for a privacy hint -- the phone's generic injector skips + /// a usage description with exactly that value. The watch treated it as an ordinary string and + /// would have shown the literal word in a watchOS permission prompt. + @Test + void falseSuppressesAPurposeStringAsItDoesOnThePhone(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.NSMicrophoneUsageDescription", "false"); + req.putArgument("ios.plistInject", "NSCameraUsageDescriptionfalse" + + "NSMotionUsageDescriptionCounts your steps"); + String plist = writeInfoPlist(req, tmp); + assertFalse(plist.contains("NSMicrophoneUsageDescription"), + "an opted-out argument must not reach the watch plist: " + plist); + assertFalse(plist.contains("NSCameraUsageDescription"), + "an opted-out injected key must not reach the watch plist: " + plist); + assertFalse(plist.contains("false"), plist); + assertTrue(plist.contains("Counts your steps"), + "an ordinary description is unaffected: " + plist); + } + + private static int countOccurrences(String haystack, String needle) { + int n = 0; + for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + 1)) { + n++; + } + return n; + } + + private static WatchNativeBuilder parse(BuildRequest req) { + WatchNativeBuilder b = new WatchNativeBuilder(new IPhoneBuilder()); + b.parseHints(req); + return b; + } + + private static String writeInfoPlist(BuildRequest req, Path tmp) throws IOException { + WatchNativeBuilder b = parse(req); + File dir = tmp.toFile(); + b.writeWatchInfoPlist(req, dir); + return read(new File(dir, req.getMainClass() + "-Watch-Info.plist")); + } + + private static String read(File f) throws IOException { + if (!f.exists()) { + throw new AssertionError("Expected generated file was not written: " + f); + } + return new String(Files.readAllBytes(f.toPath())); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java new file mode 100644 index 00000000000..03bb06e4d32 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.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.maven; + +import org.junit.Test; + +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * The watch and TV entry points are declared next to {@code codename1.mainName}, + * without the {@code codename1.arg.} prefix. Local builds read them straight off + * the settings file, but the build server only lifts {@code codename1.arg.*} + * keys out of the uploaded file -- so they have to be mirrored into that + * namespace or a cloud build produces no watch app at all. + */ +public class CN1BuildMojoSecondaryEntryPointTest { + + @Test + public void watchMainReachesTheBuildServer() { + Properties props = new Properties(); + props.setProperty("codename1.mainName", "MyApp"); + props.setProperty("codename1.watchMain", "com.mycompany.myapp.MyWatchMain"); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("com.mycompany.myapp.MyWatchMain", + props.getProperty("codename1.arg.watchMain")); + // The original declaration stays put -- it is a project setting, not a + // build hint, and the local path still reads it from there. + assertEquals("com.mycompany.myapp.MyWatchMain", + props.getProperty("codename1.watchMain")); + } + + @Test + public void watchStandaloneAndTvMainReachTheBuildServer() { + Properties props = new Properties(); + props.setProperty("codename1.watchMain", "com.mycompany.myapp.MyWatchMain"); + props.setProperty("codename1.watchStandalone", "true"); + props.setProperty("codename1.tvMain", "com.mycompany.myapp.MyTvMain"); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("true", props.getProperty("codename1.arg.watchStandalone")); + assertEquals("com.mycompany.myapp.MyTvMain", props.getProperty("codename1.arg.tvMain")); + } + + @Test + public void surroundingWhitespaceIsTrimmed() { + Properties props = new Properties(); + props.setProperty("codename1.watchMain", " com.mycompany.myapp.MyWatchMain "); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("com.mycompany.myapp.MyWatchMain", + props.getProperty("codename1.arg.watchMain")); + } + + @Test + public void aProjectWithoutSecondaryEntryPointsIsUntouched() { + Properties props = new Properties(); + props.setProperty("codename1.mainName", "MyApp"); + // A blank declaration is the same as none: it must not switch a build on. + props.setProperty("codename1.watchMain", " "); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertNull(props.getProperty("codename1.arg.watchMain")); + assertNull(props.getProperty("codename1.arg.watchStandalone")); + assertNull(props.getProperty("codename1.arg.tvMain")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java new file mode 100644 index 00000000000..2971550dd94 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java @@ -0,0 +1,141 @@ +/* + * 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.util; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; + +import org.junit.jupiter.api.function.Executable; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A watch complication is a WidgetKit widget in an accessory family, so the surfaces watch families +/// map onto those families -- but only in a watch target. The generated extension is the iOS one, so +/// a complication must not surface there: a kind that asked for a complication and got an iPhone +/// lock-screen or home-screen widget is a wrong surface in front of the user, not an approximation. +class IOSWidgetExtensionWatchFamilyTest { + + /// A project declaring only complications is legitimate -- it just has no iOS surface until the + /// watchOS extension target exists. The extension must therefore not be generated at all: an + /// emitted-but-empty `WidgetBundle` body does not compile, and falling back to the home-screen + /// sizes would ship a widget the manifest never asked for. + @Test + void watchOnlyProjectHasNoIosSurface() { + IOSWidgetExtensionBuilder b = builderFor("watchCircular", "watchRectangular", "watchInline"); + + assertFalse(b.hasIosSurface(), + "Only complication families were declared, so there is nothing for iOS to host"); + } + + /// And if a caller ignores that and generates anyway, it fails loudly here rather than emitting + /// Swift that breaks the whole iOS build. + @Test + void generatingAnEmptyBundleIsRefused() { + IOSWidgetExtensionBuilder b = builderFor("watchCircular"); + + assertThrows(IllegalStateException.class, new Executable() { + public void execute() throws Throwable { + b.buildFileMap(); + } + }); + } + + @Test + void mixedKindKeepsOnlyItsPhoneFamilies() throws IOException { + String bundle = bundleFor("small", "lockscreen", "watchCircular", "watchCorner"); + + // It does have a phone surface, so it is emitted -- with the families that exist there. + assertTrue(bundle.contains("CN1Widget_steps")); + assertTrue(bundle.contains(".systemSmall")); + assertTrue(bundle.contains(".accessoryRectangular"), + "lockscreen is an iOS family in its own right"); + assertFalse(bundle.contains(".accessoryCircular"), + "watchCircular is a complication family and does not belong to the iOS target"); + assertFalse(bundle.contains(".accessoryCorner")); + assertFalse(bundle.contains("#if os(watchOS)"), + "Nothing watch-only reaches the iOS target, so no platform guard is needed"); + } + + @Test + void watchOnlyDetectionSeparatesTheTwoCases() { + assertTrue(IOSWidgetExtensionBuilder.isWatchOnly( + new IOSWidgetExtensionBuilder.Kind("steps") + .setIosFamilies(Arrays.asList("watchCircular", "watchCorner")))); + assertFalse(IOSWidgetExtensionBuilder.isWatchOnly( + new IOSWidgetExtensionBuilder.Kind("steps") + .setIosFamilies(Arrays.asList("small", "watchCircular"))), + "A kind with a phone family still has a surface in the iOS extension"); + assertFalse(IOSWidgetExtensionBuilder.isWatchOnly( + new IOSWidgetExtensionBuilder.Kind("steps") + .setIosFamilies(Arrays.asList("small")))); + } + + @Test + void phoneOnlyKindIsUnaffected() throws IOException { + String bundle = bundleFor("small", "medium", "large"); + + assertTrue(bundle.contains(".systemSmall, .systemMedium, .systemLarge")); + assertFalse(bundle.contains("accessory"), + "A kind that declares no watch family must not gain one"); + assertFalse(bundle.contains("#if os(watchOS)")); + } + + @Test + void watchFamilyDetectionDrivesTheWatchExtension() { + assertTrue(IOSWidgetExtensionBuilder.hasWatchFamily( + new IOSWidgetExtensionBuilder.Kind("steps") + .setIosFamilies(Arrays.asList("small", "watchCircular")))); + assertFalse(IOSWidgetExtensionBuilder.hasWatchFamily( + new IOSWidgetExtensionBuilder.Kind("steps") + .setIosFamilies(Arrays.asList("small", "medium")))); + } + + // ------------------------------------------------------------------ + // Helper + // ------------------------------------------------------------------ + + private static IOSWidgetExtensionBuilder builderFor(String... families) { + return new IOSWidgetExtensionBuilder() + .setHostBundleId("com.mycompany.myapp") + .setAppGroupId("group.com.mycompany.myapp") + .addKind(new IOSWidgetExtensionBuilder.Kind("steps") + .setName("Steps") + .setIosFamilies(Arrays.asList(families))); + } + + private static String bundleFor(String... families) throws IOException { + Map files = builderFor(families).buildFileMap(); + for (Map.Entry e : files.entrySet()) { + if (e.getKey().endsWith("CN1WidgetBundle.swift")) { + return new String(e.getValue(), StandardCharsets.UTF_8); + } + } + throw new AssertionError("The generated widget bundle was not produced: " + files.keySet()); + } +} diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index f5f7a3ddf07..3e10e199eff 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -257,6 +257,36 @@ + + Generating watch skins + + + + + + + + + + +