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 f0dae775985..2a33028a0c1 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 780a1d2c79e..77c990d7986 100644 --- a/CodenameOne/src/com/codename1/impl/health/EdtResult.java +++ b/CodenameOne/src/com/codename1/impl/health/EdtResult.java @@ -98,4 +98,72 @@ void superComplete(T value) { void superError(Throwable t) { super.error(t); } + + /// Marshalling `complete` is not enough on its own: a listener attached + /// *after* the resource has settled is run by + /// `AsyncResource.ready`/`except` inline, on whichever thread attaches it. + /// + /// That is the whole contract leaking back out. The window is small but + /// entirely reachable -- `openHealthSettings` and `openProviderSetup` + /// complete before they return, so an off-EDT caller writing the ordinary + /// `openHealthSettings().onResult(cb)` races the `callSerially` above + /// between two adjacent statements. Win the race and the callback arrives + /// on the EDT; lose it and the same line of app code delivers off the EDT. + /// It is exactly the "may or may not be on the EDT" asymmetry this class + /// exists to remove, and it fails intermittently, which is worse than + /// failing always. + /// + /// So the guarantee is applied where the callback is *invoked* rather than + /// where the resource is completed: whatever thread gets here, delivery + /// hops to the EDT if it is not already on it. Already on the EDT still + /// runs inline, so the no-runnable-per-link property above is kept. + @Override + public com.codename1.util.AsyncResource ready( + final com.codename1.util.SuccessCallback callback, + com.codename1.util.EasyThread t) { + if (t != null) { + // An explicit thread is the caller overriding the default on + // purpose; honouring the request beats honouring the default. + return super.ready(callback, t); + } + return super.ready(new OnEdt(callback), null); + } + + /// `except` is deliberately NOT wrapped the same way. + /// + /// A late `except` on an already-failed resource fires synchronously, and + /// that is relied on to read an error back without waiting -- see + /// `HealthFallbackTest.errorOf`, which documents the trick and shares it + /// with `BtTestUtil`. Hopping the error to the EDT breaks every such + /// reader: the callback has not run yet when the value is read, so the + /// error reads as null and a failing call looks like a succeeding one. + /// + /// So the asymmetry is left in place on purpose rather than overlooked. + /// Closing it means changing that read pattern everywhere it is used, + /// which is a wider behavioural change than the delivery bug this fixes + /// and belongs in its own change. + + /// Named, not anonymous, for the same SpotBugs reason as `Deliver`. + private static final class OnEdt implements com.codename1.util.SuccessCallback { + + private final com.codename1.util.SuccessCallback delegate; + + OnEdt(com.codename1.util.SuccessCallback delegate) { + this.delegate = delegate; + } + + @Override + public void onSucess(final X value) { + if (Display.getInstance().isEdt()) { + delegate.onSucess(value); + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + delegate.onSucess(value); + } + }); + } + } } 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 a2e77b60de6..98d75a00413 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..93fc4a4adfd --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -0,0 +1,562 @@ +/* + * 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. +/// +/// When there is nothing on the other end, [#isSupported()] returns false and every call here is an +/// inert no-op, so this API needs no platform conditionals around it. 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. + 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 device can talk to a counterpart app at all. False on a desktop build, + /// on a phone whose platform has no wearable link, and in the simulator with no watch window + /// open. When this is false every other call here does nothing. + /// + /// #### Returns + /// + /// true if the wearable link is available + 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. + /// + /// #### Returns + /// + /// true if a counterpart device is 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; + } + int token = 0; + if (reply != null) { + synchronized (pendingReplies) { + token = nextReplyToken++; + pendingReplies.put(Integer.valueOf(token), + new PendingReply(reply, message.getPath())); + } + } + b.sendMessage(message.getPath(), message.toByteArray(), 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) { + WearableBridge b = bridge(); + if (b != null && b.isSupported() && path != null && contents != null) { + 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 && !messageListeners.contains(l)) { + synchronized (pendingMessages) { + messageListeners.add(l); + } + activate(); + drainPending(pendingMessages); + } + } + + /// 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 && !dataListeners.contains(l)) { + synchronized (pendingData) { + dataListeners.add(l); + } + activate(); + drainPending(pendingData); + } + } + + /// 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; + } + } + if (replyToken != 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) { + deliver(new Runnable() { + @Override + public void run() { + WearableMessage m = WearableMessage.fromByteArray(path, payload); + WearableDataListener[] copy = + dataListeners.toArray(new WearableDataListener[dataListeners.size()]); + for (WearableDataListener l : copy) { + l.dataChanged(m); + } + } + }, dataListeners, pendingData); + } + + /// 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) { + deliver(new Runnable() { + @Override + public void run() { + WearableDataListener[] copy = + dataListeners.toArray(new WearableDataListener[dataListeners.size()]); + for (WearableDataListener l : copy) { + l.dataRemoved(path); + } + } + }, dataListeners, pendingData); + } + + /// 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(); + } + } + }); + } + + /// 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 void deliver(Runnable delivery, List listeners, List queue) { + // 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) { + if (listeners.isEmpty()) { + queue.add(delivery); + return; + } + } + Display.getInstance().callSerially(delivery); + } + + /// Replays what was queued for one listener type, once a listener of that type exists. + private static void drainPending(List queue) { + List drained; + synchronized (queue) { + if (queue.isEmpty()) { + return; + } + drained = new ArrayList(queue); + queue.clear(); + } + for (Runnable r : drained) { + Display.getInstance().callSerially(r); + } + } + + 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..c61469f7406 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableMessage.java @@ -0,0 +1,456 @@ +/* + * 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 + public byte[] toByteArray() { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bo); + try { + out.writeByte(FORMAT_VERSION); + 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; + } + 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; + } + int count = in.readShort(); + for (int i = 0; i < count; i++) { + String key = readLongUTF(in); + int type = in.readByte(); + switch (type) { + case TYPE_STRING: + m.put(key, readLongUTF(in)); + break; + case TYPE_INT: + m.put(key, in.readInt()); + break; + case TYPE_LONG: + m.put(key, in.readLong()); + break; + case TYPE_DOUBLE: + m.put(key, in.readDouble()); + break; + case TYPE_BOOLEAN: + m.put(key, in.readBoolean()); + break; + case TYPE_BYTES: + byte[] b = new byte[readLength(in)]; + in.readFully(b); + m.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; + } + } + } 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..738103cac9c --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/package-info.java @@ -0,0 +1,64 @@ +/* + * 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 +/// +/// On a device with no counterpart -- a phone with no paired watch, a desktop build, the +/// simulator with no watch window open -- there is no bridge, [WearableConnection#isSupported()] +/// returns false and every call is an inert no-op. Application code needs no platform conditionals. +/// +/// 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..073b2c80e83 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.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 this device can talk to a counterpart at all -- the transport exists and + /// the app is allowed to use it. False on a platform with no wearable link, which makes the + /// whole public API inert. + /// + /// #### Returns + /// + /// true if the wearable transport is available + 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 63702181cb5..8e6d0f651bd 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..a2266712301 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java @@ -255,6 +255,10 @@ public void run() { 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 { @@ -264,6 +268,41 @@ public void run() { 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) { @@ -702,23 +741,39 @@ public boolean onHoverEvent(MotionEvent event) { * 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. + * 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)); + this.implementation.pointerWheelMoved(x, y, 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; } - 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)); @@ -727,6 +782,36 @@ public boolean onGenericMotionEvent(MotionEvent event) { return false; } + /** + * 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 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 7353168fe4a..d3ca6f46883 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,36 @@ 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"; + + static { + 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 +853,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 +975,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 +1054,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 @@ -4700,6 +4791,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")); @@ -5581,6 +5673,79 @@ 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"); + 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"); + new ProcessBuilder(cmd).inheritIO().start(); + } 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. @@ -7097,6 +7262,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); @@ -13997,6 +14166,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; } @@ -14888,6 +15064,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..91717f8e44d --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java @@ -0,0 +1,692 @@ +/* + * 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.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; + /// 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) { + 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(); + 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(); + } + } + + // --- replicated data ---------------------------------------------------- + + public void putData(String path, byte[] payload) { + writeValue(dataFile(path), payload, path); + } + + private void 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()); + FileOutputStream out = new FileOutputStream(tmp); + try { + 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. + long stamp = nextStamp(f); + tmp.setLastModified(stamp); + if (!tmp.renameTo(f)) { + f.delete(); + if (!tmp.renameTo(f)) { + throw new IOException("could not replace " + f); + } + } + // Our own write must not come back to us as a peer change. + synchronized (seenData) { + seenData.put(f.getName(), Long.valueOf(stamp)); + } + } catch (IOException err) { + com.codename1.io.Log.p("Wearable simulator: failed to publish " + path + ": " + err); + } + } + + public byte[] getData(String path) { + File f = dataFile(path); + if (!f.exists()) { + return null; + } + try { + return readFully(f); + } catch (IOException err) { + return null; + } + } + + public void removeData(String path) { + File f = dataFile(path); + if (f.delete()) { + synchronized (seenData) { + seenData.remove(f.getName()); + } + } + } + + 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())) { + 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() { + 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 { + connectLoop(); + } + } + }, "CN1 wearable link"); + t.setDaemon(true); + t.start(); + } + + private void acceptLoop(ServerSocket server) { + while (!closed) { + try { + Socket s = server.accept(); + adoptPeer(s); + readLoop(s); + } catch (IOException err) { + if (closed) { + return; + } + } + } + } + + private void connectLoop() { + while (!closed) { + try { + Socket s = new Socket(InetAddress.getByName("127.0.0.1"), port()); + adoptPeer(s); + readLoop(s); + } catch (IOException notUpYet) { + // The peer app is not running. Wait and retry -- the user may open it at any point. + } + if (closed) { + return; + } + try { + Thread.sleep(1000); + } catch (InterruptedException ignored) { + return; + } + } + } + + private void adoptPeer(Socket s) throws IOException { + s.setTcpNoDelay(true); + peer = s; + peerOut = new DataOutputStream(s.getOutputStream()); + writeFrame(peerOut, FRAME_HELLO, "", new byte[0], 0); + WearableConnection.notifyStateChanged(); + } + + private void readLoop(Socket s) { + 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_MESSAGE: + WearableConnection.deliverMessage(path, payload, token); + break; + case FRAME_REPLY: + 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(); + } + } + + private void dropPeer() { + 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(); + } + } + + /// 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. + } + + private void scanData() { + File[] files = dataDir.listFiles(); + 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(); + if (previous != null && previous.longValue() == stamp) { + continue; + } + synchronized (seenData) { + seenData.put(f.getName(), Long.valueOf(stamp)); + } + 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; + } + try { + WearableConnection.deliverDataChanged(deliveryPath(f.getName()), readFully(f)); + if (isInboundTransfer(f.getName())) { + // A transfer is one-shot, so the delivered file goes. Leaving it would make + // every restart of the receiving simulator replay it -- primeSeenData() + // deliberately records nothing so that offline values DO replay, and without + // this a transfer would be caught by the same rule. Distinct path/name pairs + // would also pile up in the shared directory indefinitely. + // + // Only an INBOUND transfer, though: deleting our own would destroy a + // transfer still waiting for the peer to start. Unlike the device ports + // there is exactly one peer here, so consuming an inbound file cannot + // deprive a second watch of it. + f.delete(); + synchronized (seenData) { + seenData.remove(f.getName()); + } + } + } catch (IOException stillBeingWritten) { + // Re-reported on the next pass once the writer has finished. + synchronized (seenData) { + seenData.remove(f.getName()); + } + } + } + } + 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; + } + 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); + } + } + + 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); + if ((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(); + } + + 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..c7dcffe293a --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h @@ -0,0 +1,103 @@ +/* + * 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; + +@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); +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..b8588720e7a --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -0,0 +1,652 @@ +/* + * 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), and the counter +/// breaks ties between two publishes inside the same millisecond on this device. +static int64_t cn1WearableNextSequence(void) { + static int64_t last = 0; + static dispatch_once_t once; + static NSLock *lock = nil; + dispatch_once(&once, ^{ + lock = [[NSLock alloc] init]; + }); + [lock lock]; + int64_t now = (int64_t) ([[NSDate date] timeIntervalSince1970] * 1000.0); + last = now > last ? now : last + 1; + int64_t result = last; + [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; + } + if ([tomb isKindOfClass:[NSNumber class]]) { + int64_t t = [tomb longLongValue]; + // 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. Ours wins ties, which only happens when both +/// sides predate stamping or published inside the same millisecond. +static BOOL cn1WearableLocalWins(CN1WearableEntry mine, CN1WearableEntry theirs) { + if (!theirs.known) { + return YES; + } + if (!mine.known) { + return NO; + } + return mine.stamp >= theirs.stamp; +} + +/// 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; + +/// 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. +static void cn1WearablePruneTombstones(NSMutableDictionary *ctx, NSDictionary *peerCtx) { + NSMutableArray *tombKeys = [NSMutableArray array]; + for (NSString *key in ctx.allKeys) { + if ([key isKindOfClass:[NSString class]] && [key hasPrefix:kTombPrefix]) { + [tombKeys addObject:key]; + } + } + int64_t now = (int64_t) ([[NSDate date] timeIntervalSince1970] * 1000.0); + 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; + } + if (now - [stamp longLongValue] <= 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. + NSString *path = [key substringFromIndex:kTombPrefix.length]; + CN1WearableEntry theirs = cn1WearableEntryFor(peerCtx, path); + if (theirs.known && !theirs.removed && theirs.stamp < [stamp longLongValue]) { + continue; + } + [ctx removeObjectForKey:key]; + } + 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); + }]; + for (NSUInteger i = 0; i + kCN1MaxTombstones < remaining.count; 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]; + } +} + +/// 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; + int _nextInboundToken; + /// Keys the peer's last context carried, so a key that vanishes is reported as a removal. + NSSet *_lastReceivedKeys; +} + ++ (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]; + _nextInboundToken = 1; + _lastReceivedKeys = [[NSSet 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]; + } + 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; + } + 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()); + [ctx removeObjectForKey:cn1WearableTombKey(path)]; + NSError *err = nil; + [s updateApplicationContext:ctx error:&err]; + 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; + } + 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)]; + ctx[cn1WearableTombKey(path)] = @(cn1WearableNextSequence()); + cn1WearablePruneTombstones(ctx, [s receivedApplicationContext]); + NSError *err = nil; + [s updateApplicationContext:ctx error:&err]; + [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 { + 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) { + 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; + [stored release]; + } + } + cn1_wearable_deliverMessage(path.UTF8String, body.bytes, (int) body.length, token); +} + +- (void)session:(WCSession *)session + didReceiveApplicationContext:(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. + NSDictionary *localCtx = [session applicationContext]; + NSMutableSet *seen = [NSMutableSet set]; + for (NSString *path in cn1WearableAllPaths(nil, applicationContext)) { + [seen addObject:path]; + CN1WearableEntry theirs = cn1WearableEntryFor(applicationContext, path); + CN1WearableEntry mine = cn1WearableEntryFor(localCtx, path); + if (cn1WearableLocalWins(mine, theirs)) { + continue; + } + if (theirs.removed) { + cn1_wearable_deliverDataRemoved(path.UTF8String); + } else if (theirs.data != nil) { + cn1_wearable_deliverDataChanged(path.UTF8String, theirs.data.bytes, + (int) theirs.data.length); + } + } + for (NSString *gone in _lastReceivedKeys) { + if (![seen containsObject:gone]) { + // 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. + CN1WearableEntry mine = cn1WearableEntryFor(localCtx, gone); + if (!mine.known || mine.removed) { + cn1_wearable_deliverDataRemoved(gone.UTF8String); + } + } + } + [_lastReceivedKeys release]; + _lastReceivedKeys = [seen retain]; +} + +- (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; + } + NSData *wrapped = cn1WearableWrapFile(file.fileURL.lastPathComponent, body); + cn1_wearable_deliverDataChanged(path.UTF8String, wrapped.bytes, (int) wrapped.length); +} + +@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 797d7259761..b7b26f055b6 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -14993,6 +14993,256 @@ 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_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]; + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG [paths 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_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) { +} + +#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 4a5af9111fc..20b55efa7b7 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 1bd0ea7d7dc..9daa2b16436 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1120,6 +1120,53 @@ 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); + // --- 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..e1f8e0824a9 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +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(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + } + + 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: a CN1 path is URL-shaped and never contains one, and a single string + // keeps the native signature to primitives. + java.util.List parts = com.codename1.util.StringUtil.tokenize(joined, '\n'); + return parts.toArray(new String[parts.size()]); + } + + 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..a132b8e2f5d --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java @@ -0,0 +1,100 @@ +/* + * 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); + 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 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..4dd8335bb87 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,38 @@ 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] +---- + +A standalone build produces a watch-only product on Apple, and on Android turns +the single APK into the Wear OS app. + +=== 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 +111,195 @@ 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. + +==== Asking a Question -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. +When you need an answer rather than a value, send a message and handle the reply: -==== watchOS Build Hints +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-004,indent=0] +---- -[cols="2,1,4"] +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()` is false where there is nothing to talk to at all, and every call +is then a harmless no-op, so this API needs no platform conditionals around it. +`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. 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%] + +==== 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. + +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 +322,68 @@ 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 produces a watch-only product. 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 produce the watch app through +the same iOS build -- declare the watch main class and build for iOS. === 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 +391,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..e18dffd8586 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,36 @@ 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 static String appLifecycleClass(BuildRequest request) { + 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 +1416,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 +1697,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 +2762,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 +3036,53 @@ && 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. + File wearValues = new File(projectDir, "app/src/main/res/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 +3991,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 +4413,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + remoteControlService + hceService + carAppService + + wearableListenerService + surfacesManifestEntries + " \n" + " \n" @@ -4736,14 +4891,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 +4958,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 +5180,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 +5299,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 +5316,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 +5353,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 +5715,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 +7409,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 68d474a7d26..89fe595f491 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 @@ -161,6 +161,11 @@ 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; private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -1066,6 +1071,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+). @@ -2469,6 +2480,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); @@ -3093,6 +3113,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) { @@ -4852,6 +4885,15 @@ private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { widgetBuilder.addKind(kind); } + 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); 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..26504a0acf5 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,56 @@ 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"; + teamId = request.getArg("ios.release.teamId", + request.getArg("ios.teamId", + request.getArg("ios.debug.teamId", ""))); + displayName = request.getDisplayName() != null + ? request.getDisplayName() : request.getMainClass(); } boolean isStandalone() { - return "standalone".equalsIgnoreCase(distribution); - } - - String getMinDeploymentTarget() { - return minDeploymentTarget; + return standalone; } /** Fully-qualified watch lifecycle entry class. */ @@ -414,6 +399,37 @@ 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 + */ + 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,9 +442,13 @@ 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. + plistString(sb, "CFBundleShortVersionString", shortVersion(request)); + plistString(sb, "CFBundleVersion", + request.getArg("ios.bundleVersion", shortVersion(request))); // Modern single-target watch app marker. sb.append(" WKApplication\n \n"); if (!isStandalone()) { @@ -723,7 +743,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 +771,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 +783,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 +810,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") @@ -863,11 +894,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 +918,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 +942,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 +965,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 +993,7 @@ 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)"); } 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..a2242abca1b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java @@ -0,0 +1,1943 @@ +/* + * 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.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.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(); + } + + // --- 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. + return !connectedNodes().isEmpty() || !bondedNodeIds().isEmpty(); + } + + /// 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; + } + if (!connected.isEmpty()) { + // A populated snapshot that does not contain the sender is real evidence against it. + 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 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; + } + } + return false; + } + + /// 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 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(); + 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.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(); + 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) { + // 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(); + } + } + int local = nextLocalToken++; + inboundNodes.put(Integer.valueOf(local), new InboundRequest(nodeId, peerToken)); + return local; + } + } + + /** 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; + if (b == null || value <= persistedClock) { + return; + } + persistedClock = value; + try { + b.context.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. + } + } + + /** 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) { + // 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. + try { + ResolvedValue v = resolveValue(context, path); + return v == null ? null : v.payload; + } catch (java.io.IOException unavailable) { + return null; + } + } + + /// 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) { + Uri uri = new Uri.Builder().scheme("wear").authority("*").path(dataPath(path)).build(); + dataClient.deleteDataItems(uri); + } + + public String[] getDataPaths() { + 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); + } + } + return out.toArray(new String[out.size()]); + } finally { + items.release(); + } + } catch (Throwable unavailable) { + return new String[0]; + } + } + + 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; + /** 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() { + // 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) { + return; + } + sweepScheduled = true; + lastSweepAt = now; + } + final long cutoff = System.currentTimeMillis() - TRANSFER_RETENTION_MILLIS; + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + try { + DataItemBuffer items = Tasks.await(dataClient.getDataItems(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + String localNode = localNodeId(context); + 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 (localNode == null || !localNode.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); + if (publishedAt != Long.MIN_VALUE && publishedAt < cutoff) { + dataClient.deleteDataItems(item.getUri()); + } + } + } finally { + items.release(); + } + } catch (Throwable unavailable) { + // Best effort: the next transfer sweeps again. + } finally { + synchronized (sweepLock) { + sweepScheduled = false; + } + } + } + }, 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 || attempt > TRANSFER_RETRIES) { + 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 { + for (DataItem item : items) { + Transfer t = decodeTransferOnce(context, item); + if (t.payload != null) { + if (claimTransfer(uri, sequenceOf(valueOrTransferMap(item)))) { + WearableConnection.deliverDataChanged(t.logicalPath, t.payload); + } + return; + } + } + } finally { + items.release(); + } + scheduleTransferRetry(context, uri, attempt + 1); + } catch (Throwable stillUnavailable) { + scheduleTransferRetry(context, uri, attempt + 1); + } + } + }, TRANSFER_RETRY_MILLIS * attempt); + } + + /** + * 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; + } + + /** + * Whether a received item is newer than the last thing delivered for its logical path. + * + *

Both nodes may publish the same path, which the Data Layer stores as two items under two + * authorities. A reconnect can then hand us the older one after the newer, and forwarding it + * would walk a listener-driven UI back to stale state while an immediate {@code getData()} still + * returned the newer value -- the listener and the getter disagreeing about the same path. + * + * @param path the logical path + * @param sequence the item's publication stamp + * @return true when the event should be delivered + */ + static boolean isNewerThanDelivered(String path, long sequence, String node) { + synchronized (deliveredSequences) { + String previous = deliveredSequences.get(path); + if (previous != null) { + int split = previous.indexOf('|'); + long prevSeq = Long.parseLong(previous.substring(0, split)); + String prevNode = previous.substring(split + 1); + if (!outranks(sequence, node, prevSeq, prevNode.length() == 0 ? null : prevNode)) { + return false; + } + } + deliveredSequences.put(path, sequence + "|" + (node == null ? "" : node)); + return true; + } + } + + /** + * 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 {@link #isNewerThanDelivered}, 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); + } + } + + /** 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); + } + } + + static void forgetDeliveredSequence(String path) { + synchronized (deliveredSequences) { + deliveredSequences.remove(path); + } + } + + /** + * 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. + */ + private static final Map deliveredSequences = new HashMap(); + + /** + * 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) { + if (!pendingWinnerPaths.add(path)) { + return; + } + } + pendingDeletions(path, afterDeletion); + scheduleWinnerResolution(context, path, 1); + } + + private static void scheduleWinnerResolution(final Context context, final String path, + final int attempt) { + transferTimer.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); + ResolvedValue winner = resolveValue(context, path); + if (winner != null) { + // Compare-and-replace under one lock. A plain replace would overwrite the + // newer stamp with this older one and hand the app the older payload, and + // the newer value would then stay hidden behind the stamp it just lost to. + if (setDeliveredSequenceIfOutranks(path, winner.sequence, winner.node)) { + WearableConnection.deliverDataChanged(path, winner.payload); + } + } else if (wasAfterDeletion(path) + && forgetDeliveredSequenceIfUnchanged(path, before)) { + // Empty AND nothing landed while we were asking. Announcing a removal for a + // path a concurrent publication has since refilled is the one error the app + // cannot recover from, so it is conditional on the stamp being untouched. + // Dropping the stamp stays coupled to the removal: a value republished later + // with a lower sequence must not then be filtered as older. + WearableConnection.deliverDataRemoved(path); + } + forgetPendingWinner(path); + } catch (Throwable stillUnavailable) { + if (attempt >= WINNER_RETRIES) { + // Give up rather than deliver something unverified. The path stays unstamped, + // so the next event for it resolves from scratch. + forgetPendingWinner(path); + return; + } + scheduleWinnerResolution(context, path, attempt + 1); + } + } + }, WINNER_RETRY_MILLIS * attempt); + } + + private static void forgetPendingWinner(String path) { + synchronized (pendingWinnerPaths) { + pendingWinnerPaths.remove(path); + deletionPaths.remove(path); + } + } + + /** + * 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 java.util.Set deletionPaths = new java.util.HashSet(); + + private static void pendingDeletions(String path, boolean afterDeletion) { + if (!afterDeletion) { + return; + } + synchronized (pendingWinnerPaths) { + deletionPaths.add(path); + } + } + + private static boolean wasAfterDeletion(String path) { + synchronized (pendingWinnerPaths) { + return deletionPaths.contains(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(Uri uri, long sequence) { + if (uri == null) { + return true; + } + // 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) { + int split = previous.indexOf('|'); + long prevSeq = Long.parseLong(previous.substring(0, split)); + String prevNode = previous.substring(split + 1); + if (!outranks(sequence, uri.getHost(), prevSeq, + prevNode.length() == 0 ? null : prevNode)) { + return false; + } + } + transferClaims.put(key, + sequence + "|" + (uri.getHost() == null ? "" : uri.getHost())); + return true; + } + } + + /** + * 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..972fe1efcbb --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java @@ -0,0 +1,320 @@ +/* + * 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(MessageEvent event) { + 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); + } + } + + @Override + public void onDataChanged(DataEventBuffer events) { + ensureAppRunning(); + for (DataEvent event : events) { + android.net.Uri uri = event.getDataItem().getUri(); + String path = uri.getPath(); + boolean transferItem = CN1WearableBridge.isTransferPath(path); + if (path == null || !isFromAKnownHost(uri) + || (!transferItem && !path.startsWith(CN1WearableBridge.pathPrefix()))) { + continue; + } + 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()); + if (transfer.payload != null + && CN1WearableBridge.claimTransfer(uri, CN1WearableBridge.sequenceOf( + CN1WearableBridge.valueOrTransferMap(event.getDataItem())))) { + // 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. + WearableConnection.deliverDataChanged(transfer.logicalPath, transfer.payload); + } + // 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())); + 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 { + // 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); + 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 this is the ONE caller that deliberately + // keeps the unconditional setDeliveredSequence rather than the + // outranks-guarded form the resolution paths use: the stamp being replaced + // describes an item that no longer exists, so there is no newer live value + // to protect here. + // + // 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. + if (CN1WearableBridge.setDeliveredSequence( + appPath, remaining.sequence, remaining.node)) { + WearableConnection.deliverDataChanged(appPath, 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. + CN1WearableBridge.forgetDeliveredSequence(appPath); + WearableConnection.deliverDataRemoved(appPath); + } + } 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; + } + 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, not a plain replace: resolveValueWithRetry blocks (and + // retries), so another callback can stamp this path with a NEWER publication + // while it runs. Overwriting that would deliver the older payload and leave the + // older stamp recorded, hiding the newer value behind it. + if (winner != null && CN1WearableBridge.setDeliveredSequenceIfOutranks( + appPath, winner.sequence, winner.node)) { + WearableConnection.deliverDataChanged(appPath, 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; + } + } + if (!CN1WearableBridge.isNewerThanDelivered( + appPath, CN1WearableBridge.sequenceOf(value), uri.getHost())) { + // An older item arriving after a newer one, which a reconnect can do when both nodes + // publish this path. getData() would return the newer value, so delivering this would + // make the listener and the getter disagree. + continue; + } + WearableConnection.deliverDataChanged(appPath, 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..864fec947b9 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java @@ -0,0 +1,263 @@ +/* + * 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; + } + + 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/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java index c51fc2e35d6..e3458a85f29 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java @@ -320,6 +320,56 @@ public void run() { }, true); } + /** + * The same guarantee when the listener attaches AFTER the resource has + * settled. + * + *

{@code aFacadeActionDeliversOnTheEdt} above only catches this by + * luck. {@code openHealthSettings} completes before it returns, so + * off the EDT it schedules delivery and then races the caller's + * {@code onResult} across two adjacent statements. Win the race and the + * callback is registered before completion and arrives on the EDT; lose it + * and {@code AsyncResource.ready} sees a settled resource and runs the + * callback inline, on the caller's thread. Intermittent, and it read as a + * flaky test rather than the contract breaking.

+ * + *

This forces the losing side: wait for the resource to settle, THEN + * attach. Before the fix this failed every run rather than one in + * many.

+ */ + @Test + void aLateListenerStillDeliversOnTheEdt() { + final Landing landing = new Landing(); + CN.invokeAndBlock(new Runnable() { + public void run() { + assertFalse(CN.isEdt(), "the operation must start off the EDT"); + AsyncResource r = Health.getInstance().openHealthSettings(); + long deadline = System.currentTimeMillis() + 10_000L; + while (!r.isDone() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(10L); + } catch (InterruptedException ex) { + return; + } + } + assertTrue(r.isDone(), "the resource must settle before we attach"); + r.onResult(landing); + deadline = System.currentTimeMillis() + 10_000L; + while (!landing.arrived.get() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(10L); + } catch (InterruptedException ex) { + return; + } + } + } + }); + assertTrue(landing.arrived.get(), "the callback must arrive"); + assertTrue(landing.onEdt.get(), + "a listener attached after completion must still land on the EDT"); + } + @Test void aDeleteDeliversOnTheEdt() { final FakeHealthStore store = new FakeHealthStore(); diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index f5f7a3ddf07..26f4810350f 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -257,6 +257,27 @@ + + Generating watch skins + + + + + + + + +