diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java
index fab9e846308..b4a1f2544fb 100644
--- a/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java
+++ b/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java
@@ -251,8 +251,13 @@ public static long nextEntryFlip(Map timelineDoc, long now) {
}
/// Picks the layout of a timeline document for a size name (`small` / `medium` / `large` /
- /// `lockscreen`): the explicit per-size layout when present, else the `default` layout, else
- /// null.
+ /// `lockscreen` / the `watch*` complication families): the explicit per-size layout when
+ /// present, else a family-specific substitute, else the `default` layout, else null.
+ ///
+ /// Two substitutions, matching what the platform renderers do so a preview and a device
+ /// agree. `watchCorner` falls back to `watchCircular`, because a corner complication is
+ /// round and Wear OS has no corner slot at all; `watchRectangular` falls back to
+ /// `lockscreen`, which is the same WidgetKit family on Apple.
///
/// #### Parameters
///
@@ -274,6 +279,17 @@ public static Map layoutForSize(Map timelineDoc,
}
Map layouts = (Map) layoutsObj;
Object layout = sizeName == null ? null : layouts.get(sizeName);
+ if (!(layout instanceof Map) && sizeName != null) {
+ String substitute = null;
+ if ("watchCorner".equals(sizeName)) {
+ substitute = "watchCircular";
+ } else if ("watchRectangular".equals(sizeName)) {
+ substitute = "lockscreen";
+ }
+ if (substitute != null) {
+ layout = layouts.get(substitute);
+ }
+ }
if (!(layout instanceof Map)) {
layout = layouts.get("default");
}
@@ -282,15 +298,23 @@ public static Map layoutForSize(Map timelineDoc,
// --- dynamic text ----------------------------------------------------------
- /// Formats a dynamic-text value the way the OS-native views would show it. Package-private so
- /// unit tests can cover the formatting without a `Display`.
+ /// Formats a dynamic-text value the way the OS-native views would show it.
+ ///
+ /// Public because a surface that cannot tick natively needs the text form: a Wear
+ /// complication slot takes a string, and a Tile freezes its value between timeline flips.
+ /// Both go through this rather than formatting for themselves, so a countdown reads the same
+ /// on a watch face as in the simulator preview and on a home screen.
///
/// #### Parameters
///
/// - `style`: the wire style name (`timerDown`, `timerUp`, `time`, `date`, `relative`)
/// - `dateMillis`: the target epoch millis
/// - `now`: the current epoch millis
- static String formatDynamicText(String style, long dateMillis, long now) {
+ ///
+ /// #### Returns
+ ///
+ /// the formatted value
+ public static String formatDynamicText(String style, long dateMillis, long now) {
if ("timerUp".equals(style)) {
return formatTimer(now - dateMillis);
}
diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java
index 7646d369495..f7ad9d89a9d 100644
--- a/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java
+++ b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java
@@ -359,7 +359,10 @@ private static byte[] encode(Image img) {
private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();
- private static String fnv1a(byte[] data) {
+ /// Package-visible so `Surfaces.publishRemote` can check that a name a server supplied
+ /// really is the hash of the bytes beside it. One implementation, because two would
+ /// eventually disagree and the disagreement would look like corruption.
+ static String fnv1a(byte[] data) {
long hash = 0xcbf29ce484222325L;
for (byte b : data) {
hash ^= b & 0xff;
diff --git a/CodenameOne/src/com/codename1/surfaces/Surfaces.java b/CodenameOne/src/com/codename1/surfaces/Surfaces.java
index bc02026284a..3ebfae7cb74 100644
--- a/CodenameOne/src/com/codename1/surfaces/Surfaces.java
+++ b/CodenameOne/src/com/codename1/surfaces/Surfaces.java
@@ -28,6 +28,7 @@
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -197,19 +198,171 @@ public static void publish(String kindId, WidgetTimeline timeline) {
}
Map images = new LinkedHashMap();
String json = SurfaceSerializer.serializeTimeline(kindId, timeline, images);
- b.publishWidgetTimeline(kindId, json, images);
+ synchronized (publishLock(kindId)) {
+ b.publishWidgetTimeline(kindId, json, images);
+ }
+ }
+
+ /// One monitor per kind, created on demand and never removed. Kind ids come from
+ /// surfaces.json, so the set is bounded by the app's own declaration.
+ private static final Map PUBLISH_LOCKS = new HashMap();
+
+ /// The monitor that serializes publishes of a single kind.
+ ///
+ /// A publish is a WRITE FOLLOWED BY A HAND-OFF, and the two are only meaningful as a pair:
+ /// the platform replaces the timeline in its container and then gives the same descriptor to
+ /// the watch. Let two publishes of one kind interleave and the later write can be paired with
+ /// the earlier hand-off, so the watch is left holding a descriptor the phone has already
+ /// replaced -- and left holding it for good, because nothing publishes again to correct it.
+ /// The imagery is worse than stale rather than merely old: both platforms read the blobs back
+ /// off disk at hand-off time, so the descriptor of one publish can be sent with the artwork of
+ /// another, which is a pairing neither publish ever produced.
+ ///
+ /// publish() documents itself as callable from any thread, so two threads publishing one kind
+ /// is a supported way to call this rather than an abuse of it.
+ ///
+ /// Per KIND rather than one global monitor: a publish is file I/O plus a synchronous native
+ /// call, and two different kinds have nothing to say to each other.
+ private static Object publishLock(String kindId) {
+ synchronized (PUBLISH_LOCKS) {
+ Object lock = PUBLISH_LOCKS.get(kindId);
+ if (lock == null) {
+ lock = new Object();
+ PUBLISH_LOCKS.put(kindId, lock);
+ }
+ return lock;
+ }
}
/// Push-framework entry point for a server-rendered timeline descriptor. The descriptor uses
/// the same wire format as `publish()`. The descriptor is persisted directly once the
/// Codename One runtime receives it. A platform that doesn't run application code for a
/// background push applies it when the application next starts or resumes.
+ ///
+ /// Equivalent to [#publishRemote(String,String,Map)] with no imagery. A descriptor that
+ /// references an image by name renders a gap where it should be, so prefer the overload
+ /// whenever the artwork travelled with the descriptor.
public static void publishRemote(String kindId, String timelineJson) {
+ publishRemote(kindId, timelineJson, Collections.emptyMap());
+ }
+
+ /// As [#publishRemote(String,String)], with the imagery the descriptor references.
+ ///
+ /// A timeline's node tree names its images rather than embedding them -- `SurfaceSerializer`
+ /// hashes the bytes and puts the hash on the wire -- so a descriptor that arrived from
+ /// somewhere else is only complete if its side-map arrived too. Without this overload
+ /// `publishRemote` discarded the imagery unconditionally and every referenced image rendered
+ /// as a gap.
+ ///
+ /// The two callers are a server push and the phone-to-watch mirror, which forwards a
+ /// phone-side `publish()` of a watch-bearing kind to the watch. Both are the same operation:
+ /// a descriptor produced elsewhere, applied here.
+ ///
+ /// #### Parameters
+ ///
+ /// - `kindId`: the widget kind id
+ /// - `timelineJson`: the serialized timeline, in the same wire format `publish()` produces
+ /// - `images`: the referenced images by name, or an empty map when the descriptor names none
+ public static void publishRemote(String kindId, String timelineJson,
+ Map images) {
SurfaceBridge b = bridgeInternal();
if (b == null || !b.areWidgetsSupported() || kindId == null || timelineJson == null) {
return;
}
- b.publishWidgetTimeline(kindId, timelineJson, Collections.emptyMap());
+ // The KIND is input here too, and a worse one to get wrong than an image name: every
+ // platform composes it into a directory path -- iOS as container + "/cn1surfaces/" +
+ // kindId -- so "../activities/foo" writes the timeline AND its imagery outside the kind
+ // directory, over whatever is there. publish() cannot produce such an id because
+ // WidgetKind refuses it at construction; a descriptor that arrived from a server or from
+ // the watch mirror never passed through that check, so it gets it here. The same
+ // validator, not a second copy of the grammar.
+ if (!WidgetKind.isValidId(kindId)) {
+ Log.p("Surfaces: refusing a remote publish for a kind id that is not [a-z][a-z0-9_]*: "
+ + kindId);
+ return;
+ }
+ // The same monitor publish() uses: a remote descriptor and a local one race exactly the
+ // same way, and a push landing while the app publishes is the ordinary way it happens.
+ synchronized (publishLock(kindId)) {
+ b.publishWidgetTimeline(kindId, timelineJson, safeImageNames(images));
+ }
+ }
+
+ /// The image side-map with anything that is not a plain blob name removed.
+ ///
+ /// A name here is a content hash produced by `SurfaceSerializer`, and every platform turns it
+ /// into a file inside the kind's own directory. This descriptor did NOT come from this
+ /// process, though -- a server push and the watch mirror both arrive from outside -- so the
+ /// names are input, not something the app computed. A name carrying a separator or a parent
+ /// segment escapes that directory: on iOS the path is composed as `dir + "/" + key + ".png"`
+ /// with no sanitizing of its own, so `../other_kind/hash` plants a blob under a different
+ /// kind, where a later legitimate publish will not replace it -- content-hash names are
+ /// assumed to already hold the right bytes.
+ ///
+ /// Dropped rather than rejected wholesale: a descriptor referencing an image that did not
+ /// arrive renders a gap, which every renderer already tolerates, and refusing the whole
+ /// publish would let one bad name suppress a timeline that is otherwise fine.
+ /// The prefix `SurfaceSerializer.registerImageBytes` puts in front of a content hash.
+ private static final String CONTENT_HASH_PREFIX = "img";
+
+ /// Whether a name has the shape SurfaceSerializer gives a content hash: the `img` prefix and
+ /// sixteen lowercase hex digits. The prefix is the point -- checking for bare hex matched
+ /// nothing the framework produces, so the integrity check below never ran on a real payload
+ /// at all, and would have compared a prefixed name against an unprefixed hash if it had.
+ ///
+ /// Only names of this shape are verified, so one that was never a hash -- a registered image
+ /// the app named itself -- is passed through rather than refused for failing a test that does
+ /// not apply to it.
+ private static boolean looksLikeContentHash(String name) {
+ if (name.length() != CONTENT_HASH_PREFIX.length() + 16
+ || !name.startsWith(CONTENT_HASH_PREFIX)) {
+ return false;
+ }
+ for (int i = CONTENT_HASH_PREFIX.length(); i < name.length(); i++) {
+ char c = name.charAt(i);
+ if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static Map safeImageNames(Map images) {
+ if (images == null || images.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map safe = new LinkedHashMap();
+ for (Map.Entry e : images.entrySet()) {
+ String name = e.getKey();
+ if (name == null || name.length() == 0 || name.indexOf('/') >= 0
+ || name.indexOf('\\') >= 0 || name.indexOf(':') >= 0
+ || name.indexOf('\0') >= 0 || ".".equals(name) || "..".equals(name)) {
+ Log.p("Surfaces: dropping a remote image whose name is not a plain blob name: "
+ + name);
+ continue;
+ }
+ if (looksLikeContentHash(name) && e.getValue() != null
+ && !name.equals(CONTENT_HASH_PREFIX + SurfaceSerializer.fnv1a(e.getValue()))) {
+ // The name is a CLAIM about the bytes, and this descriptor came from outside the
+ // process. iOS skips writing a blob whose file already exists, on the strength of
+ // that claim -- so bad bytes landing first cannot be repaired by any later
+ // legitimate publish, and the surface shows wrong artwork for good. Checking the
+ // claim costs one pass over bytes that are about to be written anyway.
+ Log.p("Surfaces: dropping a remote image whose bytes do not match its name: "
+ + name);
+ continue;
+ }
+ if (e.getValue() == null) {
+ // A name with no bytes -- one attachment of several failing to decode is the
+ // ordinary way to get one. Android skips a null value; the iOS bridge writes it
+ // straight to an OutputStream and the NullPointerException escapes its IOException
+ // catch, so one missing blob aborted a publish whose timeline was otherwise fine.
+ Log.p("Surfaces: dropping a remote image with no bytes: " + name);
+ continue;
+ }
+ safe.put(name, e.getValue());
+ }
+ return safe;
}
/// Asks the platform to re-render widgets from their already-published timelines.
diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetKind.java b/CodenameOne/src/com/codename1/surfaces/WidgetKind.java
index 083436e70f0..6a7009d76fa 100644
--- a/CodenameOne/src/com/codename1/surfaces/WidgetKind.java
+++ b/CodenameOne/src/com/codename1/surfaces/WidgetKind.java
@@ -51,7 +51,11 @@ public WidgetKind(String id) {
this.id = id;
}
- private static boolean isValidId(String id) {
+ /// Whether an id matches the documented `[a-z][a-z0-9_]*` grammar.
+ ///
+ /// Package-visible because `Surfaces.publishRemote` has to apply the same rule to an id that
+ /// arrived from outside the process, and two copies of a grammar is how they come to disagree.
+ static boolean isValidId(String id) {
int n = id.length();
if (n == 0) {
return false;
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java
index 30bad8715eb..ba9b8cd098a 100644
--- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java
@@ -86,16 +86,28 @@ public void registerWidgetKind(String kindJson) {
try {
ctx.getPackageManager().getReceiverInfo(provider, 0);
} catch (Exception missing) {
- Log.e(TAG, "Widget kind '" + kindId + "' was registered at runtime but is not "
- + "declared in surfaces.json; the build compiles widget kinds into the "
- + "app, so this kind cannot appear in the widget gallery. Add it to "
- + "surfaces.json and rebuild.");
+ // A missing receiver is not proof the kind is missing. A kind declaring only
+ // watch families gets no CN1Widget_ receiver ON PURPOSE -- that is the whole
+ // point of the split, and iOS refuses to host one for the same declaration --
+ // so the build-time list of watch kinds has to be consulted before calling this
+ // a mistake. Without it every correct watch-only registration produced this
+ // error and told the developer to add a kind that is already there.
+ if (!CN1WatchSurface.isWatchKind(ctx, kindId)) {
+ Log.e(TAG, "Widget kind '" + kindId + "' was registered at runtime but is "
+ + "not declared in surfaces.json; the build compiles widget kinds "
+ + "into the app, so this kind cannot appear in the widget gallery. "
+ + "Add it to surfaces.json and rebuild.");
+ }
}
} catch (Throwable t) {
Log.w(TAG, "Failed to register widget kind", t);
}
}
+ // The store write and the mirror below are one operation, and they are safe to write as one
+ // because Surfaces serializes publishes of a kind against each other. Nothing here
+ // re-establishes that: interleave two of these and the later write pairs with the earlier
+ // mirror, leaving the watch on a descriptor the phone has replaced.
@Override
public void publishWidgetTimeline(String kindId, String timelineJson,
Map images) {
@@ -111,6 +123,10 @@ public void publishWidgetTimeline(String kindId, String timelineJson,
CN1SurfaceStore.rememberBackgroundFetchClass(ctx,
AndroidImplementation.getBackgroundFetchListenerClassName());
broadcastUpdate(ctx, kindId);
+ // After the local write, so neither can leave the phone's own widget wrong. Both are
+ // no-ops unless this build declared watch families.
+ CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId);
+ CN1SurfaceMirror.onPublished(ctx, kindId, timelineJson, images);
} catch (Throwable t) {
Log.w(TAG, "Failed to publish the timeline of widget kind " + kindId, t);
}
@@ -124,10 +140,22 @@ public void reloadWidgets(String kindId) {
}
if (kindId != null) {
broadcastUpdate(ctx, kindId);
+ // broadcastUpdate reaches home-screen providers and nothing else, so without this a
+ // reload of a watch-only kind did nothing at all and a mixed kind refreshed only its
+ // phone half. Same pairing as the publish path above, and the same no-op unless this
+ // build declared watch families.
+ CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId);
+ // ...and the paired watch, which the notifier above cannot reach in a companion
+ // build: its complication and Tile services live in the wear module, so a reflective
+ // lookup from the phone process finds nothing. Both calls are no-ops unless this
+ // build declared watch families.
+ CN1SurfaceMirror.requestWatchReload(ctx, kindId);
return;
}
for (String kind : CN1SurfaceStore.getRememberedKinds(ctx)) {
broadcastUpdate(ctx, kind);
+ CN1WatchSurfaceNotifier.requestUpdate(ctx, kind);
+ CN1SurfaceMirror.requestWatchReload(ctx, kind);
}
}
@@ -199,7 +227,11 @@ public static void deliverPendingActions() {
/// Maps a widget kind id to the simple name suffix of its generated provider class:
/// underscore-separated words become CamelCase (`delivery_status` -> `DeliveryStatus`).
- /// The identical logic lives in the Android builder's widget codegen; keep them in sync.
+ ///
+ /// This is the name every shipped build uses and it must not change: Android remembers a
+ /// pinned widget by its provider `ComponentName`, so a kind whose receiver is renamed leaves
+ /// the widget the user pinned naming a receiver that no longer exists, and the home screen
+ /// drops it.
static String toClassSuffix(String kindId) {
StringBuilder sb = new StringBuilder(kindId.length());
boolean upper = true;
@@ -219,9 +251,45 @@ static String toClassSuffix(String kindId) {
return sb.toString();
}
+ /// The class-name suffix the build gave this kind.
+ ///
+ /// Read from the map the build wrote, not recomputed. Which kind holds the plain folded name
+ /// is a property of the whole declared set, so a runtime holding one id cannot work it out --
+ /// and probing for a class that exists is worse than useless: `CN1Widget_Status` exists for
+ /// `status`, so `status_` probing the plain name first finds the OTHER kind's provider and
+ /// publishes into it.
+ ///
+ /// The map is data written from the same table that named the classes, so there is no second
+ /// algorithm to drift. An APK built before the map existed has no such resource and falls
+ /// back to the plain fold, which is exactly what that APK was built with.
+ static synchronized String classSuffix(Context ctx, String kindId) {
+ if (classSuffixes == null) {
+ classSuffixes = new java.util.HashMap();
+ try {
+ int id = ctx.getResources().getIdentifier("cn1_surface_kind_classes", "array",
+ ctx.getPackageName());
+ if (id != 0) {
+ for (String entry : ctx.getResources().getStringArray(id)) {
+ int eq = entry == null ? -1 : entry.indexOf('=');
+ if (eq > 0) {
+ classSuffixes.put(entry.substring(0, eq), entry.substring(eq + 1));
+ }
+ }
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not read the surface kind class map; using the plain fold", t);
+ }
+ }
+ String mapped = kindId == null ? null : classSuffixes.get(kindId);
+ return mapped != null ? mapped : toClassSuffix(kindId);
+ }
+
+ /// Cached for the process: the map is build-time data and cannot change under a running app.
+ private static java.util.Map classSuffixes;
+
private static ComponentName providerComponent(Context ctx, String kindId) {
return new ComponentName(ctx.getPackageName(),
- "com.codename1.impl.android.CN1Widget_" + toClassSuffix(kindId));
+ "com.codename1.impl.android.CN1Widget_" + classSuffix(ctx, kindId));
}
private static void broadcastUpdate(Context ctx, String kindId) {
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java
index 82147576f98..211d5683690 100644
--- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java
@@ -23,10 +23,15 @@
package com.codename1.impl.android.surfaces;
import android.app.Activity;
+import android.content.Context;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
+import java.math.BigInteger;
+import java.security.SecureRandom;
+
/// Invisible trampoline receiving surface taps (widget nodes, live activity notifications).
/// Registered by the build with `Theme.NoDisplay`, it decodes the action extras, queues the
/// action with `AndroidSurfaceBridge` (which forwards to
@@ -40,13 +45,97 @@ public class CN1SurfaceActionActivity extends Activity {
public static final String EXTRA_ACTION_ID = "CN1SurfaceActionId";
/// Intent extra carrying the action parameters as a JSON object string.
public static final String EXTRA_ACTION_PARAMS = "CN1SurfaceActionParams";
+ /// Intent extra proving the tap came from a surface this app rendered. See [#token].
+ public static final String EXTRA_TOKEN = "CN1SurfaceActionToken";
private static final String TAG = "CN1Surfaces";
+ private static final String TOKEN_PREFS = "cn1_surface_action";
+ private static final String TOKEN_KEY = "token";
+
+ /// A per-install secret shared between the code that renders a surface and this trampoline.
+ ///
+ /// A Tile's tap is not a `PendingIntent`. ProtoLayout's `LaunchAction` names a component and
+ /// the TILE HOST starts it, from its own process, so the trampoline has to be exported for a
+ /// Tile tap to arrive at all -- and an exported activity can be started by any app on the
+ /// watch, with extras of its choosing. Without this, another app could name any action id it
+ /// liked and this class would forward it to `Surfaces.dispatchAction` as though the user had
+ /// tapped it.
+ ///
+ /// The value never leaves the device: it is generated on first use, kept in the app's own
+ /// private preferences, and travels only through the layout the app hands the tile host,
+ /// which no other app can read. A caller that cannot produce it did not get here from a
+ /// surface this app drew.
+ ///
+ /// - `ctx`: any context
+ ///
+ /// Returns the token, generating it on first use, or null when it could not be stored.
+ ///
+ /// A token that was not persisted is worse than none. The tap it authenticates is handled
+ /// later -- often by another process -- which reads the preference, finds nothing, generates
+ /// a different value and rejects the very action this app drew. Two nodes rendered in one
+ /// pass could even carry different unusable tokens. So a failed commit returns null and the
+ /// caller leaves the action off: the surface still renders and the tap does nothing, which is
+ /// the honest outcome when the device cannot keep a secret for us.
+ public static synchronized String token(Context ctx) {
+ SharedPreferences prefs = ctx.getSharedPreferences(TOKEN_PREFS, Context.MODE_PRIVATE);
+ String existing = prefs.getString(TOKEN_KEY, null);
+ if (existing != null && existing.length() > 0) {
+ return existing;
+ }
+ String fresh = new BigInteger(130, new SecureRandom()).toString(32);
+ // commit() and not apply(), because the answer is the point: apply() is asynchronous and
+ // reports nothing, so there would be no moment at which this could know.
+ if (!prefs.edit().putString(TOKEN_KEY, fresh).commit()) {
+ Log.w(TAG, "Could not persist the surface action token; actions on this surface are "
+ + "left unauthenticated and will not dispatch. The device is most likely out "
+ + "of storage.");
+ return null;
+ }
+ return fresh;
+ }
+
+ /// Attaches the token to an action intent. Every producer of these extras calls this, so the
+ /// check below can be unconditional wherever it applies.
+ ///
+ /// - `ctx`: any context
+ /// - `intent`: the action intent being built
+ static void authenticate(Context ctx, Intent intent) {
+ String token = token(ctx);
+ if (token != null) {
+ intent.putExtra(EXTRA_TOKEN, token);
+ }
+ // Absent when the token could not be stored. An intent without it is rejected by
+ // trusted() exactly as an untrusted caller's would be, which is the intended outcome:
+ // better a tap that does nothing than one that dispatches without the check.
+ }
+
+ /// Whether this activity is reachable from outside the app, which is true exactly when a
+ /// Tile was generated. Read from the merged manifest rather than assumed, so the check
+ /// follows what was actually declared.
+ private boolean isExported() {
+ try {
+ return getPackageManager().getActivityInfo(getComponentName(), 0).exported;
+ } catch (Throwable t) {
+ // The manifest says what it says; a failed lookup is not a reason to start trusting
+ // callers. Non-exported is the historical shape and the safe answer for the phone.
+ Log.w(TAG, "Could not read this activity's export state; treating taps as trusted", t);
+ return false;
+ }
+ }
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
try {
Intent intent = getIntent();
+ if (intent != null && !trusted(intent)) {
+ // Nothing at all, not merely no dispatch. Bringing the app forward is itself the
+ // interesting half of what this activity does: an app that cannot forge an action
+ // could still start the trampoline in a loop and foreground this application over
+ // and over, which is a nuisance the user would blame on us. Checked before the
+ // action is read, so an intent carrying no action id is treated the same way.
+ finish();
+ return;
+ }
if (intent != null) {
String actionId = intent.getStringExtra(EXTRA_ACTION_ID);
if (actionId != null) {
@@ -61,6 +150,26 @@ protected void onCreate(Bundle savedInstanceState) {
finish();
}
+ /// Whether this tap may be dispatched.
+ ///
+ /// Only asked where it can matter. While the trampoline is private -- every build without a
+ /// Tile -- nothing outside the app can start it, and an intent that arrives is one this app
+ /// built; requiring a token there would break a `PendingIntent` a widget handed the launcher
+ /// before the app was updated, for no gain.
+ private boolean trusted(Intent intent) {
+ if (!isExported()) {
+ return true;
+ }
+ String presented = intent.getStringExtra(EXTRA_TOKEN);
+ if (presented != null && presented.equals(token(this))) {
+ return true;
+ }
+ // Loud, because the honest cases are an app update that rotated nothing and a genuinely
+ // hostile caller, and the two look identical from here.
+ Log.w(TAG, "Refusing a surface action that did not come from a surface this app drew");
+ return false;
+ }
+
private void launchMainActivity() {
try {
Intent launch = getPackageManager()
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java
new file mode 100644
index 00000000000..6425afbe128
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java
@@ -0,0 +1,573 @@
+/*
+ * 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.surfaces;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.codename1.wearable.WearableConnection;
+import com.codename1.wearable.WearableMessage;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Carries a phone-published surface timeline to the paired watch, so a complication can show it.
+ *
+ *
A watch app has its own storage: nothing the phone writes is visible there. So a phone-side
+ * {@code Surfaces.publish()} reaches a complication only if the descriptor actually travels, and
+ * this is that transport -- over the Wearable Data Layer, using the same
+ * {@code com.codename1.wearable} API an app would use by hand.
+ *
+ *
Why the port and not the core. {@code Executor.scanClassesForPermissions} scans the
+ * app's own merged classes, not the Codename One core, so a core-level reference from
+ * {@code com.codename1.surfaces} to {@code com.codename1.wearable} would not turn the Data Layer
+ * glue on -- the mirror would be injected nowhere and silently do nothing. The port can reference
+ * the wearable API freely, and the builder forces the glue on when watch families are declared.
+ *
+ *
Best-effort by contract, and always after the local write has succeeded: nothing here can
+ * leave the phone's own widget wrong. Every refusal is logged once under {@code CN1Surfaces} and
+ * nothing throws.
+ */
+public final class CN1SurfaceMirror {
+
+ private static final String TAG = "CN1Surfaces";
+
+ /**
+ * Reserved application path. {@code CN1WearableBridge} namespaces this into a single opaque
+ * segment under {@code /cn1}, so it cannot collide with a file transfer or an
+ * acknowledgement -- only with an app that literally uses this string, which the guide
+ * reserves.
+ */
+ private static final String PATH_PREFIX = "/cn1surface/";
+
+ /**
+ * A Data Layer item's inline payload is capped near 100KB and the whole put is rejected on
+ * overflow, so the descriptor is held well under it. Complication art is a few dozen points
+ * square; anything approaching this is a phone widget's artwork that a watch face would never
+ * show anyway.
+ */
+ private static final int MAX_JSON_BYTES = 64 * 1024;
+
+ private static final int MAX_IMAGE_BYTES = 256 * 1024;
+ private static final int MAX_IMAGES = 8;
+ private static final int MAX_TOTAL_IMAGE_BYTES = 1024 * 1024;
+
+ private CN1SurfaceMirror() {
+ }
+
+ /**
+ * Mirrors a freshly published timeline, when there is a watch that could show it.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind
+ * @param timelineJson the serialized timeline
+ * @param images the imagery this publish shipped, kept in the signature because the bridge
+ * has it and a future change may want it; the artwork actually sent is read from the
+ * store, which is the complete set the descriptor references
+ */
+ public static void onPublished(Context ctx, String kindId, String timelineJson,
+ Map images) {
+ try {
+ if (ctx == null || kindId == null || timelineJson == null) {
+ return;
+ }
+ if (com.codename1.ui.CN.isWatch()) {
+ // The watch's own publish is authoritative. Sending it back would hand the phone
+ // a timeline it never asked for and, when the phone mirrored in the first place,
+ // loop.
+ return;
+ }
+ if (!CN1WatchSurface.isWatchKind(ctx, kindId)) {
+ return;
+ }
+ if (!WearableConnection.isSupported()) {
+ return;
+ }
+ byte[] json = timelineJson.getBytes("UTF-8");
+ if (json.length > MAX_JSON_BYTES) {
+ Log.w(TAG, "Widget kind \"" + kindId + "\" is too large to mirror to the watch ("
+ + json.length + " bytes, cap " + MAX_JSON_BYTES + "); the watch keeps its "
+ + "previous timeline");
+ return;
+ }
+ // Imagery first, so the descriptor is never live against art that has not landed --
+ // and the STORE's copy, not the side-map this publish happened to carry. A
+ // SurfaceImage built from a previously registered name references a blob without
+ // shipping it, so the map is empty while the descriptor still names art, and a watch
+ // installed since that art was first published would have rendered a gap for ever.
+ // onPublished runs after the store write, so what is on disk is exactly the set the
+ // descriptor references.
+ //
+ // It does mean a publish that changed only text re-sends unchanged art. Nothing here
+ // deduplicated before either, the caps below still bound it, and a transfer that was
+ // not needed costs a background stream while a missing one costs a hole in the face.
+ sendImages(kindId, storedImages(ctx, kindId));
+ WearableMessage message = new WearableMessage(PATH_PREFIX + kindId);
+ message.put("v", 1);
+ message.put("json", json);
+ WearableConnection.putData(message);
+ } catch (Throwable t) {
+ // The timeline is already persisted and the phone's own widget already updated. A
+ // watch that does not hear about it is a degraded surface, not a failed publish.
+ Log.w(TAG, "Could not mirror widget kind " + kindId + " to the watch", t);
+ }
+ }
+
+ /**
+ * Asks a paired watch to re-render a kind it already has.
+ *
+ *
{@code reloadWidgets} means "draw the descriptor you already hold again", and on the
+ * watch that is a watch-local operation -- but in a COMPANION build the call runs in the phone
+ * APK, and the complication and Tile services live in the wear module, so the notifier's
+ * reflective lookups find nothing and the reload was a no-op for every mirrored surface. The
+ * phone cannot reach into the other process; it can only ask.
+ *
+ *
Asking is a re-send of the descriptor the watch already stored, which its receiver
+ * applies exactly as it applies a fresh one -- and its notifier runs THERE, where the
+ * generated services are. The nonce is what makes it arrive: the Data Layer suppresses a
+ * DataItem whose payload is unchanged, which is the behaviour a publish wants and the one a
+ * reload has to defeat. The artwork goes with it: a reload is also how a watch app installed
+ * after the publish gets its first copy of anything.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind to re-render
+ */
+ public static void requestWatchReload(Context ctx, String kindId) {
+ try {
+ if (com.codename1.ui.CN.isWatch() || !CN1WatchSurface.isWatchKind(ctx, kindId)
+ || !WearableConnection.isSupported()) {
+ return;
+ }
+ String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId);
+ if (json == null || json.length() == 0) {
+ // Nothing published yet, so there is nothing for the watch to redraw.
+ return;
+ }
+ byte[] bytes = json.getBytes("UTF-8");
+ if (bytes.length > MAX_JSON_BYTES) {
+ return;
+ }
+ // The artwork too, and not as an optimisation to skip. A reload is also how a watch
+ // app installed AFTER the publish gets its first copy of anything, and a descriptor
+ // whose content-hash images have never existed on that device renders as permanent
+ // gaps until the app happens to publish again. Sent before the descriptor, for the
+ // same reason a publish does.
+ sendImages(kindId, storedImages(ctx, kindId));
+ WearableMessage message = new WearableMessage(PATH_PREFIX + kindId);
+ message.put("v", 1);
+ message.put("json", bytes);
+ message.put("nonce", System.currentTimeMillis());
+ WearableConnection.putData(message);
+ } catch (Throwable t) {
+ // A watch that does not hear about a reload keeps showing what it had, which is the
+ // same content: this is a refresh, not a change.
+ Log.w(TAG, "Could not ask the watch to reload widget kind " + kindId, t);
+ }
+ }
+
+ /**
+ * The image blobs a kind has on disk, keyed by the name its descriptor references.
+ *
+ *
Read back rather than remembered, because a reload can be minutes or restarts away from
+ * the publish that produced them, and the store is where they live in the meantime.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind
+ * @return the blobs, possibly empty
+ */
+ private static Map storedImages(Context ctx, String kindId) {
+ Map out = new java.util.LinkedHashMap();
+ File dir = CN1SurfaceStore.kindDir(ctx, kindId);
+ File[] files = dir.listFiles();
+ if (files == null) {
+ return out;
+ }
+ for (File f : files) {
+ String name = f.getName();
+ if (!name.endsWith(".png")) {
+ continue;
+ }
+ try {
+ java.io.FileInputStream in = new java.io.FileInputStream(f);
+ try {
+ byte[] blob = new byte[(int) f.length()];
+ int read = 0;
+ while (read < blob.length) {
+ int n = in.read(blob, read, blob.length - read);
+ if (n < 0) {
+ break;
+ }
+ read += n;
+ }
+ if (read == blob.length) {
+ out.put(name.substring(0, name.length() - 4), blob);
+ }
+ } finally {
+ in.close();
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not read " + f + " to re-send it to the watch", t);
+ }
+ }
+ return out;
+ }
+
+ private static void sendImages(String kindId, Map images) {
+ if (images == null || images.isEmpty()) {
+ return;
+ }
+ int sent = 0;
+ int total = 0;
+ for (Map.Entry e : images.entrySet()) {
+ byte[] blob = e.getValue();
+ if (blob == null || blob.length == 0) {
+ continue;
+ }
+ if (blob.length > MAX_IMAGE_BYTES) {
+ Log.w(TAG, "Skipping image \"" + e.getKey() + "\" of widget kind \"" + kindId
+ + "\" when mirroring to the watch: " + blob.length + " bytes exceeds the "
+ + MAX_IMAGE_BYTES + " byte cap. It renders as a gap on the watch face.");
+ continue;
+ }
+ if (sent >= MAX_IMAGES || total + blob.length > MAX_TOTAL_IMAGE_BYTES) {
+ Log.w(TAG, "Widget kind \"" + kindId + "\" references more imagery than is worth "
+ + "carrying to a watch face; the rest render as gaps.");
+ return;
+ }
+ // A file transfer rather than a data item: the Data Layer streams these in the
+ // background and they routinely exceed the inline payload cap. Names are content
+ // hashes, so an unchanged image sends identical bytes and the receiver overwrites in
+ // place.
+ WearableConnection.transferFile(PATH_PREFIX + kindId, e.getKey() + ".png", blob);
+ sent++;
+ total += blob.length;
+ }
+ }
+
+ /**
+ * Applies a mirrored descriptor on the watch and re-renders whatever shows it.
+ *
+ *
Called from the injected listener service, which may be running with no Codename One
+ * runtime at all: the Data Layer starts the app's process to deliver, and the whole point is
+ * to refresh a complication rather than to bring an application forward. So this is a file
+ * write and an update request, touching no framework state.
+ *
+ * @param ctx any context
+ * @param path the reserved application path the item arrived on
+ * @param payload the item's payload
+ * @return true when the descriptor was written. A false answer is retried by the caller: the
+ * Data Layer item does not change after a failed write, so nothing else would ever
+ * offer this descriptor again and the watch would keep the content it already had
+ */
+ public static boolean receive(Context ctx, String path, byte[] payload) {
+ try {
+ String kindId = kindOf(path);
+ if (kindId == null || payload == null) {
+ return false;
+ }
+ WearableMessage message = WearableMessage.fromByteArray(path, payload);
+ byte[] json = message.getBytes("json", null);
+ if (json == null) {
+ return false;
+ }
+ File kindDir = CN1SurfaceStore.kindDir(ctx, kindId);
+ mkdirs(kindDir);
+ writeAtomically(new File(kindDir, "timeline.json"), json);
+ // AFTER the replacement is safely on disk, and with the same reference set the
+ // publish path uses. Blob names are content hashes, so without this every changed
+ // image leaves its predecessor behind for ever in the watch app's storage. Artwork
+ // for the new descriptor that has not arrived yet is simply absent rather than
+ // unreferenced, so this cannot delete an image the timeline is waiting for.
+ // With the SAME grace the image path uses, and for the mirrored side's own reason:
+ // the descriptor and the images are independent Data Layer items, so they can arrive
+ // out of order across publications. Artwork for publication B can already be staged
+ // when A's descriptor is handled, and a zero-grace collection here deletes it --
+ // permanently, because that transfer has been acknowledged and will not be resent, so
+ // when B's descriptor arrives its art is simply gone. Age keeps freshly staged blobs
+ // and still collects the genuinely superseded ones.
+ CN1SurfaceStore.deleteUnreferencedImages(kindDir, new String(json, "UTF-8"),
+ STALE_IMAGE_GRACE_MILLIS);
+ // A mirrored kind was never published by THIS process, so nothing else records it --
+ // and reloadWidgets(null) walks the remembered set, so a reload-all on a watch whose
+ // content only ever arrived from the phone skipped the complication entirely.
+ CN1SurfaceStore.rememberKind(ctx, kindId);
+ CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId);
+ return true;
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not apply a mirrored surface from " + path, t);
+ return false;
+ }
+ }
+
+ /**
+ * Stores one mirrored image beside the descriptor that references it.
+ *
+ *
The payload is the serialized {@code WearableMessage} a file transfer carries -- the
+ * name and the bytes together -- rather than the raw file, which is what the delivery path
+ * hands every other listener too.
+ *
+ * @param ctx any context
+ * @param path the reserved application path
+ * @param payload the transfer payload
+ * @return true when the image was stored. The caller acknowledges delivery on this, and an
+ * acknowledgement is durable -- a false answer gets the transfer redelivered, a
+ * wrongly true one loses the artwork for good
+ */
+ /// How long an unreferenced mirrored image is left alone before it counts as stale.
+ ///
+ /// Art that arrives ahead of the descriptor naming it is seconds or minutes old -- the two
+ /// travel together and the images are sent first on purpose. An hour is far past that and far
+ /// short of letting a disconnected watch accumulate every missed publication's artwork.
+ private static final long STALE_IMAGE_GRACE_MILLIS = 60L * 60L * 1000L;
+
+ /**
+ * Collects stale mirrored artwork for a kind, called from wherever the store is read.
+ *
+ *
The write-time sweep cannot collect the blob that triggered it -- writeAtomically gives
+ * it the current time, so it is inside the grace by definition -- and when that blob belongs
+ * to a superseded publication nothing else looks again. A delayed in-memory pass was the
+ * obvious answer and the wrong one: this runs in a process the system starts and stops at
+ * will, so a Handler callback dies with it and the blob outlives the fix.
+ *
+ *
Reading is the durable hook. Anything that renders a mirrored surface reads the store
+ * first, so the sweep happens on the next render whenever that is, across any number of
+ * process deaths, and costs one directory listing.
+ *
+ * @param ctx any context
+ * @param kindId the kind whose directory to sweep
+ */
+ public static void collectStaleImages(Context ctx, String kindId) {
+ try {
+ String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId);
+ if (json == null || json.length() == 0) {
+ return;
+ }
+ CN1SurfaceStore.deleteUnreferencedImages(CN1SurfaceStore.kindDir(ctx, kindId), json,
+ STALE_IMAGE_GRACE_MILLIS);
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not collect stale mirrored images for " + kindId, t);
+ }
+ }
+
+ public static boolean receiveFile(Context ctx, String path, byte[] payload) {
+ try {
+ String kindId = kindOf(path);
+ if (kindId == null || payload == null) {
+ return false;
+ }
+ WearableMessage transfer = WearableMessage.fromByteArray(path, payload);
+ String name = transfer.getString("name", null);
+ byte[] contents = transfer.getBytes("contents", null);
+ if (name == null || contents == null) {
+ return false;
+ }
+ if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0) {
+ // A name is a content hash, never a path. Refusing one that looks like a path
+ // keeps a malformed payload from writing outside the kind's own directory.
+ Log.w(TAG, "Refusing a mirrored image with a suspicious name: " + name);
+ return false;
+ }
+ File dir = CN1SurfaceStore.kindDir(ctx, kindId);
+ // Written whatever the descriptor on disk currently says, and deliberately so.
+ //
+ // onPublished sends the images BEFORE the descriptor that names them, precisely so a
+ // descriptor is never live against art that has not landed -- so the normal case is an
+ // image arriving while the PREVIOUS descriptor is still stored, and refusing anything
+ // it does not name rejects exactly the art the next descriptor is waiting for. The
+ // transfer is then acknowledged and gone, and the new descriptor references a blob
+ // that will never exist.
+ //
+ // The opposite hazard is art from a superseded publish arriving after the newest
+ // descriptor has already collected. That is NOT the fixed one-publish cost it looks
+ // like: the descriptor is a Data Layer item and collapses to the newest value when
+ // delivery is delayed, while each image is a transfer with its own sequence and none
+ // of them collapse -- so a watch that was away for ten publications receives one
+ // descriptor and then ten publications' worth of artwork behind it, with no later
+ // descriptor promised to collect the nine that are stale.
+ //
+ // Hence the sweep below rather than a condition here. Refusing the write outright is
+ // still wrong for the reason above, so what settles it is age, not reference.
+ mkdirs(dir);
+ writeAtomically(new File(dir, name), contents);
+ // A file transfer is asynchronous and unordered against the descriptor, so artwork
+ // routinely lands AFTER the timeline that references it. The descriptor's own arrival
+ // already asked for a refresh, but that render saw a gap where this image belongs --
+ // and nothing else would ask again until the next publish. So each arriving image
+ // asks too.
+ CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId);
+ // Collect what the stored descriptor does not reference and is old enough not to be
+ // waiting for one. Done HERE and not only in receive(), because the case this exists
+ // for is precisely the one where no further descriptor arrives.
+ String stored = CN1SurfaceStore.readWidgetTimeline(ctx, kindId);
+ if (stored != null && stored.length() > 0) {
+ CN1SurfaceStore.deleteUnreferencedImages(dir, stored, STALE_IMAGE_GRACE_MILLIS);
+ }
+ return true;
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not store a mirrored image from " + path, t);
+ }
+ // The caller acknowledges delivery on the strength of this, and an acknowledgement is
+ // durable: a false answer gets the transfer redelivered, a wrongly true one loses the
+ // artwork for good.
+ return false;
+ }
+
+ /**
+ * Withdraws a mirrored surface the phone has removed.
+ *
+ *
The Data Layer announces an unpublish as a deletion of the item, and a mirror never
+ * entered the replication cache that ordinarily handles one -- so without this the descriptor
+ * stayed on disk and the complication went on showing content the phone had already taken
+ * down. Deleting the whole kind directory rather than the descriptor alone: its images exist
+ * only to serve it, and the reference set that would tell them apart has just gone away.
+ *
+ *
The watch face is asked to re-read afterwards, which is what makes the slot go back to
+ * whatever it shows for a source with no data.
+ *
+ * @param ctx any context
+ * @param path the reserved application path that was deleted
+ * @return true when the descriptor is gone. A Data Layer deletion cannot be redelivered, so
+ * a false answer is retried by the caller or the withdrawal never happens
+ */
+ public static boolean remove(Context ctx, String path) {
+ try {
+ String kindId = kindOf(path);
+ if (kindId == null) {
+ return false;
+ }
+ // kindDir always answers a File -- it composes a path and never looks at the disk --
+ // so listFiles() returning null is how "there is nothing here" arrives, and there is
+ // no directory to guard against.
+ File kindDir = CN1SurfaceStore.kindDir(ctx, kindId);
+ // The DESCRIPTOR first, and on its own. A Data Layer deletion cannot be replayed --
+ // unlike a changed item, the tombstone is consumed once and there is nothing to ask
+ // for again -- so this has one attempt at making the surface go away, and what
+ // actually does that is the timeline being gone. Art left behind is clutter the next
+ // publish collects; a descriptor left behind is a complication still showing content
+ // the phone withdrew.
+ File timeline = new File(kindDir, "timeline.json");
+ if (timeline.exists() && !timeline.delete()) {
+ // REPORTED, not merely logged. A Data Layer deletion cannot be redelivered, so
+ // nothing will bring this tombstone back -- the caller has to retry it or the
+ // complication goes on showing content the phone withdrew for good.
+ Log.w(TAG, "Could not delete " + timeline + ", so the watch would keep showing a "
+ + "surface the phone withdrew; the caller retries this.");
+ return false;
+ }
+ File[] files = kindDir.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ if (!f.delete()) {
+ Log.w(TAG, "Could not delete " + f + " while withdrawing a mirror");
+ }
+ }
+ }
+ if (kindDir.exists() && !kindDir.delete()) {
+ Log.w(TAG, "Could not delete " + kindDir + " while withdrawing a mirror");
+ }
+ CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId);
+ return true;
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not withdraw a mirrored surface from " + path, t);
+ return false;
+ }
+ }
+
+ /**
+ * Republishes a mirrored kind because the watch asked for it.
+ *
+ *
Called on the PHONE, where the content actually lives. A watch showing a mirrored
+ * surface cannot refresh it by asking itself -- it has no publish path of its own and no
+ * background-fetch listener recorded, that preference being written by the very method it
+ * never runs -- so it sends the ask back up the link and this answers it.
+ *
+ *
The same throttled request a widget makes, so a watch asking repeatedly costs no more
+ * than a home-screen widget doing the same, and an app that declares no background fetch is
+ * unaffected.
+ *
+ * @param ctx any context
+ * @param kindId the kind the watch wants republished
+ * @return true, so the reflective caller treats it as handled
+ */
+ public static boolean reloadRequested(Context ctx, String kindId) {
+ try {
+ if (kindId != null && kindId.length() > 0) {
+ // NOT allowed to ask the peer. This IS the peer's request: a device with nothing
+ // to publish answering by asking back is how the two bounce messages at each
+ // other until they disconnect.
+ CN1WidgetProvider.requestAppRefresh(ctx, kindId, false);
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not answer a watch request to republish " + kindId, t);
+ }
+ return true;
+ }
+
+ /** True when a Data Layer path belongs to this framework rather than to the app. */
+ public static boolean isMirrorPath(String path) {
+ return path != null && path.startsWith(PATH_PREFIX);
+ }
+
+ private static String kindOf(String path) {
+ if (!isMirrorPath(path)) {
+ return null;
+ }
+ String kindId = path.substring(PATH_PREFIX.length());
+ return kindId.length() == 0 ? null : kindId;
+ }
+
+ /**
+ * Creates a directory, failing loudly when it could not be.
+ *
+ *
The return value of {@code mkdirs()} alone is the wrong test: it answers false both when
+ * the directory could not be created AND when it already exists, which here is the common
+ * case. Existence afterwards is what the caller actually needs, and the callers turn a
+ * failure into a logged warning rather than a lost timeline.
+ */
+ private static void mkdirs(File dir) throws IOException {
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("could not create " + dir);
+ }
+ }
+
+ private static void writeAtomically(File target, byte[] bytes) throws IOException {
+ File tmp = new File(target.getParentFile(), target.getName() + ".tmp");
+ FileOutputStream out = new FileOutputStream(tmp);
+ try {
+ out.write(bytes);
+ } finally {
+ out.close();
+ }
+ if (!tmp.renameTo(target)) {
+ // A rename across the same directory should not fail, but a partial descriptor is
+ // worse than a stale one, so the half-written file goes rather than the good one.
+ if (!tmp.delete()) {
+ Log.w(TAG, "Could not remove a partial mirrored file at " + tmp);
+ }
+ throw new IOException("could not replace " + target);
+ }
+ }
+}
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java
index 67a5bbc3cdb..a692d98e6c5 100644
--- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java
@@ -738,11 +738,74 @@ private static void applyFixedSize(RemoteViews rv, JSONObject node, RenderContex
}
}
+ /**
+ * Rasterizes one {@code img} or {@code vec} node for a Wear complication or Tile.
+ *
+ *
Neither of those renders through RemoteViews -- a complication hands the watch face a
+ * typed value and a Tile serves a ProtoLayout -- so they need the bitmap rather than a view
+ * tree. Reusing the decoding and vector rasterization here is what makes a vector degrade to
+ * a bitmap on a watch face exactly as it does on a home screen, instead of degrading twice
+ * in two slightly different ways.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind, which locates the published imagery
+ * @param node an {@code img} or {@code vec} node
+ * @param state the entry state, for interpolated values
+ * @return the bitmap, or null when the node names nothing renderable
+ */
+ static Bitmap renderWatchBitmap(Context ctx, String kindId, JSONObject node,
+ JSONObject state) {
+ RenderContext rc = new RenderContext(ctx, state == null ? new JSONObject() : state,
+ kindId, CN1SurfaceStore.kindDir(ctx, kindId));
+ String type = node.optString("t", "");
+ if ("vec".equals(type)) {
+ return renderVectorBitmap(node, rc);
+ }
+ if ("img".equals(type)) {
+ return loadBitmap(node.optString("name", ""), node, rc);
+ }
+ return null;
+ }
+
+ /**
+ * The intent a complication or Tile tap should fire, matching what a widget tap sends.
+ *
+ *
Built here rather than at the call site so all three surfaces agree on the extras and
+ * on the canonical {@code cn1surface://} form -- which doubles as the uniqueness key that
+ * keeps PendingIntents with different extras from colliding.
+ *
+ * @param ctx any context
+ * @param source the widget kind, reported to the action handler
+ * @param actionId the declared action id
+ * @param params the declared parameters, or null
+ * @return the trampoline intent
+ */
+ static Intent watchActionIntent(Context ctx, String source, String actionId,
+ JSONObject params) {
+ String paramsJson = params == null ? null : params.toString();
+ Intent intent = new Intent(ctx, CN1SurfaceActionActivity.class);
+ CN1SurfaceActionActivity.authenticate(ctx, intent);
+ intent.putExtra(CN1SurfaceActionActivity.EXTRA_SOURCE, source);
+ intent.putExtra(CN1SurfaceActionActivity.EXTRA_ACTION_ID, actionId);
+ if (paramsJson != null) {
+ intent.putExtra(CN1SurfaceActionActivity.EXTRA_ACTION_PARAMS, paramsJson);
+ }
+ StringBuilder uri = new StringBuilder("cn1surface://a?src=");
+ uri.append(Uri.encode(source == null ? "" : source));
+ uri.append("&id=").append(Uri.encode(actionId));
+ if (paramsJson != null) {
+ uri.append("&p=").append(Uri.encode(paramsJson));
+ }
+ intent.setData(Uri.parse(uri.toString()));
+ return intent;
+ }
+
private static void applyAction(RemoteViews rv, JSONObject action, RenderContext rc) {
String actionId = action.optString("id", "");
JSONObject params = action.optJSONObject("p");
String paramsJson = params == null ? null : params.toString();
Intent intent = new Intent(rc.ctx, CN1SurfaceActionActivity.class);
+ CN1SurfaceActionActivity.authenticate(rc.ctx, intent);
intent.putExtra(CN1SurfaceActionActivity.EXTRA_SOURCE, rc.source);
intent.putExtra(CN1SurfaceActionActivity.EXTRA_ACTION_ID, actionId);
if (paramsJson != null) {
@@ -784,28 +847,92 @@ private static void setColorStateList(RemoteViews rv, int viewId, String method,
private static int resolveColor(JSONObject color, RenderContext rc, int fallbackLight,
int fallbackDark) {
+ return resolveColor(color, rc != null && rc.dark, fallbackLight, fallbackDark);
+ }
+
+ /// The colour a `color` node resolves to, in ARGB.
+ ///
+ /// Package-private and taking the appearance directly rather than a RenderContext, because
+ /// the Tile renderer needs the same answer and has none to give. One implementation: a
+ /// semantic role meaning one thing on a home screen and another on a watch face is a bug
+ /// nobody would look for.
+ static int resolveColor(JSONObject color, boolean dark, int fallbackLight, int fallbackDark) {
String role = color.optString("role", null);
if (role != null && role.length() > 0) {
if ("label".equals(role)) {
- return rc.dark ? LABEL_DARK : LABEL_LIGHT;
+ return dark ? LABEL_DARK : LABEL_LIGHT;
}
if ("secondaryLabel".equals(role)) {
- return rc.dark ? SECONDARY_LABEL_DARK : SECONDARY_LABEL_LIGHT;
+ return dark ? SECONDARY_LABEL_DARK : SECONDARY_LABEL_LIGHT;
}
if ("background".equals(role)) {
- return rc.dark ? BACKGROUND_DARK : BACKGROUND_LIGHT;
+ return dark ? BACKGROUND_DARK : BACKGROUND_LIGHT;
}
if ("accent".equals(role)) {
return ACCENT;
}
- return rc.dark ? fallbackDark : fallbackLight;
+ return dark ? fallbackDark : fallbackLight;
}
if (color.has("l") || color.has("d")) {
long l = color.optLong("l", fallbackLight);
long d = color.optLong("d", l);
- return (int) (rc.dark ? d : l);
+ return (int) (dark ? d : l);
}
- return rc.dark ? fallbackDark : fallbackLight;
+ return dark ? fallbackDark : fallbackLight;
+ }
+
+ /**
+ * A dynamic node's resolved timestamp, for the Wear complication and Tile readers.
+ *
+ *
Those two do not render through RemoteViews and so have no RenderContext, but they must
+ * resolve {@code dateKey} against the entry state exactly as a widget does -- otherwise the
+ * same published timeline would show a different moment on a watch face than on a home
+ * screen.
+ *
+ * @param node a {@code dyn} node
+ * @param state the entry state
+ * @return epoch millis, or 0 when the node names none
+ */
+ static long resolveWatchDate(JSONObject node, JSONObject state) {
+ String dateKey = node.optString("dateKey", null);
+ if (dateKey != null && dateKey.length() > 0 && state != null) {
+ Object v = state.opt(dateKey);
+ if (v instanceof Number) {
+ return ((Number) v).longValue();
+ }
+ if (v instanceof String) {
+ try {
+ return Long.parseLong((String) v);
+ } catch (NumberFormatException ignore) {
+ // Not a timestamp; fall through to the literal below.
+ }
+ }
+ }
+ return node.optLong("date", 0);
+ }
+
+ /**
+ * A dynamic node formatted as a plain string, for a surface that can only show one.
+ *
+ *
A complication slot takes a string and a Tile freezes its value, so both need the
+ * text form rather than the ticking Chronometer a home-screen widget gets. The styles map
+ * the way the guide describes: the two timer styles read as remaining or elapsed time, and
+ * everything else as the moment itself.
+ *
+ * @param node a {@code dyn} node
+ * @param state the entry state
+ * @return the formatted value, never null
+ */
+ static String formatWatchDynamicText(JSONObject node, JSONObject state) {
+ long date = resolveWatchDate(node, state);
+ if (date <= 0) {
+ return "";
+ }
+ // The core's own formatter, not a second one here. It covers all five styles including
+ // "relative", and sharing it is what keeps a countdown reading the same on a watch face
+ // as in the simulator preview.
+ return com.codename1.surfaces.SurfaceRasterizer.formatDynamicText(
+ node.optString("style", "timerDown"), date, System.currentTimeMillis());
}
private static long resolveDate(JSONObject node, RenderContext rc) {
@@ -826,18 +953,38 @@ private static long resolveDate(JSONObject node, RenderContext rc) {
}
private static double resolveFraction(JSONObject node, RenderContext rc) {
+ return resolveFraction(node, rc == null ? null : rc.state);
+ }
+
+ /// The fraction a `prog` node is showing, in 0..1.
+ ///
+ /// Package-private and taking the state map directly rather than a RenderContext, because
+ /// the watch reader needs the same answer and has no RenderContext to give. One
+ /// implementation: a complication and a home-screen widget disagreeing about what a progress
+ /// node means is a bug nobody would look for.
+ static double resolveFraction(JSONObject node, JSONObject state) {
+ return resolveFraction(node, state, System.currentTimeMillis());
+ }
+
+ /// As above, but resolving a date interval against a STATED moment.
+ ///
+ /// A complication is handed a whole timeline at once and its future entries are rendered
+ /// before they are current, so an interval evaluated against the request's clock freezes at
+ /// today's fraction and stays there -- the provider sets no update period, so nothing
+ /// recomputes it when the entry actually takes over. The entry's own start is the moment it
+ /// describes.
+ static double resolveFraction(JSONObject node, JSONObject state, long asOf) {
double fraction;
String valueKey = node.optString("valueKey", null);
- if (valueKey != null && valueKey.length() > 0 && rc.state != null
- && rc.state.opt(valueKey) instanceof Number) {
- fraction = ((Number) rc.state.opt(valueKey)).doubleValue();
+ if (valueKey != null && valueKey.length() > 0 && state != null
+ && state.opt(valueKey) instanceof Number) {
+ fraction = ((Number) state.opt(valueKey)).doubleValue();
} else if (node.has("start") && node.has("end")) {
// Date-interval progress freezes at render time on Android; the next widget
// update recomputes it.
long start = node.optLong("start");
long end = node.optLong("end");
- long now = System.currentTimeMillis();
- fraction = end <= start ? 1d : (now - start) / (double) (end - start);
+ fraction = end <= start ? 1d : (asOf - start) / (double) (end - start);
} else {
fraction = node.optDouble("value", 0d);
}
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java
index f10ee52124d..1e50e61f885 100644
--- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java
@@ -85,7 +85,31 @@ public static void writeWidgetTimeline(Context ctx, String kindId, String timeli
deleteUnreferencedImages(dir, timelineJson);
}
- private static void deleteUnreferencedImages(File dir, String timelineJson) {
+ /// Deletes image blobs a timeline no longer references. The document's `images` list is
+ /// the complete reference set, and blob names are content hashes, so a changed image would
+ /// otherwise leave its predecessor behind for ever.
+ ///
+ /// Package-private rather than private because the mirror receiver persists a timeline that
+ /// arrived over the Data Layer rather than one this process published, and needs the same
+ /// collection afterwards.
+ static void deleteUnreferencedImages(File dir, String timelineJson) {
+ deleteUnreferencedImages(dir, timelineJson, 0L);
+ }
+
+ /// The same collection, but sparing blobs written within `graceMillis`.
+ ///
+ /// The mirror needs this because on the watch the two halves of a publication arrive by
+ /// different routes: the descriptor is a Data Layer item, which COLLAPSES to the newest value
+ /// when delivery is delayed, while each image is a separate transfer with its own sequence
+ /// and none of them collapse. So after a disconnection the watch can receive one descriptor
+ /// and then every missed publication's artwork behind it -- and an unreferenced blob there is
+ /// ambiguous: it is either art from a superseded publish, or art for a descriptor that has
+ /// not landed yet. The grace tells them apart by age, since art still waiting for its
+ /// descriptor is seconds old and art from a superseded publish is not.
+ ///
+ /// A zero grace is the ordinary publish path, where the descriptor is written first and the
+ /// reference set is authoritative immediately.
+ static void deleteUnreferencedImages(File dir, String timelineJson, long graceMillis) {
try {
org.json.JSONObject doc = new org.json.JSONObject(timelineJson);
org.json.JSONArray names = doc.optJSONArray("images");
@@ -99,10 +123,12 @@ private static void deleteUnreferencedImages(File dir, String timelineJson) {
if (files == null) {
return;
}
+ long spareAfter = System.currentTimeMillis() - graceMillis;
for (File f : files) {
String name = f.getName();
if (name.endsWith(".png")
- && !referenced.contains(name.substring(0, name.length() - 4))) {
+ && !referenced.contains(name.substring(0, name.length() - 4))
+ && (graceMillis <= 0 || f.lastModified() < spareAfter)) {
delete(f);
}
}
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java
new file mode 100644
index 00000000000..d1821bda95b
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java
@@ -0,0 +1,594 @@
+/*
+ * 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.surfaces;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Reads a published surface timeline for a Wear OS watch face, and reduces its node tree to the
+ * handful of values a complication or a Tile can actually show.
+ *
+ *
This is the half of the lowering that touches no {@code androidx.wear} type, which is why
+ * it lives in the port and is compiled by this repository. The generated
+ * {@code CN1ComplicationDataSource} and {@code CN1SurfaceTileService} ship as build-time
+ * resources because they must compile against libraries the port cannot depend on; keeping
+ * everything else here is what lets CI catch a break in it.
+ *
+ *
A complication is not a small widget. A watch face asks for a typed value -- a
+ * number, a short string, a ranged value, a monochrome glyph -- and composes it into its own
+ * design. There is no layout to honour: padding, background, alignment, weight and colour are
+ * all the face's business, not the app's. So the tree is flattened and mined for content rather
+ * than rendered, and everything that cannot survive that is dropped and logged.
+ */
+public final class CN1WatchSurface {
+
+ private static final String TAG = "CN1Surfaces";
+
+ /** Matches CN1SurfaceRenderer: deeper than this is a malformed descriptor, not a design. */
+ private static final int MAX_DEPTH = 8;
+
+ private CN1WatchSurface() {
+ }
+
+ /**
+ * The content of one kind at one moment, already resolved to the layout and entry that
+ * should be showing.
+ */
+ public static final class Reading {
+ private final JSONObject layout;
+ private final JSONObject state;
+ private final long nextFlipDate;
+
+ private final long start;
+ private final boolean reloadAtEnd;
+
+ Reading(JSONObject layout, JSONObject state, long nextFlipDate) {
+ this(layout, state, nextFlipDate, 0L);
+ }
+
+ Reading(JSONObject layout, JSONObject state, long nextFlipDate, long start) {
+ this(layout, state, nextFlipDate, start, true);
+ }
+
+ Reading(JSONObject layout, JSONObject state, long nextFlipDate, long start,
+ boolean reloadAtEnd) {
+ this.layout = layout;
+ this.state = state;
+ this.nextFlipDate = nextFlipDate;
+ this.start = start;
+ this.reloadAtEnd = reloadAtEnd;
+ }
+
+ /**
+ * Whether the app asked to be woken when the timeline runs out.
+ *
+ *
{@code WidgetTimeline.RELOAD_AT_END} is the default and means the last entry stays on
+ * screen while the app is asked -- throttled -- to publish fresh content. A widget already
+ * honours it; a Tile that ignored it froze on its final entry for ever.
+ *
+ * @return true for the default at-end policy, false for {@code RELOAD_NEVER}
+ */
+ public boolean isReloadAtEnd() {
+ return reloadAtEnd;
+ }
+
+ /**
+ * When this entry takes over, or 0 for the one that is current already.
+ *
+ *
Only meaningful for a reading that came from {@link #readTimeline}: a single
+ * resolved reading is by definition the one showing now.
+ *
+ * @return the entry's start in epoch millis, or 0
+ */
+ public long getStart() {
+ return start;
+ }
+
+ public JSONObject getLayout() {
+ return layout;
+ }
+
+ /** The entry's interpolation state; never null, so callers need no guard. */
+ public JSONObject getState() {
+ return state;
+ }
+
+ /**
+ * When the next timeline entry becomes current, or 0 when none does.
+ *
+ *
A Tile turns this into its freshness interval and a complication into the point at
+ * which it asks again, so an app that publishes entries covering the hours ahead is
+ * refreshed by the system without ever being woken.
+ */
+ public long getNextFlipDate() {
+ return nextFlipDate;
+ }
+ }
+
+ /**
+ * Reads the timeline a kind last published and resolves it for one watch family.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind
+ * @param family the portable family name, e.g. {@code watchCircular}
+ * @return the resolved content, or null when nothing has been published
+ */
+ public static Reading read(Context ctx, String kindId, String family) {
+ String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId);
+ if (json == null || json.length() == 0) {
+ return null;
+ }
+ // Stale mirrored artwork is collected here, on the read, because reading is the one thing
+ // guaranteed to happen again. The mirror's own write-time sweep cannot collect the blob
+ // that triggered it -- it is inside the grace by definition -- and a delayed in-memory
+ // pass dies with a process the system stops at will.
+ CN1SurfaceMirror.collectStaleImages(ctx, kindId);
+ try {
+ JSONObject doc = new JSONObject(json);
+ JSONObject layout = pickLayout(doc.optJSONObject("layouts"), family);
+ if (layout == null) {
+ return null;
+ }
+ JSONArray entries = doc.optJSONArray("entries");
+ long now = System.currentTimeMillis();
+ JSONObject entry = pickActiveEntry(entries, now);
+ JSONObject state = entry == null ? new JSONObject() : entry.optJSONObject("state");
+ return new Reading(layout, state == null ? new JSONObject() : state,
+ nextFlipDate(entries, now), 0L,
+ !"never".equals(doc.optString("reload", "atEnd")));
+ } catch (Throwable t) {
+ // A malformed descriptor must leave the face showing whatever it had, not crash the
+ // data source -- which on Wear takes the whole watch face down with it.
+ Log.w(TAG, "Could not read the published timeline for watch kind " + kindId, t);
+ return null;
+ }
+ }
+
+ /**
+ * Reads every entry a kind published that is still ahead of it, resolved for one family.
+ *
+ *
A complication answers with a whole timeline rather than one value, and the system swaps
+ * entries at the stated moments without waking anything -- so the entries the app published
+ * for the hours ahead have to survive the read rather than being collapsed to whichever one
+ * is current. The first element is the entry showing now; each later one carries the moment
+ * it takes over in {@link Reading#getStart}.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind
+ * @param family the portable family name, e.g. {@code watchCircular}
+ * @return the entries from now onward, or an empty list when nothing has been published
+ */
+ public static List readTimeline(Context ctx, String kindId, String family) {
+ List out = new ArrayList();
+ // Every read path sweeps, not just read(). A complication-only kind renders through
+ // readTimeline and never through read, so the collection never ran for the watches most
+ // likely to need it -- the mirror is their only source of artwork. Reading is the durable
+ // hook precisely because it always happens again; that only holds if every reader does it.
+ CN1SurfaceMirror.collectStaleImages(ctx, kindId);
+ String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId);
+ if (json == null || json.length() == 0) {
+ return out;
+ }
+ try {
+ JSONObject doc = new JSONObject(json);
+ JSONObject layout = pickLayout(doc.optJSONObject("layouts"), family);
+ if (layout == null) {
+ return out;
+ }
+ JSONArray entries = doc.optJSONArray("entries");
+ long now = System.currentTimeMillis();
+ JSONObject active = pickActiveEntry(entries, now);
+ if (active != null) {
+ JSONObject state = active.optJSONObject("state");
+ out.add(new Reading(layout, state == null ? new JSONObject() : state,
+ nextFlipDate(entries, now), 0L,
+ !"never".equals(doc.optString("reload", "atEnd"))));
+ }
+ for (int i = 0; entries != null && i < entries.length(); i++) {
+ JSONObject e = entries.optJSONObject(i);
+ if (e == null) {
+ continue;
+ }
+ long date = e.optLong("date", 0);
+ if (date <= now) {
+ // Already superseded, or the one already added above.
+ continue;
+ }
+ JSONObject state = e.optJSONObject("state");
+ out.add(new Reading(layout, state == null ? new JSONObject() : state,
+ nextFlipDate(entries, date), date,
+ !"never".equals(doc.optString("reload", "atEnd"))));
+ }
+ } catch (Throwable t) {
+ // Same contract as read(): a malformed descriptor leaves the face showing whatever it
+ // had rather than taking the watch face down with the data source.
+ Log.w(TAG, "Could not read the published timeline for watch kind " + kindId, t);
+ }
+ return out;
+ }
+
+ /**
+ * Reads every entry a kind published, including the ones already superseded.
+ *
+ *
Unlike {@link #readTimeline} this does not drop the past, because its caller is not
+ * asking what to show -- it is asking what it already showed. A Tile host requests resources
+ * for the version the layout it is displaying advertised, and that layout can be an entry
+ * behind by the time the request lands. The published descriptor is the only record of what
+ * that entry was, so answering from it is what makes the answer survive a flip, a cache
+ * eviction, and the service being torn down and rebuilt between the two callbacks.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind
+ * @param family the portable family name, e.g. {@code watchRectangular}
+ * @return every entry, in published order, or an empty list when nothing has been published
+ */
+ public static List readAllEntries(Context ctx, String kindId, String family) {
+ List out = new ArrayList();
+ // Every read path sweeps, not just read(). A complication-only kind renders through
+ // readTimeline and never through read, so the collection never ran for the watches most
+ // likely to need it -- the mirror is their only source of artwork. Reading is the durable
+ // hook precisely because it always happens again; that only holds if every reader does it.
+ CN1SurfaceMirror.collectStaleImages(ctx, kindId);
+ String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId);
+ if (json == null || json.length() == 0) {
+ return out;
+ }
+ try {
+ JSONObject doc = new JSONObject(json);
+ JSONObject layout = pickLayout(doc.optJSONObject("layouts"), family);
+ if (layout == null) {
+ return out;
+ }
+ boolean reloadAtEnd = !"never".equals(doc.optString("reload", "atEnd"));
+ JSONArray entries = doc.optJSONArray("entries");
+ for (int i = 0; entries != null && i < entries.length(); i++) {
+ JSONObject e = entries.optJSONObject(i);
+ if (e == null) {
+ continue;
+ }
+ long date = e.optLong("date", 0);
+ JSONObject state = e.optJSONObject("state");
+ out.add(new Reading(layout, state == null ? new JSONObject() : state,
+ nextFlipDate(entries, date), date, reloadAtEnd));
+ }
+ } catch (Throwable t) {
+ // Same contract as read(): a malformed descriptor leaves the face showing whatever it
+ // had rather than taking the watch face down with the data source.
+ Log.w(TAG, "Could not read the published timeline for watch kind " + kindId, t);
+ }
+ return out;
+ }
+
+ /**
+ * Picks the layout for a family, substituting the way every other platform does.
+ *
+ *
{@code watchCorner} borrows the circular layout because Wear OS has no corner slot at
+ * all and a corner complication is round; {@code watchRectangular} borrows the lock-screen
+ * layout, which is the same family on Apple. Both are closer to what the developer designed
+ * than {@code default}, which may well be a rectangular phone widget.
+ */
+ static JSONObject pickLayout(JSONObject layouts, String family) {
+ if (layouts == null) {
+ return null;
+ }
+ JSONObject layout = family == null ? null : layouts.optJSONObject(family);
+ if (layout == null && "watchCorner".equals(family)) {
+ layout = layouts.optJSONObject("watchCircular");
+ }
+ if (layout == null && "watchRectangular".equals(family)) {
+ layout = layouts.optJSONObject("lockscreen");
+ }
+ if (layout == null) {
+ layout = layouts.optJSONObject("default");
+ }
+ if (layout == null) {
+ // Last resort: any watch layout at all beats showing nothing, because a face that
+ // asked for a type this kind offers will otherwise sit empty.
+ String[] fallbacks = {"watchRectangular", "watchCircular", "watchInline", "medium"};
+ for (String fallback : fallbacks) {
+ layout = layouts.optJSONObject(fallback);
+ if (layout != null) {
+ break;
+ }
+ }
+ }
+ return layout;
+ }
+
+ /** The latest entry whose date has passed, or the first when none has. */
+ static JSONObject pickActiveEntry(JSONArray entries, long now) {
+ if (entries == null || entries.length() == 0) {
+ return null;
+ }
+ JSONObject active = entries.optJSONObject(0);
+ for (int i = 0; i < entries.length(); i++) {
+ JSONObject e = entries.optJSONObject(i);
+ if (e != null && e.optLong("date") <= now) {
+ active = e;
+ }
+ }
+ return active;
+ }
+
+ /** When the next entry becomes current, or 0 when none is ahead. */
+ static long nextFlipDate(JSONArray entries, long now) {
+ long next = 0;
+ if (entries != null) {
+ for (int i = 0; i < entries.length(); i++) {
+ JSONObject e = entries.optJSONObject(i);
+ if (e == null) {
+ continue;
+ }
+ long date = e.optLong("date");
+ if (date > now && (next == 0 || date < next)) {
+ next = date;
+ }
+ }
+ }
+ return next;
+ }
+
+ /**
+ * Flattens the node tree depth-first.
+ *
+ *
Containers contribute traversal order and nothing else: a complication has no layout to
+ * honour, so a row and a column produce the same reading. That is the whole reason this is a
+ * flatten rather than a render.
+ *
+ * @param root the layout root
+ * @return every leaf node in document order
+ */
+ public static List flatten(JSONObject root) {
+ List out = new ArrayList();
+ flattenInto(root, out, 0);
+ return out;
+ }
+
+ private static void flattenInto(JSONObject node, List out, int depth) {
+ if (node == null || depth > MAX_DEPTH) {
+ return;
+ }
+ // "ch", which is what SurfaceContainer.serializeContent writes and what the RemoteViews
+ // renderer reads. Reading "c" found nothing, so every row, column and box looked empty
+ // and a complication mined a layout with no text, no progress and no imagery in it.
+ JSONArray children = node.optJSONArray("ch");
+ if (children != null && children.length() > 0) {
+ for (int i = 0; i < children.length(); i++) {
+ flattenInto(children.optJSONObject(i), out, depth + 1);
+ }
+ return;
+ }
+ out.add(node);
+ }
+
+ /** The first node of a wire type, or null. */
+ public static JSONObject firstOfType(List nodes, String type) {
+ for (JSONObject node : nodes) {
+ if (type.equals(node.optString("t", ""))) {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Every text-bearing node's resolved string, in document order.
+ *
+ *
Both {@code text} and {@code dyn} count: a countdown reads as text to a face that asked
+ * for one, even where the caller can do better with a native timer.
+ */
+ public static List texts(List nodes, JSONObject state) {
+ List out = new ArrayList();
+ for (JSONObject node : nodes) {
+ String type = node.optString("t", "");
+ if ("text".equals(type)) {
+ String text = CN1SurfaceRenderer.interpolate(node.optString("text", ""), state);
+ if (text != null && text.length() > 0) {
+ out.add(text);
+ }
+ } else if ("dyn".equals(type)) {
+ // A dynamic node has no "text" field at all -- it serializes a style plus a date
+ // or a dateKey, and the reader formats it. Interpolating "text" here resolved to
+ // an empty string, so every countdown, clock and relative date vanished from a
+ // complication rather than showing its value.
+ String text = dynamicText(node, state);
+ if (text != null && text.length() > 0) {
+ out.add(text);
+ }
+ }
+ }
+ return out;
+ }
+
+ /**
+ * A dynamic node's value as a plain string.
+ *
+ *
A complication slot takes a string, so a countdown is formatted at render time and
+ * refreshed when the timeline flips -- there is no native ticking widget to hand a watch
+ * face. A caller that CAN tick natively, as the complication data source does for the timer
+ * styles, should read the style and date itself and build the ticking form instead.
+ *
+ * @param node a {@code dyn} node
+ * @param state the entry state, which may supply the date by key
+ * @return the formatted value, never null
+ */
+ public static String dynamicText(JSONObject node, JSONObject state) {
+ if (node == null) {
+ return "";
+ }
+ return CN1SurfaceRenderer.formatWatchDynamicText(node, state);
+ }
+
+ /**
+ * A dynamic node's resolved timestamp, so a caller that can render it natively has the
+ * value rather than a formatted string.
+ *
+ * @param node a {@code dyn} node
+ * @param state the entry state, which may supply the date by key
+ * @return epoch millis, or 0 when the node names none
+ */
+ public static long dynamicDate(JSONObject node, JSONObject state) {
+ if (node == null) {
+ return 0;
+ }
+ return CN1SurfaceRenderer.resolveWatchDate(node, state);
+ }
+
+ /**
+ * A progress node's value, clamped to 0..1.
+ *
+ *
Literal, read from the entry's state by key, or computed from a date interval --
+ * whichever the node carries. The arithmetic is the renderer's own
+ * {@code resolveFraction}, not a second copy of it, because a complication and a
+ * home-screen widget disagreeing about what a progress node shows is a bug nobody would
+ * think to look for.
+ *
+ *
What is decided HERE rather than there is emptiness. The renderer always has a bar to
+ * draw and treats an unusable node as zero; a ranged complication would then read as a
+ * gauge pinned at the bottom, which is a claim about the value rather than an absence of
+ * one. So a node carrying no value, no resolvable key and no interval answers -1, and the
+ * caller offers the slot nothing.
+ *
+ *
A date interval freezes at read time, exactly as it does for a widget: the value is
+ * recomputed on the next refresh. Wear has no ticking ranged-value complication to use
+ * instead.
+ *
+ * @param prog a {@code prog} node
+ * @param state the entry state
+ * @return the value in 0..1, or -1 when the node carries none
+ */
+ public static float progressValue(JSONObject prog, JSONObject state) {
+ return progressValue(prog, state, System.currentTimeMillis());
+ }
+
+ /**
+ * As above, but resolving a date interval against a stated moment.
+ *
+ *
A complication renders its future entries before they are current, so an interval
+ * evaluated against the request's clock is frozen at today's fraction for ever -- there is no
+ * later request to recompute it.
+ *
+ * @param prog a {@code prog} node
+ * @param state the entry state
+ * @param asOf the moment the entry describes
+ * @return the value in 0..1, or -1 when the node carries none
+ */
+ public static float progressValue(JSONObject prog, JSONObject state, long asOf) {
+ if (prog == null) {
+ return -1f;
+ }
+ String key = prog.optString("valueKey", "");
+ boolean resolvableKey = key.length() > 0 && state != null
+ && state.opt(key) instanceof Number;
+ if (!prog.has("value") && !resolvableKey
+ && !(prog.has("start") && prog.has("end"))) {
+ return -1f;
+ }
+ return (float) CN1SurfaceRenderer.resolveFraction(prog, state, asOf);
+ }
+
+ /**
+ * Rasterizes an image or vector node for a complication or Tile.
+ *
+ *
Reuses the renderer's own decoding and vector rasterization rather than reimplementing
+ * them, so a vector degrades to a bitmap here exactly as it does for a home-screen widget.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind, which locates the published imagery
+ * @param node an {@code img} or {@code vec} node
+ * @param state the entry state
+ * @return the bitmap, or null when the node names nothing renderable
+ */
+ public static Bitmap bitmap(Context ctx, String kindId, JSONObject node, JSONObject state) {
+ if (node == null) {
+ return null;
+ }
+ return CN1SurfaceRenderer.renderWatchBitmap(ctx, kindId, node, state);
+ }
+
+ /**
+ * The tap target for a complication or Tile: the root action, as an intent into the same
+ * trampoline a widget tap uses.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind, reported to the action handler as the source
+ * @param layout the resolved layout root
+ * @return the intent, or null when the layout declares no action
+ */
+ public static Intent rootAction(Context ctx, String kindId, JSONObject layout) {
+ if (layout == null) {
+ return null;
+ }
+ JSONObject action = layout.optJSONObject("action");
+ if (action == null) {
+ return null;
+ }
+ String actionId = action.optString("id", "");
+ if (actionId.length() == 0) {
+ return null;
+ }
+ return CN1SurfaceRenderer.watchActionIntent(ctx, kindId, actionId,
+ action.optJSONObject("p"));
+ }
+
+ /**
+ * Whether a kind declares a watch family, from the build-time list the builder wrote.
+ *
+ *
Read from resources rather than from the timeline, because it has to be answerable for
+ * a kind that has never published anything.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind
+ * @return true when the kind was declared with a complication family
+ */
+ public static boolean isWatchKind(Context ctx, String kindId) {
+ if (kindId == null) {
+ return false;
+ }
+ try {
+ int id = ctx.getResources().getIdentifier("cn1_surface_watch_kinds", "array",
+ ctx.getPackageName());
+ if (id == 0) {
+ return false;
+ }
+ String[] kinds = ctx.getResources().getStringArray(id);
+ for (String kind : kinds) {
+ if (kindId.equals(kind)) {
+ return true;
+ }
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not read the declared watch surface kinds", t);
+ }
+ return false;
+ }
+}
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java
new file mode 100644
index 00000000000..baa5bd2d8b2
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.impl.android.surfaces;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.util.Log;
+
+import java.lang.reflect.Method;
+
+/**
+ * Asks a Wear OS watch face and tile carousel to re-read what this app just published.
+ *
+ *
Reflective on purpose. {@code ComplicationDataSourceUpdateRequester} and
+ * {@code TileService.getUpdater} live in {@code androidx.wear}, which the Android port must not
+ * depend on -- an app that publishes no complication should carry neither library. The generated
+ * service classes it looks for are only present in a watch build that declared watch families,
+ * so on a phone every lookup simply misses.
+ *
+ *
Same shape as {@code AndroidWearableSupport}'s reflective bridge lookup: a miss is expected
+ * and answered with silence, and a genuine failure is one warning rather than an exception into
+ * a caller that has already done its real work.
+ */
+public final class CN1WatchSurfaceNotifier {
+
+ private static final String TAG = "CN1Surfaces";
+
+ private CN1WatchSurfaceNotifier() {
+ }
+
+ /**
+ * Requests a refresh of everything showing a kind.
+ *
+ * @param ctx any context
+ * @param kindId the widget kind that was just published
+ */
+ public static void requestUpdate(Context ctx, String kindId) {
+ if (ctx == null || kindId == null) {
+ return;
+ }
+ // The name the build gave THIS kind, from the map it wrote. Trying candidates instead
+ // would be wrong here for the same reason it is wrong for the widget provider: the plain
+ // name may well exist and belong to a different kind.
+ String suffix = AndroidSurfaceBridge.classSuffix(ctx, kindId);
+ requestComplicationUpdate(ctx, "com.codename1.impl.android.CN1Complication_" + suffix);
+ requestTileUpdate(ctx, "com.codename1.impl.android.CN1Tile_" + suffix);
+ }
+
+ /**
+ * Asks the PHONE to publish this kind again, for a watch that has no content of its own.
+ *
+ *
A mirrored kind's descriptors are produced on the phone and sent down, so a watch asking
+ * itself for fresh content asks the wrong device -- and it has no background-fetch listener
+ * recorded anyway, that preference being written by the publish path the watch never runs.
+ * The request goes back over the Data Layer the descriptor came down.
+ *
+ *
Reflective for the same reason the update requesters are: CN1WearableBridge is injected
+ * by the build and is simply absent from a project that declares no wearable link, where
+ * there is no phone half to ask.
+ *
+ * @param ctx any context
+ * @param kindId the kind wanting fresh content
+ */
+ static void requestPhoneReload(Context ctx, String kindId) {
+ try {
+ Class> bridge = Class.forName("com.codename1.impl.android.CN1WearableBridge");
+ bridge.getMethod("requestSurfaceReload", Context.class, String.class)
+ .invoke(null, ctx, kindId);
+ } catch (ClassNotFoundException expected) {
+ // No wearable link in this build, so there is no phone half to ask.
+ } catch (NoSuchMethodException expected) {
+ // An older injected bridge. The watch keeps what it has, as it did before.
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not ask the phone to republish " + kindId, t);
+ }
+ }
+
+ private static void requestComplicationUpdate(Context ctx, String className) {
+ try {
+ Class> service = Class.forName(className);
+ Class> requester = Class.forName("androidx.wear.watchface.complications.datasource."
+ + "ComplicationDataSourceUpdateRequester");
+ Method create = requester.getMethod("create", Context.class, ComponentName.class);
+ Object instance = create.invoke(null, ctx,
+ new ComponentName(ctx, service));
+ requester.getMethod("requestUpdateAll").invoke(instance);
+ } catch (ClassNotFoundException expected) {
+ // No complication for this kind, or not a watch build. Nothing to say.
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not request a complication update for " + className, t);
+ }
+ }
+
+ private static void requestTileUpdate(Context ctx, String className) {
+ try {
+ Class> service = Class.forName(className);
+ Class> tileService = Class.forName("androidx.wear.tiles.TileService");
+ Method getUpdater = tileService.getMethod("getUpdater", Context.class);
+ Object updater = getUpdater.invoke(null, ctx);
+ updater.getClass().getMethod("requestUpdate", Class.class).invoke(updater, service);
+ } catch (ClassNotFoundException expected) {
+ // No Tile for this kind, or not a watch build.
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not request a Tile update for " + className, t);
+ }
+ }
+
+}
diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java
index c107c7392ad..3c402364e5c 100644
--- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java
+++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java
@@ -143,10 +143,118 @@ private void renderAll(Context context, AppWidgetManager mgr, int[] appWidgetIds
/// publish -- and at most once per 15 minutes per kind. Failures are swallowed: modern
/// Android may refuse a background service start, in which case the widget simply keeps
/// showing the last entry until the app's own fetch schedule catches up.
- private static void requestAppRefresh(Context context, String kindId) {
+ /// Package-private rather than private: a Tile reaching the end of its timeline needs the
+ /// same throttled request, and reimplementing it there would give the two surfaces different
+ /// refresh behaviour for one published document.
+ /**
+ * Asks for the same background fetch, but AT a stated moment rather than now.
+ *
+ *
A complication is handed its whole timeline once and the system swaps entries itself, so
+ * nothing calls the provider when the last entry finally takes over -- which is exactly when
+ * a reload-at-end timeline wants more content. Asking at build time instead can spend the
+ * one throttled fetch hours early and republish over entries the user has not seen yet.
+ *
+ *
An alarm carrying the same broadcast the immediate path sends. It targets
+ * BackgroundFetchHandler, which every manifest that has background fetch already declares --
+ * so this needs no new component -- and an alarm survives the process the way a posted
+ * Runnable does not.
+ *
+ * @param context any context
+ * @param kindId the kind wanting fresh content
+ * @param whenMillis when the timeline runs out
+ */
+ static void scheduleAppRefresh(Context context, String kindId, long whenMillis) {
try {
String listenerClass = CN1SurfaceStore.getBackgroundFetchClass(context);
if (listenerClass == null) {
+ // A mirrored kind on the watch; see requestAppRefresh. The phone is asked NOW
+ // rather than at the timeline's end, because the alarm below needs a local
+ // component to deliver to and this build has none -- the throttle is what keeps
+ // that from being chatty. Asking early costs one phone-side publish; not asking
+ // leaves the complication on its final entry.
+ CN1WatchSurfaceNotifier.requestPhoneReload(context, kindId);
+ return;
+ }
+ if (whenMillis <= System.currentTimeMillis()) {
+ return;
+ }
+ // The cast sits INSIDE the instanceof branch, which is the shape the cast-semantics
+ // verifier recognises -- and the reason for the rule is real here: a failed CHECKCAST
+ // does not throw on ParparVM, so the catch below would never run for one.
+ Object service = context.getSystemService(Context.ALARM_SERVICE);
+ if (service instanceof AlarmManager) {
+ scheduleFetchAlarm(context, (AlarmManager) service, kindId, listenerClass,
+ whenMillis);
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not schedule the reload-at-end fetch for " + kindId, t);
+ }
+ }
+
+ /// The alarm itself, once the manager is known to be one. Separate so the cast above is the
+ /// last thing its own method does and no cast sits under the catch.
+ private static void scheduleFetchAlarm(Context context, AlarmManager am, String kindId,
+ String listenerClass, long whenMillis) {
+ try {
+ Intent intent = new Intent(context,
+ com.codename1.impl.android.BackgroundFetchHandler.class);
+ intent.setData(android.net.Uri.parse("http://codenameone.com/a?" + listenerClass));
+ // A SERVICE PendingIntent. BackgroundFetchHandler is an IntentService declared as a
+ // , so a broadcast one names a receiver that does not exist and the alarm
+ // fires into nothing. The port's own helper is used rather than a hand-rolled call,
+ // so the flags match what every other alarm-delivered start of this same handler
+ // uses. An alarm briefly allowlists the app, which is what lets the service start
+ // from here at all on API 26+.
+ //
+ // Keyed by kind so two kinds do not replace each other's wake-up, and distinct from
+ // the flip alarm's own request code for the same reason.
+ PendingIntent pi = com.codename1.impl.android.AndroidImplementation.getPendingIntent(
+ context, ("reloadAtEnd:" + kindId).hashCode(), intent);
+ // INEXACT deliberately. This is "some time after the timeline runs out", not a
+ // deadline, and an exact alarm costs the user a special permission for no benefit.
+ if (Build.VERSION.SDK_INT >= 23) {
+ am.setAndAllowWhileIdle(AlarmManager.RTC, whenMillis, pi);
+ } else {
+ am.set(AlarmManager.RTC, whenMillis, pi);
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "Could not schedule the reload-at-end fetch for " + kindId, t);
+ }
+ }
+
+ static void requestAppRefresh(Context context, String kindId) {
+ requestAppRefresh(context, kindId, true);
+ }
+
+ /**
+ * As above, but able to refuse the peer fallback.
+ *
+ *
{@code mayAskPeer} is false when this IS the answer to a peer's request. Without that
+ * the two devices bounce: a watch with no listener asks the phone, a phone with no listener
+ * answers by asking the watch, and neither ever acquires one -- an unthrottled message loop
+ * waking both processes until they disconnect. The device that was asked either has content
+ * to produce or has nothing to say, and saying nothing is the end of it.
+ *
+ * @param context any context
+ * @param kindId the kind wanting fresh content
+ * @param mayAskPeer whether a device with no listener of its own may ask the other one
+ */
+ static void requestAppRefresh(Context context, String kindId, boolean mayAskPeer) {
+ try {
+ String listenerClass = CN1SurfaceStore.getBackgroundFetchClass(context);
+ if (listenerClass == null) {
+ if (!mayAskPeer) {
+ // Answering a peer. It asked because it has nothing; this device has nothing
+ // either, so there is no one left to ask.
+ return;
+ }
+ // Nothing local to run. On a WATCH this is the normal case for a mirrored kind:
+ // the preference is recorded by publishWidgetTimeline, which the watch never
+ // runs -- its descriptors arrive through CN1SurfaceMirror.receive instead. The
+ // content belongs to the phone, so the phone is who to ask, and the request goes
+ // back over the same Data Layer the descriptor came down. A no-op everywhere
+ // else, including a phone with no background fetch declared.
+ CN1WatchSurfaceNotifier.requestPhoneReload(context, kindId);
return;
}
if (!CN1SurfaceStore.tryClaimBackgroundFetch(context, kindId,
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorWidgets.java b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorWidgets.java
index 48437b6beda..89c6a775800 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorWidgets.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorWidgets.java
@@ -82,9 +82,24 @@ class SimulatorWidgets implements JavaSEWidgetBridge.Listener {
private static final int EXPANDED_W = 350;
private static final int EXPANDED_H = 160;
- private static final String[] SIZE_NAMES = {"small", "medium", "large"};
- private static final int[] SIZE_W = {158, 338, 338};
- private static final int[] SIZE_H = {158, 158, 354};
+ /// The families the preview can render, in the order the combo lists them.
+ ///
+ /// The four watch families are complications. They are here because a developer designing
+ /// one otherwise has no way to look at it: a complication cannot be placed on a watch face
+ /// by simctl, so short of building to a device and adding it by hand there is nothing to
+ /// see. The sizes are the accessory families' own point sizes on a 45mm watch.
+ ///
+ /// What this previews is the NODE TREE, at the right size and shape. It is not the
+ /// per-platform lowering -- WidgetKit renders these through the same descriptor, but Wear OS
+ /// reduces a complication to typed ComplicationData -- so a layout that looks right here can
+ /// still lose detail on a watch face. Say so in the window rather than implying otherwise.
+ private static final String[] SIZE_NAMES = {"small", "medium", "large",
+ "watchCircular", "watchRectangular", "watchInline", "watchCorner"};
+ private static final int[] SIZE_W = {158, 338, 338, 84, 168, 168, 84};
+ private static final int[] SIZE_H = {158, 158, 354, 84, 76, 26, 84};
+ /// Which families are round, so the preview clips them the way a watch face does. A corner
+ /// complication hugs the bezel and is circular on every face that has one.
+ private static final boolean[] SIZE_ROUND = {false, false, false, true, false, false, true};
private static SimulatorWidgets instance;
@@ -138,7 +153,8 @@ public void valueChanged(ListSelectionEvent e) {
kindScroll.setPreferredSize(new Dimension(180, 200));
kindScroll.setBorder(BorderFactory.createTitledBorder("Widget kinds"));
- sizeCombo = new JComboBox(new String[] {"Small", "Medium", "Large"});
+ sizeCombo = new JComboBox(new String[] {"Small", "Medium", "Large",
+ "Watch circular", "Watch rectangular", "Watch inline", "Watch corner"});
sizeCombo.addActionListener(new java.awt.event.ActionListener() {
@Override
public void actionPerformed(java.awt.event.ActionEvent e) {
@@ -313,6 +329,7 @@ private void requestRender() {
final long now = System.currentTimeMillis();
final Map layout = SurfaceRasterizer.layoutForSize(doc, sizeName);
widgetPanel.setLogicalSize(SIZE_W[sizeIndex], SIZE_H[sizeIndex]);
+ widgetPanel.setRoundBackground(SIZE_ROUND[sizeIndex]);
if (layout == null) {
widgetPanel.showImage(null, new ArrayList());
updateTimelineLabel(doc, now);
@@ -509,6 +526,8 @@ private static final class SurfacePanel extends JPanel {
private int logicalHeight;
private final boolean checkerBackdrop;
private boolean pillBackground;
+ /// Clip and back the surface as a circle, for the round complication families.
+ private boolean roundBackground;
private SourceLookup sourceLookup;
private String sourceLookupValue;
@@ -539,6 +558,13 @@ void setPillBackground(boolean pill) {
this.pillBackground = pill;
}
+ void setRoundBackground(boolean round) {
+ if (round != roundBackground) {
+ this.roundBackground = round;
+ repaint();
+ }
+ }
+
void setLogicalSize(int w, int h) {
if (w != logicalWidth || h != logicalHeight) {
logicalWidth = w;
@@ -580,7 +606,19 @@ protected void paintComponent(Graphics g) {
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
if (checkerBackdrop) {
g2.setColor(new Color(0xEDEDED));
- g2.fillRoundRect(0, 0, logicalWidth, logicalHeight, 20, 20);
+ if (roundBackground) {
+ g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+ RenderingHints.VALUE_ANTIALIAS_ON);
+ g2.fillOval(0, 0, logicalWidth, logicalHeight);
+ } else {
+ g2.fillRoundRect(0, 0, logicalWidth, logicalHeight, 20, 20);
+ }
+ }
+ if (roundBackground) {
+ // A watch face clips a circular complication to its slot, so anything the layout
+ // draws into the corners is not merely tight -- it is not shown at all. Previewing
+ // it square would make a design look fine that loses content on the device.
+ g2.setClip(new java.awt.geom.Ellipse2D.Float(0, 0, logicalWidth, logicalHeight));
}
if (pillBackground) {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h
index 2b74a46aa6b..d5d28571acc 100644
--- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h
+++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h
@@ -97,6 +97,15 @@
/// Re-runs the received-context delivery once, after any number of paths have been forgotten.
- (void)scheduleReceivedContextReplay;
+/// Mirrors a published surface timeline to the paired watch so a complication can render it.
+///
+/// A class method because the surfaces natives reach it through NSClassFromString: they compile
+/// in builds that never touched com.codename1.wearable, where this class may not exist at all.
+///
+/// Best-effort by contract. See the implementation for the delivery ladder; the caller has
+/// already persisted the timeline locally, so nothing here can make the phone's own widget wrong.
++ (void)mirrorComplicationUserInfo:(NSDictionary *)info;
+
@end
#endif // CN1_USE_WATCHCONNECTIVITY
diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m
index 31d981fed3a..8c7be1b281d 100644
--- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m
+++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m
@@ -155,6 +155,20 @@ static void cn1WearableExpireReplies(NSMutableDictionary *replies, NSMutableDict
return dir;
}
+#if !TARGET_OS_WATCH
+/// Where complication payloads waiting their turn are parked, for the same reason received
+/// transfers are: the process does not own its own lifetime.
+static NSString *cn1PendingComplicationsPath(void) {
+ NSArray *dirs = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,
+ NSUserDomainMask, YES);
+ NSString *base = dirs.count > 0 ? dirs[0] : NSTemporaryDirectory();
+ NSString *dir = [base stringByAppendingPathComponent:@"cn1-surface-outbox"];
+ [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES
+ attributes:nil error:NULL];
+ return [dir stringByAppendingPathComponent:@"pending.plist"];
+}
+#endif
+
/// Writes the encoded transfer to the inbox and returns its file name, or nil.
/// Entries whose durable write failed -- storage full, or unavailable behind data protection --
/// keyed by the same token the durable copy would have used.
@@ -888,6 +902,39 @@ @implementation CN1WatchConnectivity {
/// When each pending reply arrived, so one that is never answered can be retired. Parallel to
/// _pendingReplies and guarded by the same monitor.
NSMutableDictionary *_pendingReplyAt;
+ /// Complication payloads waiting to be sent, keyed by kind id.
+ ///
+ /// transferCurrentComplicationUserInfo: keeps only the MOST RECENT transfer -- handing it a
+ /// second payload discards the first -- and each payload carries one kind. So two kinds
+ /// published before the first is delivered meant the earlier one simply never arrived, and
+ /// with the generated providers disabling periodic updates nothing would refresh it. Held
+ /// here and sent one at a time instead. A repeat of the same kind replaces its own entry,
+ /// which is what should happen: only the newest timeline for a kind is worth sending.
+ NSMutableDictionary *_pendingComplications;
+ /// The order the kinds were published in, so the queue is not at the mercy of dictionary
+ /// enumeration. A kind already queued keeps its original position.
+ NSMutableArray *_pendingComplicationOrder;
+ /// The kind currently handed to WCSession, or nil when nothing is in flight.
+ NSString *_complicationInFlight;
+ /// The exact payload handed over for that kind. Compared by identity when the transfer
+ /// finishes, so a NEWER payload queued for the same kind meanwhile is not mistaken for the
+ /// one that was just delivered and discarded with it.
+ NSDictionary *_complicationInFlightPayload;
+ /// The transfer object WCSession returned for it.
+ ///
+ /// The completion callback is told which TRANSFER finished, and matching on the kind alone is
+ /// not enough: the fallback transferUserInfo: path queues its own transfers for the same
+ /// kinds, and one of those completing would clear an unrelated newer transfer that is still
+ /// in flight -- after which the next publish displaces it before the watch ever sees it.
+ WCSessionUserInfoTransfer *_complicationInFlightTransfer;
+ /// Transfers whose completion arrived before the sending thread could record them.
+ ///
+ /// transferCurrentComplicationUserInfo: can complete on the delegate queue before it has even
+ /// returned to the caller, so the recorded slot is briefly nil while a transfer is genuinely
+ /// in flight. Discarding the completion in that window left _complicationInFlight set for
+ /// ever and stalled the whole queue -- every later publication silently unsent. The
+ /// completion is parked here instead, and the sending thread finds it the moment it records.
+ NSMutableSet *_complicationCompletedEarly;
/// Guards the recurring tombstone sweep to a single pending chain; see pruneTombstonesNow.
BOOL _tombstoneSweepScheduled;
/// Guards the post-removal deadline sweep to one block, however many paths are removed.
@@ -918,6 +965,9 @@ - (instancetype)init {
if (self != nil) {
_pendingReplies = [[NSMutableDictionary alloc] init];
_pendingReplyAt = [[NSMutableDictionary alloc] init];
+ _pendingComplications = [[NSMutableDictionary alloc] init];
+ _pendingComplicationOrder = [[NSMutableArray alloc] init];
+ _complicationCompletedEarly = [[NSMutableSet alloc] init];
_nextInboundToken = 1;
_lastReceived = [[NSMutableDictionary alloc] init];
}
@@ -930,12 +980,293 @@ - (void)activate {
s.delegate = self;
[s activate];
}
+#if !TARGET_OS_WATCH
+ // Anything that was still waiting its turn when the process last ended. Only the head of the
+ // queue is ever handed to WCSession -- the rest lived only in memory -- so a suspension or a
+ // termination during a background transfer lost them outright, and their complications stayed
+ // stale until something else published. Restored here because activation is the one thing
+ // that always happens, whatever brought the process up.
+ [self restorePendingComplications];
+#endif
+}
+
+#if !TARGET_OS_WATCH
+/// Writes the waiting queue to disk. Called with the monitor held.
+- (void)persistPendingComplicationsLocked {
+ @try {
+ if ([_pendingComplicationOrder count] == 0) {
+ [[NSFileManager defaultManager] removeItemAtPath:cn1PendingComplicationsPath()
+ error:NULL];
+ return;
+ }
+ NSDictionary *doc = [NSDictionary dictionaryWithObjectsAndKeys:
+ [NSArray arrayWithArray:_pendingComplicationOrder], @"order",
+ [NSDictionary dictionaryWithDictionary:_pendingComplications], @"payloads", nil];
+ NSData *encoded = [NSPropertyListSerialization dataWithPropertyList:doc
+ format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil];
+ if (encoded != nil) {
+ [encoded writeToFile:cn1PendingComplicationsPath() atomically:YES];
+ }
+ } @catch (NSException *ex) {
+ NSLog(@"[CN1Surfaces] could not park the pending complication queue: %@", ex.reason);
+ }
+}
+
+/// Reads back whatever the last process left waiting, and starts sending again.
+- (void)restorePendingComplications {
+ NSData *encoded = [NSData dataWithContentsOfFile:cn1PendingComplicationsPath()];
+ if (encoded == nil) {
+ return;
+ }
+ id doc = [NSPropertyListSerialization propertyListWithData:encoded options:0 format:NULL
+ error:nil];
+ if (![doc isKindOfClass:[NSDictionary class]]) {
+ return;
+ }
+ id order = [(NSDictionary *)doc objectForKey:@"order"];
+ id payloads = [(NSDictionary *)doc objectForKey:@"payloads"];
+ if (![order isKindOfClass:[NSArray class]] || ![payloads isKindOfClass:[NSDictionary class]]) {
+ return;
+ }
+ @synchronized (self) {
+ for (id kind in (NSArray *)order) {
+ id payload = [(NSDictionary *)payloads objectForKey:kind];
+ if (![kind isKindOfClass:[NSString class]]
+ || ![payload isKindOfClass:[NSDictionary class]]) {
+ continue;
+ }
+ // A kind published since the restore is NEWER than what was parked, so the parked
+ // one is dropped rather than overwriting it.
+ if ([_pendingComplications objectForKey:kind] != nil) {
+ continue;
+ }
+ [_pendingComplicationOrder addObject:kind];
+ [_pendingComplications setObject:payload forKey:kind];
+ }
+ }
+ [self sendNextComplicationUserInfo];
}
+#endif
- (WCSession *)session {
return [WCSession isSupported] ? [WCSession defaultSession] : nil;
}
+// --- surface mirror ------------------------------------------------------
+//
+// Complication content published on the phone, delivered to the watch. Kept beside the rest of
+// the WCSession plumbing rather than in IOSNative.m so session activation and delegate
+// bookkeeping have one owner.
+
++ (void)mirrorComplicationUserInfo:(NSDictionary *)info {
+#if TARGET_OS_WATCH
+ // Only the phone mirrors. The watch's own publish is authoritative and sending it back would
+ // loop when the phone mirrored in the first place.
+ (void)info;
+#else
+ if (info == nil) {
+ return;
+ }
+ // Activates lazily on first touch, which is what makes this work in an app that publishes
+ // surfaces and never calls the wearable API.
+ CN1WatchConnectivity *self_ = [CN1WatchConnectivity shared];
+ WCSession *s = [self_ session];
+ if (s == nil) {
+ return;
+ }
+ NSString *kind = [info objectForKey:@"cn1.surfaces.kind"];
+ if (kind == nil) {
+ kind = @"";
+ }
+ if (s.activationState != WCSessionActivationStateActivated) {
+ // NOT YET JUDGED. shared activates the session asynchronously, so the first publish in a
+ // fresh process arrives before activation completes -- and until it does, isPaired and
+ // isWatchAppInstalled are not reliable and a transfer may be refused outright. Deciding
+ // here would discard the only copy of that payload on the strength of an answer the
+ // session was not ready to give.
+ //
+ // Queued instead, which also parks it on disk, and activationDidCompleteWithState sends
+ // it once the session can actually be asked.
+ [self_ enqueueComplicationUserInfo:info forKind:kind];
+ return;
+ }
+ if (!s.isPaired || !s.isWatchAppInstalled) {
+ // No watch, or no watch app to receive it. Not a failure: most installs are this.
+ //
+ // A FAST PATH ONLY. sendNextComplicationUserInfo asks the same question again about
+ // whatever it is about to send, because a payload can reach it without passing through
+ // here at all. Kept because the common install has no watch, and without it every
+ // publish on such a phone would write a queue file and delete it again.
+ return;
+ }
+ // Which transfer to spend is decided at the moment of SENDING, not here: see
+ // sendNextComplicationUserInfo.
+ [self_ enqueueComplicationUserInfo:info forKind:kind];
+#endif
+}
+
+#if !TARGET_OS_WATCH
+/// Queues a complication payload and sends it when the session is free.
+///
+/// One at a time, because transferCurrentComplicationUserInfo: keeps only the most recent
+/// transfer: handing it a second payload while the first is still pending discards the first
+/// outright. Sending only when nothing is in flight means the payload it holds is always one we
+/// have not yet been told was delivered, so nothing is displaced.
+- (void)enqueueComplicationUserInfo:(NSDictionary *)info forKind:(NSString *)kind {
+ @synchronized (self) {
+ if ([_pendingComplications objectForKey:kind] == nil) {
+ [_pendingComplicationOrder addObject:kind];
+ }
+ [_pendingComplications setObject:info forKey:kind];
+ [self persistPendingComplicationsLocked];
+ }
+ [self sendNextComplicationUserInfo];
+}
+
+/// Hands the next queued payload to WCSession, if nothing is in flight.
+- (void)sendNextComplicationUserInfo {
+ NSDictionary *info = nil;
+ NSString *kind = nil;
+ @synchronized (self) {
+ if (_complicationInFlight != nil || [_pendingComplicationOrder count] == 0) {
+ return;
+ }
+ kind = [_pendingComplicationOrder objectAtIndex:0];
+ info = [_pendingComplications objectForKey:kind];
+ if (info == nil) {
+ [_pendingComplicationOrder removeObjectAtIndex:0];
+ return;
+ }
+ _complicationInFlight = kind;
+ _complicationInFlightPayload = info;
+ _complicationInFlightTransfer = nil;
+ }
+ WCSession *s = [self session];
+ // Not before the session can be asked. The restore on activation calls this too, and a queue
+ // drained against an unactivated session would spend its payloads on transfers that may be
+ // refused -- which is the discard this queue exists to prevent.
+ if (s == nil || s.activationState != WCSessionActivationStateActivated) {
+ @synchronized (self) {
+ _complicationInFlight = nil;
+ _complicationInFlightPayload = nil;
+ _complicationInFlightTransfer = nil;
+ }
+ return;
+ }
+ // THE LADDER, weakest guarantee last -- and here rather than at the publish that produced the
+ // payload, because a payload can reach this point without having been judged at all: the
+ // pre-activation queue and the restore from disk both hand over payloads whose publish either
+ // could not ask the session yet or happened in a previous run of the app. Sending those
+ // straight down the budgeted path spends a transfer on a watch with no complication placed,
+ // or on a budget already exhausted -- and the resulting exception retires the payload, which
+ // is the discard this queue exists to prevent.
+ if (!s.isPaired || !s.isWatchAppInstalled) {
+ // Nothing to deliver it to. Retired rather than held for ever: a queue that keeps a
+ // payload for a watch that is not there never drains, and it is persisted, so it would
+ // outlive the process too.
+ [self finishComplicationForKind:kind];
+ return;
+ }
+ // transferCurrentComplicationUserInfo is the only API that WAKES the watch app in the
+ // background to refresh a complication, and it is budgeted -- roughly fifty a day. Spending
+ // one when the user has placed no complication wastes the budget the app will want later,
+ // and spending one that is not there fails outright, so both cases fall through to
+ // transferUserInfo: queued, unbudgeted, and applied whenever the watch app next runs. That
+ // is materially weaker -- a complication may show stale content until then -- which is why
+ // it is the fallback rather than the default.
+ BOOL wantsWake = s.isComplicationEnabled;
+ if (wantsWake && s.remainingComplicationUserInfoTransfers == 0) {
+ wantsWake = NO;
+ NSLog(@"[CN1Surfaces] the watch complication refresh budget is spent for today; "
+ "queueing the update to apply when the watch app next runs");
+ }
+ if (!wantsWake) {
+ @try {
+ // transferUserInfo: QUEUES -- successive calls all survive -- so it needs none of the
+ // in-flight sequencing the budgeted transfer below does. Handed over and retired in
+ // one step, which also drains whatever is behind it.
+ [s transferUserInfo:info];
+ } @catch (NSException *ex) {
+ // WCSession raises rather than returning an error for a payload it will not carry.
+ // The publish itself already succeeded, so this is reported and dropped.
+ NSLog(@"[CN1Surfaces] could not mirror a surface to the watch: %@", ex.reason);
+ }
+ [self finishComplicationForKind:kind];
+ return;
+ }
+ @try {
+ WCSessionUserInfoTransfer *handed = [s transferCurrentComplicationUserInfo:info];
+ BOOL alreadyDone = NO;
+ @synchronized (self) {
+ // Only if this is still the transfer we started. A completion can land before this
+ // assignment does, and overwriting a cleared slot would leave the queue believing
+ // something is in flight for ever.
+ if (_complicationInFlight != nil && [_complicationInFlight isEqualToString:kind]
+ && _complicationInFlightPayload == info) {
+ if (handed != nil && [_complicationCompletedEarly containsObject:handed]) {
+ // It finished before we got here. The delegate parked it rather than
+ // discarding it, precisely so this thread can retire it now -- discarding it
+ // there would have left the queue believing this kind was still in flight and
+ // stalled every publication behind it.
+ [_complicationCompletedEarly removeObject:handed];
+ alreadyDone = YES;
+ } else {
+ _complicationInFlightTransfer = handed;
+ }
+ } else if (handed != nil) {
+ [_complicationCompletedEarly removeObject:handed];
+ }
+ }
+ if (alreadyDone) {
+ [self finishComplicationForKind:kind];
+ }
+ } @catch (NSException *ex) {
+ // Raised for a payload the session will not carry. Drop this kind and carry on with the
+ // rest: holding the queue for it would strand every kind behind it.
+ NSLog(@"[CN1Surfaces] could not mirror a surface to the watch: %@", ex.reason);
+ [self finishComplicationForKind:kind];
+ }
+}
+
+/// Retires a kind whose transfer has completed (or failed) and starts the next.
+///
+/// Only the payload that was actually sent is retired. Publishing the same kind again while its
+/// transfer is in flight replaces the queued value with the newer timeline, and removing the
+/// entry unconditionally here threw that replacement away -- the watch then stayed on the older
+/// timeline for good, since the generated provider disables periodic updates. Compared by
+/// identity rather than by kind: it is the same object only if nothing has replaced it.
+- (void)finishComplicationForKind:(NSString *)kind {
+ if (kind == nil) {
+ return;
+ }
+ @synchronized (self) {
+ NSDictionary *sent = _complicationInFlightPayload;
+ if (_complicationInFlight != nil && [_complicationInFlight isEqualToString:kind]) {
+ _complicationInFlight = nil;
+ _complicationInFlightPayload = nil;
+ _complicationInFlightTransfer = nil;
+ // Nothing is in flight, so a parked completion can only be for a transfer already
+ // retired. Cleared here rather than left to accumulate.
+ [_complicationCompletedEarly removeAllObjects];
+ }
+ NSDictionary *queued = [_pendingComplications objectForKey:kind];
+ if (queued == nil || queued == sent) {
+ // Nothing newer arrived while it was in flight, so this kind is done.
+ [_pendingComplications removeObjectForKey:kind];
+ [_pendingComplicationOrder removeObject:kind];
+ } else {
+ // A newer timeline for the same kind is waiting. Keep it queued -- and move it to the
+ // BACK, so a kind republished in a tight loop cannot hold the head of the queue and
+ // starve the other kinds behind it.
+ [_pendingComplicationOrder removeObject:kind];
+ [_pendingComplicationOrder addObject:kind];
+ }
+ [self persistPendingComplicationsLocked];
+ }
+ [self sendNextComplicationUserInfo];
+}
+#endif
+
// --- state ---------------------------------------------------------------
- (BOOL)isSupported {
@@ -1334,6 +1665,51 @@ - (void)cn1CleanupStagedTransfer:(WCSessionFileTransfer *)transfer {
// --- WCSessionDelegate ---------------------------------------------------
+#if !TARGET_OS_WATCH
+/// Completion for the complication queue: the transfer WCSession was holding is done, so the
+/// next queued kind can be handed over without displacing anything.
+///
+/// Retired on failure too. A payload the watch refused is not going to succeed by being kept at
+/// the head of the queue, and holding it there strands every kind behind it -- which is the very
+/// failure the queue exists to prevent.
+- (void)session:(WCSession *)session
+ didFinishUserInfoTransfer:(WCSessionUserInfoTransfer *)userInfoTransfer
+ error:(NSError *)error {
+ NSDictionary *info = userInfoTransfer.userInfo;
+ NSString *kind = info == nil ? nil : [info objectForKey:@"cn1.surfaces.kind"];
+ if (kind == nil) {
+ // Not one of ours -- the wearable API's own transferUserInfo: traffic lands here too.
+ return;
+ }
+ // THIS transfer, not merely this kind. The fallback transferUserInfo: path queues transfers
+ // for the same kinds, and an old one of those completing would otherwise clear a newer
+ // complication transfer that is still in flight -- after which the next publish displaces it
+ // and the watch never sees it. A surfaces transfer we are not tracking needs no bookkeeping.
+ BOOL mine = NO;
+ @synchronized (self) {
+ if (_complicationInFlightTransfer != nil) {
+ mine = _complicationInFlightTransfer == userInfoTransfer;
+ } else if (_complicationInFlight != nil
+ && [_complicationInFlight isEqualToString:kind]) {
+ // In flight for this kind, but the sending thread has not recorded the transfer
+ // object yet -- WCSession can complete before transferCurrentComplicationUserInfo:
+ // has even returned. Parked rather than dropped: dropping it is what left the queue
+ // believing this kind was still in flight for ever, with every later publication
+ // silently unsent. The sender retires it the moment it looks.
+ [_complicationCompletedEarly addObject:userInfoTransfer];
+ }
+ }
+ if (error != nil) {
+ NSLog(@"[CN1Surfaces] the watch did not accept the update for \"%@\": %@", kind,
+ error.localizedDescription);
+ }
+ if (!mine) {
+ return;
+ }
+ [self finishComplicationForKind:kind];
+}
+#endif
+
- (void)session:(WCSession *)session
didFinishFileTransfer:(WCSessionFileTransfer *)fileTransfer
error:(NSError *)error {
@@ -1355,6 +1731,15 @@ - (void)session:(WCSession *)session
// only thing that will ever look at that batch again.
[self pruneTombstonesNow];
cn1_wearable_notifyStateChanged();
+#if !TARGET_OS_WATCH
+ // Complication payloads queued BEFORE the session finished activating. A publish in a fresh
+ // process reaches the mirror before this callback, and the session's pairing and installed-app
+ // answers are not reliable until now -- so those payloads waited rather than being judged
+ // against an unformed session, and this is where they go.
+ if (activationState == WCSessionActivationStateActivated) {
+ [self sendNextComplicationUserInfo];
+ }
+#endif
}
#if !TARGET_OS_WATCH
@@ -1432,6 +1817,153 @@ - (void)dispatchInbound:(NSDictionary *)message
cn1_wearable_deliverMessage(path.UTF8String, body.bytes, (int) body.length, token);
}
+- (void)session:(WCSession *)session didReceiveUserInfo:(NSDictionary *)userInfo {
+ // The receiving half of the surface mirror. Both rungs of the sender's ladder --
+ // transferCurrentComplicationUserInfo and transferUserInfo -- arrive here.
+ //
+ // Framework traffic is routed BEFORE anything app-visible, the same way /cnxk acknowledgement
+ // traffic is: a reserved key is bookkeeping, and delivering it to the app's own listeners
+ // would show it a message it never sent itself.
+ if (userInfo != nil && [userInfo objectForKey:@"cn1.surfaces.kind"] != nil) {
+ [self applyMirroredSurface:userInfo];
+ return;
+ }
+ // Nothing else uses this queue today. Ignored rather than guessed at: a payload with no
+ // reserved key did not come from this framework.
+}
+
+#if TARGET_OS_WATCH
+/// Applies a mirrored timeline: persist it where the complication extension reads, then ask
+/// WidgetKit to re-render.
+///
+/// Deliberately HEADLESS -- it does not start the CN1 runtime. Everything this needs is a file
+/// write and a WidgetCenter poke, and the app process may well not be running: the whole point of
+/// the wake is to refresh a complication, not to bring an application forward the user did not
+/// ask for. When the runtime IS up, Surfaces.publishRemote is called as well so the app's own
+/// diagnostics observe the update.
+/// The one queue every mirrored apply runs on, delivered or retried.
+///
+/// The delegate hands deliveries over on its own queue and a retry fires from a timer, so without
+/// this they can run at once -- and the check-install-record sequence below is not atomic.
+static dispatch_queue_t cn1MirrorQueue(void) {
+ static dispatch_queue_t queue = NULL;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ queue = dispatch_queue_create("com.codename1.surfaces.mirror", DISPATCH_QUEUE_SERIAL);
+ });
+ return queue;
+}
+
+/// How many times a mirrored surface the watch could not install is re-attempted, and the delay
+/// before the first. The delays double, so the last lands a little over twenty minutes out --
+/// past the transient conditions this is for, and short of holding a payload indefinitely.
+#define CN1_MIRROR_APPLY_RETRIES 6
+#define CN1_MIRROR_APPLY_DELAY_NS (20ull * NSEC_PER_SEC)
+
+/// Re-attempts a mirrored surface, on the same delegate-facing path as the original delivery.
+- (void)retryMirroredSurface:(NSDictionary *)info attempt:(int)attempt {
+ if (attempt > CN1_MIRROR_APPLY_RETRIES) {
+ NSLog(@"[CN1Surfaces] gave up installing a mirrored surface after %d attempts; the watch "
+ "keeps what it had until the phone publishes again", CN1_MIRROR_APPLY_RETRIES);
+ return;
+ }
+ // Captured PLAIN, not __block. This file is manual reference counting -- see the retain and
+ // release calls throughout -- and a copied block retains an ordinary captured object while a
+ // __block one it does not: the payload would have been released when didReceiveUserInfo
+ // returned, and the retry would read freed memory twenty seconds later.
+ NSDictionary *payload = info;
+ // On the MIRROR QUEUE, not a global one. applyMirroredSurface reads the stored sequence,
+ // installs, and records the new mark, and those three are not atomic together: a retry racing
+ // a freshly delivered publication could pass the check, let the newer one install and record,
+ // and then overwrite it and LOWER the mark -- leaving the complication stale and the ordering
+ // permanently confused. One serial queue makes every apply, delivered or retried, exclusive.
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
+ (int64_t)(CN1_MIRROR_APPLY_DELAY_NS << (attempt - 1))),
+ cn1MirrorQueue(), ^{
+ [self applyMirroredSurface:payload attempt:attempt + 1];
+ });
+}
+
+- (void)applyMirroredSurface:(NSDictionary *)info {
+ // Onto the mirror queue, so a delivery cannot interleave with a retry already in flight.
+ NSDictionary *payload = info;
+ dispatch_async(cn1MirrorQueue(), ^{
+ [self applyMirroredSurface:payload attempt:1];
+ });
+}
+
+- (void)applyMirroredSurface:(NSDictionary *)info attempt:(int)attempt {
+ NSString *kind = [info objectForKey:@"cn1.surfaces.kind"];
+ NSData *json = [info objectForKey:@"cn1.surfaces.json"];
+ if (![kind isKindOfClass:[NSString class]] || ![json isKindOfClass:[NSData class]]) {
+ return;
+ }
+ // ARRIVAL ORDER IS NOT PUBLICATION ORDER. The sender has two transports and they do not share
+ // a queue: transferCurrentComplicationUserInfo is prioritized, transferUserInfo merely
+ // queued, and the sender falls back to the second whenever the complication is disabled or
+ // its daily budget is spent. So a publication sent on the queued transport can arrive after a
+ // later one sent on the prioritized one, and applying both in the order they land lets the
+ // older timeline overwrite the newer -- permanently, since the generated provider has no
+ // periodic update to correct it.
+ //
+ // Persisted rather than held in memory: this delegate runs in a process the system starts and
+ // stops at will, and a counter that resets would let the next stale payload through.
+ id sequence = [info objectForKey:@"cn1.surfaces.seq"];
+ NSString *sequenceKey = nil;
+ long long incoming = 0;
+ if ([sequence isKindOfClass:[NSNumber class]]) {
+ sequenceKey = [@"cn1.surfaces.seq." stringByAppendingString:kind];
+ NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
+ long long applied = (long long)[defaults doubleForKey:sequenceKey];
+ incoming = [(NSNumber *)sequence longLongValue];
+ if (applied != 0 && incoming <= applied) {
+ NSLog(@"[CN1Surfaces] ignoring a mirrored update for \"%@\" that was superseded "
+ "before it arrived (%lld <= %lld)", kind, incoming, applied);
+ return;
+ }
+ }
+ NSMutableArray *names = [NSMutableArray array];
+ NSMutableArray *blobs = [NSMutableArray array];
+ for (NSString *key in info) {
+ if ([key hasPrefix:@"cn1.surfaces.img."]) {
+ id blob = [info objectForKey:key];
+ if ([blob isKindOfClass:[NSData class]]) {
+ [names addObject:[key substringFromIndex:[@"cn1.surfaces.img." length]]];
+ [blobs addObject:blob];
+ }
+ }
+ }
+ // The mark moves only when the timeline is actually INSTALLED. Recording it first meant a
+ // write that failed -- a momentarily unwritable App Group, a full disk -- still raised the
+ // high-water mark, so the payload was consumed and even a redelivery of the very same one was
+ // rejected as superseded. The complication then kept its old content until the phone happened
+ // to publish again. A failed apply now leaves the mark where it was, so the next delivery of
+ // this publication, or any later one, still lands.
+ if (!cn1_watch_apply_mirrored_surface(kind, json, names, blobs)) {
+ // Retried, because nothing else will offer this payload again: didReceiveUserInfo is a
+ // one-shot delivery, and leaving the high-water mark alone only permits a LATER
+ // publication -- it does not bring this one back. If the phone publishes nothing further,
+ // the complication keeps its old content for good.
+ //
+ // In memory and bounded, the same shape the Android mirror uses: the condition this is
+ // for is transient (an App Group briefly unwritable, a full disk), and persisting the
+ // payload to survive a process death would mean writing to the storage that just refused
+ // a write.
+ [self retryMirroredSurface:info attempt:attempt];
+ return;
+ }
+ if (sequenceKey != nil) {
+ [[NSUserDefaults standardUserDefaults] setDouble:(double)incoming forKey:sequenceKey];
+ }
+}
+#else
+- (void)applyMirroredSurface:(NSDictionary *)info {
+ // Only the watch consumes a mirror. Reaching here on the phone means the payload came back
+ // the way it went, which nothing sends.
+ (void)info;
+}
+#endif
+
- (void)session:(WCSession *)session
didReceiveApplicationContext:(NSDictionary *)applicationContext {
// SERIALIZED against itself. WCSession delivers this on its own delegate queue, and the
diff --git a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m
index 0228cc85636..ca026f8d0d8 100644
--- a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m
+++ b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m
@@ -220,6 +220,17 @@ static void cn1WatchDeliverPhase(int phase) {
void cn1_watch_runtime_markJavaReady(void) {
cn1WatchJavaLifecycleReady = YES;
cn1WatchReplayPendingPhase();
+ // ...and a complication tap that arrived before the VM did. A tap on a terminated watch app
+ // launches it WITH the URL, and SwiftUI delivers onOpenURL as soon as the scene exists --
+ // which is before this. Same readiness, same drain point.
+ extern void cn1_watch_surface_drainPending(void);
+ cn1_watch_surface_drainPending();
+}
+
+/// Whether the Java lifecycle callback has run, for the surface-URL path in IOSNative.m. An int
+/// rather than a BOOL so the declaration at the call site needs no Objective-C types.
+int cn1_watch_runtime_isJavaReady(void) {
+ return cn1WatchJavaLifecycleReady ? 1 : 0;
}
/// Hands over, in order, every phase the app could not be told about yet.
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
index 84f6f7ec7fb..ca104839bf2 100644
--- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
+++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
@@ -449,33 +449,19 @@ - (BOOL)cn1OpenURL:(UIApplication *)application url:(NSURL *)url sourceApplicati
#endif
#ifdef CN1_USE_WIDGETS
- // Surface action deep link (cn1surface://a?src=..&id=..&p=) from a widget
- // or live activity tap. Decode and hand it straight to the Java framework;
+ // Surface action deep link (cn1surface://a?src=..&id=..&p=) from a widget,
+ // live activity or complication tap. Handed straight to the Java framework;
// Surfaces.dispatchAction queues internally until the app registers its action handler, so
// cold-start taps are safe (every openURL path -- delegate, legacy handleOpenURL and the
// scene delegate's connection/openURLContexts callbacks -- funnels through cn1OpenURL after
// the VM is up, exactly like the shouldApplicationHandleURL call below). These URLs are
// consumed here: do NOT store them in AppArg and report them handled so no other machinery
// sees them.
- if (url.scheme != nil && [@"cn1surface" caseInsensitiveCompare:url.scheme] == NSOrderedSame) {
- NSURLComponents *cn1SurfaceComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
- NSString *cn1SurfaceSrc = nil;
- NSString *cn1SurfaceActionId = nil;
- NSString *cn1SurfaceParams = nil;
- for (NSURLQueryItem *item in cn1SurfaceComponents.queryItems) {
- if ([item.name isEqualToString:@"src"]) {
- cn1SurfaceSrc = item.value;
- } else if ([item.name isEqualToString:@"id"]) {
- cn1SurfaceActionId = item.value;
- } else if ([item.name isEqualToString:@"p"]) {
- // NSURLQueryItem.value is already percent-decoded JSON.
- cn1SurfaceParams = item.value;
- }
- }
- JAVA_OBJECT jSurfaceSrc = cn1SurfaceSrc == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG cn1SurfaceSrc);
- JAVA_OBJECT jSurfaceActionId = cn1SurfaceActionId == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG cn1SurfaceActionId);
- JAVA_OBJECT jSurfaceParams = cn1SurfaceParams == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG cn1SurfaceParams);
- com_codename1_impl_ios_IOSSurfaceCallbacks_nativeSurfaceAction___java_lang_String_java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG jSurfaceSrc, jSurfaceActionId, jSurfaceParams);
+ // Decoded by cn1HandleSurfaceURL in IOSNative.m rather than here, because the watch reaches
+ // the same deep link with no UIApplicationDelegate to route it through -- a complication tap
+ // launches the app and delivers the URL to the SwiftUI scene instead. One decoder, so the two
+ // platforms cannot drift on what a surface action means.
+ if (cn1HandleSurfaceURL(url)) {
return YES;
}
#endif // CN1_USE_WIDGETS
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h
index 7a2c7f6df68..02370cb5ec0 100644
--- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h
+++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h
@@ -169,11 +169,37 @@ void cn1RunSyncOnMainQueue(void (^block)(void));
// header (included first by every surfaces TU) so the define is visible across translation
// units, mirroring CN1_USE_CARPLAY.
//#define CN1_USE_WIDGETS
-// WidgetKit home-screen widgets are unavailable on watchOS / tvOS; undo the define there.
-#if TARGET_OS_WATCH || TARGET_OS_TV
+// tvOS has no WidgetKit at all, so the define is undone there.
+//
+// watchOS deliberately KEEPS it. A complication is a WidgetKit widget in an accessory family,
+// hosted by the watch app's own CN1WatchWidgets extension and fed from the watch's own App
+// Group container -- the same identifier as the phone's, but a separate container on the
+// device, which is why the watch has to publish for itself rather than reading what the phone
+// wrote. The surfaces natives below are pure Foundation and resolve the Swift bridge through
+// NSClassFromString, so they are exactly as real on the watch as on the phone. While this
+// undef also covered watchOS, Surfaces.publish() from a watch app compiled to the unsupported
+// stub and silently did nothing.
+#if TARGET_OS_TV
#undef CN1_USE_WIDGETS
#endif
+#ifdef CN1_USE_WIDGETS
+// Decodes a cn1surface:// deep link -- a widget, live activity or complication tap -- and
+// dispatches it to the Java framework. Implemented in IOSNative.m rather than in the app
+// delegate because the delegate is #if !TARGET_OS_WATCH and the watch reaches the same link
+// through its SwiftUI scene. Returns YES when the URL was ours and has been consumed.
+BOOL cn1HandleSurfaceURL(NSURL *url);
+
+#if TARGET_OS_WATCH
+// Applies a timeline the phone mirrored across into the watch's own App Group container and
+// re-renders. Called from CN1WatchConnectivity's didReceiveUserInfo, which may run with no CN1
+// runtime at all -- the whole point of the background wake is to refresh a complication, not to
+// start an application -- so this touches no Java.
+BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json,
+ NSArray *imageNames, NSArray *imageBlobs);
+#endif
+#endif
+
// CN1_USE_INTENTS gates the app intents native bridge: the IOSNative intents* implementations
// (Core Spotlight directly, App Intents through the generated Swift CN1IntentBridge via the
// CN1IntentHost Objective-C shim) plus the non-browsing NSUserActivity handling in
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 1207524ac5b..c919fec5328 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -65,6 +65,13 @@
#include "com_codename1_impl_ios_IOSSecureStorage.h"
#include "com_codename1_impl_ios_IOSNfc.h"
#include "com_codename1_impl_ios_IOSConnectivity.h"
+// Declares nativeSurfaceAction for cn1HandleSurfaceURL below. The decode used to live in
+// the app delegate, which includes this same header; moving it here for the watch left the
+// call with no declaration, and C then invented one. Catalyst builds with
+// -Werror=implicit-function-declaration and said so, but the danger is not the diagnostic:
+// an invented prototype passes three JAVA_OBJECTs and a thread state through the wrong
+// registers, which links and then misbehaves.
+#include "com_codename1_impl_ios_IOSSurfaceCallbacks.h"
#include "com_codename1_ui_Display.h"
#include "com_codename1_ui_Component.h"
#include "java_lang_Throwable.h"
@@ -15302,27 +15309,179 @@ static Class cn1SurfacesBridgeClass() {
return NSClassFromString(@"CN1SurfaceBridge");
}
-// True when the running OS meets the CN1Widgets extension's deployment target
+// True when the running OS meets the widget extension's deployment target
// (CN1SurfacesMinOS Info.plist key, injected by the builder from
-// ios.surfaces.deploymentTarget; defaults to 16.1). Below that version the
+// ios.surfaces.deploymentTarget; defaults to 16.1 on iOS). Below that version the
// extension cannot run or appear in the widget gallery, so the API must not
// report widget support even though WidgetKit itself shipped with iOS 14.
+//
+// The fallback is per-platform because the two extensions have different floors and this
+// compares against the OS actually running. The watch app's CN1WatchWidgets extension targets
+// watchOS 10, so the iOS default of 16.1 would be compared against a watchOS version and never
+// be met -- every watch would have reported no widget support, whatever was in the plist.
static BOOL cn1SurfacesMinOSSupported() {
NSString *min = nil;
id v = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1SurfacesMinOS"];
if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) {
min = (NSString *)v;
} else {
+#if TARGET_OS_WATCH
+ min = @"10.0";
+#else
min = @"16.1";
+#endif
}
NSArray *parts = [min componentsSeparatedByString:@"."];
NSOperatingSystemVersion required;
- required.majorVersion = parts.count > 0 ? [[parts objectAtIndex:0] integerValue] : 16;
- required.minorVersion = parts.count > 1 ? [[parts objectAtIndex:1] integerValue] : 1;
+#if TARGET_OS_WATCH
+ NSInteger defaultMajor = 10;
+ NSInteger defaultMinor = 0;
+#else
+ NSInteger defaultMajor = 16;
+ NSInteger defaultMinor = 1;
+#endif
+ required.majorVersion = parts.count > 0 ? [[parts objectAtIndex:0] integerValue] : defaultMajor;
+ required.minorVersion = parts.count > 1 ? [[parts objectAtIndex:1] integerValue] : defaultMinor;
required.patchVersion = parts.count > 2 ? [[parts objectAtIndex:2] integerValue] : 0;
return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:required];
}
+// Decodes a cn1surface://a?src=..&id=..&p= deep link -- a widget, live
+// activity or complication tap -- and hands it to the Java framework.
+//
+// Shared because the two platforms reach it from opposite directions. On iOS every openURL path
+// funnels through the app delegate, which is entirely #if !TARGET_OS_WATCH; on watchOS there is
+// no UIApplicationDelegate at all and the URL arrives at the SwiftUI scene's onOpenURL, which
+// calls cn1_watch_surface_url below. Leaving the decode in the delegate meant a complication tap
+// launched the watch app and then dropped the action on the floor.
+//
+// Surfaces.dispatchAction queues internally until the app registers its handler, so a cold-start
+// tap -- which is the usual case for a complication -- is safe.
+BOOL cn1HandleSurfaceURL(NSURL *url) {
+ if (url == nil || url.scheme == nil) {
+ return NO;
+ }
+ // This app's own scheme, cn1surface., is what the widget and complication now
+ // generate: the bare cn1surface was claimed globally by every Codename One app, so two of
+ // them installed together were two claims on one name and the watch could route a tap to
+ // the wrong bundle. The bare name is still accepted on the phone because the app has always
+ // registered it and something may still hold a link built with it; the WATCH registers only
+ // the qualified one, which is where the collision actually bit.
+ NSString *ownScheme = [@"cn1surface." stringByAppendingString:
+ [[NSBundle mainBundle] bundleIdentifier] ?: @""];
+ BOOL mine = [ownScheme caseInsensitiveCompare:url.scheme] == NSOrderedSame;
+#if TARGET_OS_WATCH
+ if (!mine) {
+ return NO;
+ }
+#else
+ if (!mine && [@"cn1surface" caseInsensitiveCompare:url.scheme] != NSOrderedSame) {
+ return NO;
+ }
+#endif
+ NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
+ NSString *src = nil;
+ NSString *actionId = nil;
+ NSString *params = nil;
+ for (NSURLQueryItem *item in components.queryItems) {
+ if ([item.name isEqualToString:@"src"]) {
+ src = item.value;
+ } else if ([item.name isEqualToString:@"id"]) {
+ actionId = item.value;
+ } else if ([item.name isEqualToString:@"p"]) {
+ // NSURLQueryItem.value is already percent-decoded JSON.
+ params = item.value;
+ }
+ }
+ JAVA_OBJECT jSrc = src == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG src);
+ JAVA_OBJECT jActionId = actionId == nil ? JAVA_NULL
+ : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG actionId);
+ JAVA_OBJECT jParams = params == nil ? JAVA_NULL
+ : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG params);
+ com_codename1_impl_ios_IOSSurfaceCallbacks_nativeSurfaceAction___java_lang_String_java_lang_String_java_lang_String(
+ CN1_THREAD_GET_STATE_PASS_ARG jSrc, jActionId, jParams);
+ return YES;
+}
+
+#if TARGET_OS_WATCH
+/// A lock object for the pending-URL slot, which the SwiftUI scene and the VM bootstrap thread
+/// both touch.
+@interface CN1WatchSurfaceURLLock : NSObject
+@end
+@implementation CN1WatchSurfaceURLLock
+@end
+
+// Called from the generated CN1WatchApp.swift scene's onOpenURL. A complication tap launches the
+// watch app with the URL rather than delivering it to a delegate, so this is the whole path.
+
+// The tap that arrived before the VM did.
+//
+// A complication tap on a terminated watch app launches it WITH the URL, and SwiftUI delivers
+// onOpenURL as soon as the scene exists -- which is before cn1_watch_runtime_start has finished
+// bringing the VM up, because it starts it on a pthread and returns. Handling the URL then reaches
+// into a half-built runtime to make Java strings and call into Java. So it waits: one pending URL,
+// handed over by cn1_watch_runtime_markJavaReady, which is the same readiness the lifecycle phases
+// queue behind.
+//
+// One slot and not a queue. A launch carries one URL, and if a second somehow arrived first the
+// newest is the one the user just tapped.
+static NSString *cn1WatchPendingSurfaceURL = nil;
+
+/// Whether the drain has run, owned by the lock below rather than read from the runtime.
+///
+/// Asking cn1_watch_runtime_isJavaReady and then storing is two steps, and the VM thread can
+/// become ready and drain an empty slot between them -- the URL is stored a moment later and
+/// nothing ever looks at it again. So readiness and the slot move together under one lock: the
+/// drain sets this flag while holding it, and a tap either sees the flag and delivers or does not
+/// and is found by the drain.
+static BOOL cn1WatchSurfaceURLDrained = NO;
+
+void cn1_watch_surface_url(const char *url) {
+ if (url == NULL) {
+ return;
+ }
+ POOL_BEGIN();
+ NSString *str = [NSString stringWithUTF8String:url];
+ if (str != nil) {
+ BOOL deliverNow = NO;
+ @synchronized ([CN1WatchSurfaceURLLock class]) {
+ if (cn1WatchSurfaceURLDrained) {
+ deliverNow = YES;
+ } else {
+ [cn1WatchPendingSurfaceURL release];
+ cn1WatchPendingSurfaceURL = [str retain];
+ }
+ }
+ // Outside the lock: handling the URL calls into Java, which must not run holding a lock
+ // the VM thread also takes.
+ if (deliverNow) {
+ cn1HandleSurfaceURL([NSURL URLWithString:str]);
+ }
+ }
+ POOL_END();
+}
+
+/// Hands over a tap that arrived before the runtime was ready. Called from
+/// cn1_watch_runtime_markJavaReady, and defined whatever this build carries so that call needs no
+/// guard of its own.
+void cn1_watch_surface_drainPending(void) {
+ NSString *pending = nil;
+ @synchronized ([CN1WatchSurfaceURLLock class]) {
+ // The flag and the slot together, so a tap arriving alongside this either lands in the
+ // slot before it is emptied or delivers itself afterwards -- never neither.
+ cn1WatchSurfaceURLDrained = YES;
+ pending = cn1WatchPendingSurfaceURL;
+ cn1WatchPendingSurfaceURL = nil;
+ }
+ if (pending != nil) {
+ POOL_BEGIN();
+ cn1HandleSurfaceURL([NSURL URLWithString:pending]);
+ POOL_END();
+ [pending release];
+ }
+}
+#endif
+
JAVA_OBJECT com_codename1_impl_ios_IOSNative_getSurfacesContainerPath__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
POOL_BEGIN();
NSString *path = cn1SurfacesContainerPath();
@@ -15361,6 +15520,13 @@ JAVA_INT com_codename1_impl_ios_IOSNative_surfacesInstalledCount___java_lang_Str
}
JAVA_OBJECT com_codename1_impl_ios_IOSNative_surfacesStartActivity___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT descriptorJson) {
+ // Live activities are an iOS capability: watchOS has no ActivityKit, and the Swift bridge
+ // compiles its ActivityKit bodies out there. Answering here rather than relying on that
+ // states the intent -- and keeps the symbol, which the watch slice still links because the
+ // Java method is reachable from shared code.
+#if TARGET_OS_WATCH
+ return JAVA_NULL;
+#else
if (@available(iOS 16.1, *)) {
POOL_BEGIN();
JAVA_OBJECT result = JAVA_NULL;
@@ -15377,9 +15543,17 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_surfacesStartActivity___java_lang_S
return result;
}
return JAVA_NULL;
+#endif
}
void com_codename1_impl_ios_IOSNative_surfacesUpdateActivity___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityId, JAVA_OBJECT stateJson) {
+ // Live activities are an iOS capability: watchOS has no ActivityKit, and the Swift bridge
+ // compiles its ActivityKit bodies out there. Answering here rather than relying on that
+ // states the intent -- and keeps the symbol, which the watch slice still links because the
+ // Java method is reachable from shared code.
+#if TARGET_OS_WATCH
+ return;
+#else
if (@available(iOS 16.1, *)) {
POOL_BEGIN();
Class bridge = cn1SurfacesBridgeClass();
@@ -15391,9 +15565,17 @@ void com_codename1_impl_ios_IOSNative_surfacesUpdateActivity___java_lang_String_
}
POOL_END();
}
+#endif
}
void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_java_lang_String_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityId, JAVA_OBJECT finalStateJson, JAVA_BOOLEAN dismissImmediately) {
+ // Live activities are an iOS capability: watchOS has no ActivityKit, and the Swift bridge
+ // compiles its ActivityKit bodies out there. Answering here rather than relying on that
+ // states the intent -- and keeps the symbol, which the watch slice still links because the
+ // Java method is reachable from shared code.
+#if TARGET_OS_WATCH
+ return;
+#else
if (@available(iOS 16.1, *)) {
POOL_BEGIN();
Class bridge = cn1SurfacesBridgeClass();
@@ -15406,7 +15588,295 @@ void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_jav
}
POOL_END();
}
+#endif
+}
+
+// --- Phone -> watch complication mirror ------------------------------------
+//
+// An App Group container is device-local: the watch resolves the same identifier to a directory
+// of its own, which the phone cannot see. So a phone-side Surfaces.publish() is invisible to a
+// complication until the descriptor actually travels, and this is that transport.
+//
+// WCSession's transferCurrentComplicationUserInfo is the only API that WAKES the watch app in
+// the background to refresh a complication. updateApplicationContext -- which putData already
+// owns, with its own stamp and tombstone protocol -- delivers only when the watch app next runs,
+// which for a complication means "possibly never". The budget is small and reported, so this
+// degrades through progressively weaker delivery rather than pretending: no complication placed
+// or budget spent falls back to transferUserInfo, which arrives eventually; over the size cap
+// drops the imagery and then gives up entirely. The local publish has already succeeded, so the
+// phone's own widget stays correct whatever happens here.
+
+#if !TARGET_OS_WATCH
+
+/// A strictly increasing publication sequence for mirrored surfaces.
+///
+/// Seeded from the wall clock so it keeps rising across a relaunch -- a counter restarting at 1
+/// would have every publication after a restart look older than what the watch already holds --
+/// and incremented so two publications in the same millisecond still differ.
+static long long cn1NextSurfaceMirrorSequence(void) {
+ static long long last = 0;
+ static dispatch_once_t once;
+ static NSObject *lock = nil;
+ dispatch_once(&once, ^{
+ lock = [[NSObject alloc] init];
+ });
+ @synchronized (lock) {
+ NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
+ if (last == 0) {
+ // Resumed from the highest we have ever ISSUED, not from the clock.
+ //
+ // The clock is only a seed, and it can move backwards -- an NTP correction or the
+ // user setting the date. Reseeding from it after a relaunch would then hand out
+ // numbers below the high-water mark the WATCH has persisted, and the watch rejects
+ // those by design: every mirrored update would be dropped until the clock caught up,
+ // which could be hours or days. Remembering what we issued makes the sequence
+ // monotonic across a restart whatever the clock does.
+ last = (long long)[defaults doubleForKey:@"cn1.surfaces.seq.sent"];
+ }
+ long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
+ last = now > last ? now : last + 1;
+ [defaults setDouble:(double)last forKey:@"cn1.surfaces.seq.sent"];
+ return last;
+ }
+}
+
+
+// A property list has a hard ceiling around 64KB and rejects the whole payload on overflow.
+// Complication art is a few dozen points square, so 48KB is generous and leaves envelope room.
+#define CN1_SURFACES_MIRROR_MAX_BYTES (48 * 1024)
+
+// The kinds worth mirroring, from the CN1SurfacesWatchKinds Info.plist key the builder writes
+// from the manifest's watch families. Decided at build time so a publish of a phone-only kind
+// costs one dictionary lookup and nothing else.
+static NSSet *cn1SurfacesWatchKinds() {
+ static NSSet *kinds = nil;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ id v = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1SurfacesWatchKinds"];
+ if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) {
+ kinds = [[NSSet alloc] initWithArray:[(NSString *)v componentsSeparatedByString:@","]];
+ } else {
+ kinds = [[NSSet alloc] init];
+ }
+ });
+ return kinds;
+}
+
+static void cn1SurfacesLogOnce(NSString *key, NSString *message) {
+ static NSMutableSet *said = nil;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ said = [[NSMutableSet alloc] init]; });
+ @synchronized (said) {
+ if ([said containsObject:key]) {
+ return;
+ }
+ [said addObject:key];
+ }
+ NSLog(@"[CN1Surfaces] %@", message);
+}
+
+void com_codename1_impl_ios_IOSNative_surfacesMirrorToWatch___java_lang_String_java_lang_String_java_lang_String_1ARRAY_byte_2ARRAY(
+ CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT kindId, JAVA_OBJECT timelineJson,
+ JAVA_OBJECT imageNames, JAVA_OBJECT imageBlobs) {
+ if (kindId == JAVA_NULL || timelineJson == JAVA_NULL) {
+ return;
+ }
+ POOL_BEGIN();
+ NSString *kind = toNSString(CN1_THREAD_STATE_PASS_ARG kindId);
+ if (kind == nil || ![cn1SurfacesWatchKinds() containsObject:kind]) {
+ POOL_END();
+ return;
+ }
+ Class sessionClass = NSClassFromString(@"CN1WatchConnectivity");
+ if (sessionClass == nil) {
+ cn1SurfacesLogOnce(@"noWC", @"watch mirror unavailable: this build has no "
+ "WatchConnectivity glue");
+ POOL_END();
+ return;
+ }
+ NSString *json = toNSString(CN1_THREAD_STATE_PASS_ARG timelineJson);
+ if (json == nil) {
+ POOL_END();
+ return;
+ }
+ NSMutableDictionary *payload = [NSMutableDictionary dictionary];
+ [payload setObject:kind forKey:@"cn1.surfaces.kind"];
+ [payload setObject:[json dataUsingEncoding:NSUTF8StringEncoding] forKey:@"cn1.surfaces.json"];
+ // A publication sequence, so the watch can tell an older payload from a newer one.
+ //
+ // The two transports do not share a queue: transferCurrentComplicationUserInfo is prioritized
+ // and transferUserInfo merely queued, so a publication sent on the second -- because the
+ // complication was disabled or its daily budget was spent -- can arrive AFTER a later one
+ // sent on the first. Applied in arrival order, the older timeline then overwrites the newer
+ // and the complication sits on stale content indefinitely, there being no periodic update to
+ // correct it. Monotonic per process and carried per kind; the receiver keeps the highest it
+ // has applied and ignores anything at or below it.
+ [payload setObject:[NSNumber numberWithLongLong:cn1NextSurfaceMirrorSequence()]
+ forKey:@"cn1.surfaces.seq"];
+
+ // Imagery travels in the same dictionary rather than through transferFile, deliberately.
+ // A file transfer is a separate unordered queue with no atomicity against the descriptor, so
+ // a complication could render against art that had not landed yet -- worse than a gap.
+ if (imageNames != JAVA_NULL && imageBlobs != JAVA_NULL) {
+ JAVA_ARRAY names = (JAVA_ARRAY)imageNames;
+ JAVA_ARRAY blobs = (JAVA_ARRAY)imageBlobs;
+ JAVA_OBJECT *nameData = (JAVA_OBJECT *)names->data;
+ JAVA_OBJECT *blobData = (JAVA_OBJECT *)blobs->data;
+ int count = (int)(names->length < blobs->length ? names->length : blobs->length);
+ for (int i = 0; i < count; i++) {
+ if (nameData[i] == JAVA_NULL || blobData[i] == JAVA_NULL) {
+ continue;
+ }
+ NSString *name = toNSString(CN1_THREAD_STATE_PASS_ARG nameData[i]);
+ JAVA_ARRAY blob = (JAVA_ARRAY)blobData[i];
+ if (name == nil || blob->length <= 0) {
+ continue;
+ }
+ [payload setObject:[NSData dataWithBytes:blob->data length:(NSUInteger)blob->length]
+ forKey:[@"cn1.surfaces.img." stringByAppendingString:name]];
+ }
+ }
+
+ NSData *encoded = [NSPropertyListSerialization dataWithPropertyList:payload
+ format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil];
+ if (encoded == nil || [encoded length] > CN1_SURFACES_MIRROR_MAX_BYTES) {
+ // Shed the imagery first: a complication that renders its numbers with a missing glyph
+ // is worth more than one that never updates.
+ NSMutableDictionary *lean = [NSMutableDictionary dictionary];
+ [lean setObject:[payload objectForKey:@"cn1.surfaces.kind"] forKey:@"cn1.surfaces.kind"];
+ // The sequence travels on the lean payload too, or a publication that shed its imagery
+ // would arrive unordered and could be overwritten by an older one.
+ [lean setObject:[payload objectForKey:@"cn1.surfaces.seq"] forKey:@"cn1.surfaces.seq"];
+ [lean setObject:[payload objectForKey:@"cn1.surfaces.json"] forKey:@"cn1.surfaces.json"];
+ NSData *leanEncoded = [NSPropertyListSerialization dataWithPropertyList:lean
+ format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil];
+ if (leanEncoded == nil || [leanEncoded length] > CN1_SURFACES_MIRROR_MAX_BYTES) {
+ cn1SurfacesLogOnce([@"tooBig." stringByAppendingString:kind],
+ [NSString stringWithFormat:@"widget kind \"%@\" is too large to mirror to the "
+ "watch (%lu bytes, cap %d); the watch keeps its previous timeline",
+ kind, (unsigned long)(leanEncoded == nil ? 0 : [leanEncoded length]),
+ CN1_SURFACES_MIRROR_MAX_BYTES]);
+ POOL_END();
+ return;
+ }
+ cn1SurfacesLogOnce([@"noImages." stringByAppendingString:kind],
+ [NSString stringWithFormat:@"widget kind \"%@\" exceeds the watch mirror cap with "
+ "its imagery; mirroring the layout without it", kind]);
+ payload = lean;
+ }
+
+ // The Objective-C half owns WCSession; reaching it here would duplicate its activation and
+ // delegate bookkeeping.
+ ((void (*)(id, SEL, NSDictionary *))objc_msgSend)((id)sessionClass,
+ NSSelectorFromString(@"mirrorComplicationUserInfo:"), payload);
+ POOL_END();
+}
+
+#else
+
+// On the watch the app's own publish is authoritative. Mirroring back would send a timeline the
+// phone did not ask for and, when the phone mirrored in the first place, loop.
+void com_codename1_impl_ios_IOSNative_surfacesMirrorToWatch___java_lang_String_java_lang_String_java_lang_String_1ARRAY_byte_2ARRAY(
+ CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT kindId, JAVA_OBJECT timelineJson,
+ JAVA_OBJECT imageNames, JAVA_OBJECT imageBlobs) {
+}
+
+#endif
+
+#if TARGET_OS_WATCH
+// Applies a timeline the phone mirrored across: write it into the watch's own App Group
+// container -- the one the complication extension reads -- and ask WidgetKit to re-render.
+//
+// Called from CN1WatchConnectivity's didReceiveUserInfo, which may run with no CN1 runtime at
+// all: transferCurrentComplicationUserInfo wakes the app in the background precisely to refresh a
+// complication, and starting the whole application to do a file write would bring a UI forward
+// nobody asked for. So this is plain Foundation and touches no Java.
+//
+// The layout matches what IOSSurfaceBridge writes locally, because the extension reads one
+// format and does not care which side produced it.
+BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json,
+ NSArray *imageNames, NSArray *imageBlobs) {
+ NSString *container = cn1SurfacesContainerPath();
+ if (container == nil || kind == nil || json == nil) {
+ return NO;
+ }
+ NSString *kindDir = [[container stringByAppendingPathComponent:@"cn1surfaces"]
+ stringByAppendingPathComponent:kind];
+ NSFileManager *fm = [NSFileManager defaultManager];
+ NSError *err = nil;
+ if (![fm createDirectoryAtPath:kindDir withIntermediateDirectories:YES
+ attributes:nil error:&err]) {
+ NSLog(@"[CN1Surfaces] could not prepare the mirrored surface directory: %@", err);
+ return NO;
+ }
+ // Imagery first, so the descriptor is never live against art that has not landed. Names are
+ // content hashes, so an unchanged image rewrites identical bytes.
+ for (NSUInteger i = 0; i < [imageNames count] && i < [imageBlobs count]; i++) {
+ NSString *name = [imageNames objectAtIndex:i];
+ if ([name rangeOfString:@"/"].location != NSNotFound) {
+ // A name is a hash, never a path. Refusing one that looks like a path keeps a
+ // malformed payload from writing outside the kind's own directory.
+ continue;
+ }
+ if (![[imageBlobs objectAtIndex:i]
+ writeToFile:[kindDir stringByAppendingPathComponent:
+ [name stringByAppendingString:@".png"]]
+ atomically:YES]) {
+ // The descriptor is NOT installed. Writing it anyway would make a timeline live
+ // against art that is not there -- a hole in the complication -- and the collection
+ // that follows would then delete whatever the previous descriptor was still using,
+ // so the watch would end up worse off than if nothing had arrived. Leaving the old
+ // timeline in place keeps a complete surface on the face, and the next publish or
+ // reload sends the whole set again.
+ NSLog(@"[CN1Surfaces] could not store mirrored image \"%@\" for \"%@\"; keeping the "
+ "previous timeline", name, kind);
+ return NO;
+ }
+ }
+ if (![json writeToFile:[kindDir stringByAppendingPathComponent:@"timeline.json"]
+ atomically:YES]) {
+ NSLog(@"[CN1Surfaces] could not write the mirrored timeline for \"%@\"", kind);
+ return NO;
+ }
+ // AFTER the replacement document is in place, so an extension rendering concurrently re-reads
+ // the new timeline before its art can disappear -- the same order IOSSurfaceBridge uses for a
+ // local publish. Without this the mirror had no collection at all: blob names are content
+ // hashes, so every changed image left its predecessor in the App Group container for ever,
+ // and a container that only grows is a watch app that eventually cannot write.
+ //
+ // The reference set is the document's own "images" list, not the blobs that arrived in this
+ // message. A mirror only ships art the watch has not seen, so the transferred names are a
+ // subset and collecting against them would delete the images being kept.
+ NSError *parseErr = nil;
+ id doc = [NSJSONSerialization JSONObjectWithData:json options:0 error:&parseErr];
+ if ([doc isKindOfClass:[NSDictionary class]]) {
+ id names = [(NSDictionary *)doc objectForKey:@"images"];
+ NSMutableSet *referenced = [NSMutableSet set];
+ if ([names isKindOfClass:[NSArray class]]) {
+ for (id name in (NSArray *)names) {
+ [referenced addObject:[NSString stringWithFormat:@"%@", name]];
+ }
+ }
+ for (NSString *entry in [fm contentsOfDirectoryAtPath:kindDir error:NULL]) {
+ if (![[entry pathExtension] isEqualToString:@"png"]) {
+ continue;
+ }
+ if (![referenced containsObject:[entry stringByDeletingPathExtension]]) {
+ [fm removeItemAtPath:[kindDir stringByAppendingPathComponent:entry] error:NULL];
+ }
+ }
+ } else {
+ NSLog(@"[CN1Surfaces] could not read the mirrored timeline of \"%@\" to collect its "
+ "images: %@", kind, parseErr);
+ }
+ Class bridge = cn1SurfacesBridgeClass();
+ if (bridge != nil) {
+ ((void (*)(id, SEL, NSString *))objc_msgSend)((id)bridge,
+ NSSelectorFromString(@"reloadTimelines:"), kind);
+ }
+ return YES;
}
+#endif
JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
if (@available(iOS 14.0, *)) {
@@ -15420,6 +15890,13 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THR
}
JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ // Live activities are an iOS capability: watchOS has no ActivityKit, and the Swift bridge
+ // compiles its ActivityKit bodies out there. Answering here rather than relying on that
+ // states the intent -- and keeps the symbol, which the watch slice still links because the
+ // Java method is reachable from shared code.
+#if TARGET_OS_WATCH
+ return JAVA_FALSE;
+#else
if (@available(iOS 16.1, *)) {
POOL_BEGIN();
BOOL supported = NO;
@@ -15433,6 +15910,7 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(CN1_
return supported ? JAVA_TRUE : JAVA_FALSE;
}
return JAVA_FALSE;
+#endif
}
#else // CN1_USE_WIDGETS
@@ -15453,6 +15931,8 @@ void com_codename1_impl_ios_IOSNative_surfacesUpdateActivity___java_lang_String_
}
void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_java_lang_String_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityId, JAVA_OBJECT finalStateJson, JAVA_BOOLEAN dismissImmediately) {
}
+void com_codename1_impl_ios_IOSNative_surfacesMirrorToWatch___java_lang_String_java_lang_String_java_lang_String_1ARRAY_byte_2ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT kindId, JAVA_OBJECT timelineJson, JAVA_OBJECT imageNames, JAVA_OBJECT imageBlobs) {
+}
JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
return JAVA_FALSE;
}
@@ -16011,6 +16491,28 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_intentsIndexingSupported___R_boole
#import "CN1WatchConnectivity.h"
+#if TARGET_OS_WATCH
+// Brings the session up on the watch without anyone asking for it.
+//
+// Every other route into CN1WatchConnectivity is a wearable native, so the session is activated
+// lazily the first time the app touches com.codename1.wearable. An app that declares watch
+// surfaces and never touches that API takes no such route: the delegate is never installed, the
+// WCSession is never activated, and didReceiveUserInfo: therefore cannot fire -- which is exactly
+// the surfaces-only configuration the phone-to-watch mirror exists to serve. Nothing reports it,
+// because the phone half sends successfully into a session that has no listener.
+//
+// Called from the generated app delegate's applicationDidFinishLaunching, NOT from initVM.
+// A mirrored complication update wakes a terminated watch app in the background, where the
+// SwiftUI root view is not guaranteed to appear -- so CN1WatchHost.startWithWidth() may never
+// run and initVM with it. Activating there left the session unreachable in exactly the launch
+// this transport causes.
+//
+// The accessor activates on first use, so asking for it is the whole job.
+void cn1_watch_activate_connectivity(void) {
+ [CN1WatchConnectivity shared];
+}
+#endif
+
// 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.
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
index b79db860fe6..5e75e8820bf 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
@@ -1202,6 +1202,28 @@ native void walletExtensionAddPassEntry(boolean remote, String identifier, Strin
*/
native void surfacesEndActivity(String activityId, String finalStateJson, boolean dismissImmediately);
+ /**
+ * Mirrors a published timeline to the paired watch, when the kind declares a complication
+ * family and this build has a watch app to receive it.
+ *
+ *
An App Group container is device-local -- the watch resolves the same identifier to a
+ * directory of its own -- so a phone-side publish is invisible to a complication until it
+ * travels. This is that transport. It is budgeted and best-effort by nature: the native
+ * degrades through progressively weaker delivery and finally to nothing, logging once at
+ * each step, because a failed mirror must never break the publish that already succeeded
+ * locally.
+ *
+ *
A no-op on a build with no watch app, on a kind with no watch family, and on the watch
+ * itself -- where the app's own publish is authoritative and mirroring back would loop.
+ *
+ * @param kindId the widget kind
+ * @param timelineJson the serialized timeline
+ * @param imageNames names of the images the timeline references, may be empty
+ * @param imageBlobs the corresponding PNG bytes, parallel to imageNames
+ */
+ native void surfacesMirrorToWatch(String kindId, String timelineJson,
+ String[] imageNames, byte[][] imageBlobs);
+
/** True when this build/device can render WidgetKit widgets (iOS 14+, app group resolvable). */
native boolean surfacesWidgetsSupported();
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java
index 48278f5dccc..679b5ea4a58 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java
@@ -87,6 +87,10 @@ public void registerWidgetKind(String kindJson) {
}
}
+ // The container write and the watch hand-off below are one operation, and they are safe to
+ // write as one because Surfaces serializes publishes of a kind against each other. Nothing
+ // here re-establishes that: interleave two of these and the later write pairs with the
+ // earlier hand-off, leaving the watch on a descriptor the phone has replaced.
public void publishWidgetTimeline(String kindId, String timelineJson,
Map images) {
String container = containerPath();
@@ -106,10 +110,156 @@ public void publishWidgetTimeline(String kindId, String timelineJson,
return;
}
nativeInstance.surfacesReloadTimelines(kindId);
+ // The STORE's artwork, not the side-map this publish happened to carry. A SurfaceImage
+ // built from a previously registered name references a blob without shipping it, so the
+ // map is empty while the descriptor still names art -- and a watch installed since that
+ // art was first published rendered a gap until something else forced a full re-send. The
+ // container write above has already run, so what is on disk is exactly the referenced
+ // set.
+ mirrorToWatch(kindId, timelineJson, storedImages(container + "/cn1surfaces/" + kindId));
+ }
+
+ /// The image blobs a kind has in its container, keyed by the name its descriptor references.
+ ///
+ /// Read back rather than remembered, because a reload can be far from the publish that
+ /// produced them and the container is where they live in the meantime.
+ private Map storedImages(String kindDir) {
+ Map out = new java.util.LinkedHashMap();
+ try {
+ String[] files = fs.listFiles(kindDir);
+ if (files == null) {
+ return out;
+ }
+ for (String name : files) {
+ if (name == null || !name.endsWith(".png")) {
+ continue;
+ }
+ // PER FILE. One unreadable blob -- a concurrent publish removing it between the
+ // listing and the read is the ordinary way -- used to abandon the enumeration, so
+ // every image after it was dropped too. The descriptor then went to the watch
+ // with a partial map, and a watch installing it fresh showed gaps for artwork
+ // that was perfectly readable, until some later publish happened to fix it.
+ try {
+ java.io.InputStream in = fs.openInputStream(kindDir + "/" + name);
+ try {
+ byte[] blob = com.codename1.io.Util.readInputStream(in);
+ out.put(name.substring(0, name.length() - 4), blob);
+ } finally {
+ in.close();
+ }
+ } catch (Throwable oneBlob) {
+ // A blob that cannot be read is a gap in that image, not in the rest.
+ Log.e(oneBlob);
+ }
+ }
+ } catch (Throwable t) {
+ // The listing itself failed, which is the only thing left that can reach here.
+ Log.e(t);
+ }
+ return out;
+ }
+
+ /// Forwards a published timeline to the paired watch, when this build has a watch app and
+ /// the kind declares a complication family.
+ ///
+ /// An App Group container is device-local: the watch resolves the same identifier to a
+ /// directory of its own, which nothing on the phone can write. So a phone-side publish is
+ /// invisible to a complication unless the descriptor travels, and this is where it does.
+ ///
+ /// Which kinds are worth sending is decided at build time and read from the app's plist by
+ /// the native, so a publish of a phone-only kind costs one dictionary lookup. The native is
+ /// also a no-op in a build with no watch app, and on the watch itself -- where the app's own
+ /// publish is authoritative and mirroring back would loop.
+ ///
+ /// Best-effort by contract, and deliberately after the local write: this call cannot fail in
+ /// a way that leaves the phone's own widget wrong.
+ private void mirrorToWatch(String kindId, String timelineJson, Map images) {
+ String[] names;
+ byte[][] blobs;
+ if (images == null || images.isEmpty()) {
+ names = new String[0];
+ blobs = new byte[0][];
+ } else {
+ names = new String[images.size()];
+ blobs = new byte[images.size()][];
+ int i = 0;
+ for (Map.Entry e : images.entrySet()) {
+ names[i] = e.getKey();
+ blobs[i] = e.getValue();
+ i++;
+ }
+ }
+ try {
+ nativeInstance.surfacesMirrorToWatch(kindId, timelineJson, names, blobs);
+ } catch (Throwable t) {
+ // The timeline is already persisted and the phone's widget already reloaded. A watch
+ // that does not hear about it is a degraded surface, not a failed publish.
+ Log.e(t);
+ }
}
public void reloadWidgets(String kindId) {
nativeInstance.surfacesReloadTimelines(kindId == null ? "" : kindId);
+ // ...and the paired watch, which surfacesReloadTimelines does not reach: it drives
+ // WidgetCenter, and a complication lives in another bundle on another device with its own
+ // copy of the descriptor. A reload means "draw what you already hold again", and the only
+ // way to ask for that across the pairing is to hand the descriptor over again. No images:
+ // their names are content hashes, so whatever it references is already beside it.
+ String container = containerPath();
+ if (container == null) {
+ return;
+ }
+ if (kindId != null) {
+ remirror(container, kindId);
+ return;
+ }
+ try {
+ String[] kinds = fs.listFiles(container + "/cn1surfaces");
+ if (kinds == null) {
+ return;
+ }
+ for (String kind : kinds) {
+ if (kind == null) {
+ continue;
+ }
+ // listFiles returns child names; a directory carries a trailing slash on some
+ // ports.
+ String bare = kind.endsWith("/") ? kind.substring(0, kind.length() - 1) : kind;
+ if (bare.length() > 0) {
+ remirror(container, bare);
+ }
+ }
+ } catch (IOException e) {
+ // Nothing published yet, most likely. A reload-all that cannot enumerate has nothing
+ // to forward.
+ Log.e(e);
+ }
+ }
+
+ /// Sends a kind's stored descriptor to the watch again. Silent when nothing was published:
+ /// there is then nothing for the watch to redraw.
+ private void remirror(String container, String kindId) {
+ try {
+ String path = container + "/cn1surfaces/" + kindId + "/timeline.json";
+ if (!fs.exists(path)) {
+ return;
+ }
+ java.io.InputStream in = fs.openInputStream(path);
+ byte[] json = com.codename1.io.Util.readInputStream(in);
+ in.close();
+ if (json.length > 0) {
+ // With the artwork, not without it. A reload is also how a watch app installed
+ // AFTER the publish gets its first copy of anything, and a descriptor whose
+ // content-hash images have never existed on that device renders as permanent gaps
+ // until the app happens to publish again.
+ mirrorToWatch(kindId, new String(json, "UTF-8"),
+ storedImages(container + "/cn1surfaces/" + kindId));
+ }
+ } catch (Throwable t) {
+ // A watch that does not hear about a reload keeps showing the same content, which is
+ // what a reload would have redrawn: this is a refresh, not a change.
+ Log.e(t);
+ }
}
public int getInstalledWidgetCount(String kindId) {
diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc
index a302964ab6f..7091edecbb8 100644
--- a/docs/developer-guide/External-Surfaces.asciidoc
+++ b/docs/developer-guide/External-Surfaces.asciidoc
@@ -23,7 +23,7 @@ Platform widget galleries are compiled into the native app, so widget kinds must
include::../demos/common/src/main/snippets/developer-guide/external-surfaces.json[tag=external-surfaces-json-001,indent=0]
----
-The `id` values must match `[a-z][a-z0-9_]*`. The `iosFamilies` list accepts both the portable names (`small`, `medium`, `large`, `lockscreen`) and the WidgetKit spellings (`systemSmall`, `systemMedium`, `systemLarge`, `accessoryRectangular`); when omitted, all three home-screen sizes are offered. The `androidMinWidthDp` / `androidMinHeightDp` / `androidResizeMode` fields fill the Android provider metadata. An optional top-level `appGroup` pins the iOS App Group id, and `"liveActivities": true` enables the live activity plumbing.
+The `id` values must match `[a-z][a-z0-9_]*`. The `families` list accepts the portable names (`small`, `medium`, `large`, `lockscreen`, and the four `watch*` complication families) as well as the WidgetKit spellings (`systemSmall`, `systemMedium`, `systemLarge`, `accessoryRectangular`); when omitted, all three home-screen sizes are offered. `iosFamilies` is the older spelling of the same key and still works -- it predates there being a second platform that cared -- and is read only when `families` is absent. The `androidMinWidthDp` / `androidMinHeightDp` / `androidResizeMode` fields fill the Android provider metadata. An optional top-level `appGroup` pins the iOS App Group id, and `"liveActivities": true` enables the live activity plumbing.
At runtime, mirror the manifest by registering each kind in your app's `init()`:
@@ -56,7 +56,7 @@ image::img/surfaces-sample-form.png[The SurfacesSample main form with publish an
==== Previewing in the simulator
-Open *Widgets > Widgets Preview* in the simulator. The window lists your registered kinds, renders the published timeline of the selected kind at any size in light or dark mode, flips timeline entries on schedule, and ticks countdowns exactly as a home-screen widget would. The mock Dynamic Island at the bottom renders running live activities. Clicks map through to your action handler, and a desktop (non-simulator) build renders the same publishes as floating widget windows pinned from a tray icon.
+Open *Widgets > Widgets Preview* in the simulator. The window lists your registered kinds, renders the published timeline of the selected kind at any size in light or dark mode, flips timeline entries on schedule, and ticks countdowns exactly as a home-screen widget would. The four watch complication families are listed too, at the accessory families' own sizes and clipped round where a watch face clips them -- worth seeing, because a face shows nothing a circular complication draws into its corners. What it previews is the node tree, not the per-platform lowering, so a complication that looks right there can still arrive on a Wear OS face as one number. The mock Dynamic Island at the bottom renders running live activities. Clicks map through to your action handler, and a desktop (non-simulator) build renders the same publishes as floating widget windows pinned from a tray icon.
=== The node catalog
@@ -179,6 +179,61 @@ Widget taps deep link back into the app through the `cn1surface://` URL scheme,
Widgets are rendered through `RemoteViews` by generated per-kind providers; no Android-specific build hints are needed, and the per-kind sizing metadata comes from `surfaces.json`. Timeline entry flips are scheduled with inexact alarms (a 30-second window) to avoid the exact-alarm permission by default; apps that need to-the-second flips can opt in with the `android.surfaces.exactAlarms` build hint. Second-precision countdowns still tick natively through `Chronometer`. Live activities lower to ongoing notifications, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android stops showing the dialog after two refusals anyway -- and spends an attempt only on a request it managed to issue. Android reports a dismissed dialog exactly as it reports a refusal, so dismissing one does cost an attempt, but one rather than the whole budget. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt. `LiveActivity.isSupported()` is the programmatic signal once the answer has settled -- a spent budget or, for an app that never declared the permission, a missing manifest entry -- and `adb logcat -s CN1Surfaces` explains every refusal, reporting each settled reason once. A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps.
+==== Apple Watch
+
+A kind declaring a `WATCH_*` family gets a second WidgetKit extension,
+`CN1WatchWidgets`, embedded in the watch app rather than the phone app. It needs
+watchOS 10 (`watchNative.surfaces.deploymentTarget` overrides the floor), and it
+shares the App Group identifier with the phone -- though the container behind it
+is watch-local, which is why the watch publishes its own timelines. Under manual
+signing the extension's bundle id, `.watchkitapp.CN1WatchWidgets`,
+needs its own provisioning profile; the generic
+`ios.appext.CN1WatchWidgets.provisioningURL` hint supplies it.
+
+==== Wear OS
+
+A watch-bearing kind gets a `ComplicationDataSourceService`, and the
+`WATCH_RECTANGULAR` family additionally gets a `TileService`. Both are generated
+into the Wear module -- the single APK in a standalone build, the `wear` module
+in a companion one -- and pull in the `androidx.wear` complication and Tile
+libraries, which are AndroidX-only and raise that module's `minSdkVersion` to 26.
+The phone module's floor is untouched.
+
+The node catalog maps as follows. A complication is the lossy one: the face asks
+for a typed value and composes it itself.
+
+[cols="1,2,2"]
+|===
+|Node |Complication |Tile
+
+|`SurfaceText` / `SurfaceDynamicText`
+|First two nodes only, as text and title
+|Rendered, but a countdown is frozen and refreshed on timeline flips
+
+|`SurfaceImage` / `SurfaceVector`
+|First node only, as a monochrome glyph the face tints
+|Rendered as an inline image resource
+
+|`SurfaceProgress`
+|Becomes the ranged value
+|**Renders natively as an arc** -- better than the phone widget, which degrades a circular bar to linear
+
+|`SurfaceRow` / `SurfaceColumn` / `SurfaceBox`
+|Traversal order only; there is no layout to honour
+|Rendered
+
+|Padding, background, alignment, weight, colour
+|Dropped -- the face owns its design
+|Rendered
+
+|Actions
+|Root action only, as the complication tap
+|**Per-node actions work** -- better than a small iOS widget
+|===
+
+Everything a complication drops is logged once per render; `adb logcat -s
+CN1Surfaces` shows what a face is actually displaying.
+
==== Desktop, Windows, and Linux
In a desktop build the app shows a tray icon whose menu pins a floating widget per kind: a frameless, always-on-top window rendering the published timeline, with clicks dispatched to your action handler. Window positions and the pinned set persist across runs, and a running live activity docks a pill window at the top of the primary screen. Desktop widgets are process-bound in this release -- they exist while the app process runs. On Windows the plain signed executable ships these layered floating widgets with zero packaging; setting `windows.msix=true` additionally wraps the build in an MSIX package that declares a Windows 11 Widgets Board provider, so your kinds appear in the Win+W board rendered as Adaptive Cards. The MSIX channel is opt-in because it has real distribution prerequisites: a certificate the target machine trusts, the Windows App Runtime redistributable on the target machine, and Windows 11 for the board itself. On Linux the widgets are frameless GTK applet windows; on Wayland compositors that support the layer-shell protocol (KDE Plasma, Sway and the rest of the `wlroots` family) a runtime-loaded `gtk-layer-shell` places widgets above the wallpaper as real desktop applets with persistent positions and drag-to-move, and on GNOME Wayland they degrade to plain floating windows because the compositor controls global positioning and keep-above.
@@ -196,6 +251,12 @@ In a desktop build the app shows a tray icon whose menu pins a floating widget p
| `ios.debug.appext.CN1Widgets.provisioningURL` / `ios.release.appext.CN1Widgets.provisioningURL` | | Build-type-specific variants: the URL of the development profile used by debug device builds and the URL of the distribution profile used by release builds. The variant matching the build target overrides the unqualified hint
| `ios.background_modes` | | Add `fetch` so background fetch can re-publish timelines on device
| `android.surfaces.exactAlarms` | `false` | Schedule widget timeline entry flips with exact alarms; declares `SCHEDULE_EXACT_ALARM` and falls back to the inexact 30-second window when the user revokes the special app access
+| `watchNative.surfaces.deploymentTarget` | `10.0` | Deployment target of the watchOS complication extension. Can't go below 10.0: the container background every generated widget applies is watchOS 10, so a lower floor fails the build rather than losing the background
+| `ios.appext.CN1WatchWidgets.provisioningURL` | | URL of the watch complication extension's provisioning profile, for cloud manual-signing builds. The `ios.debug.` / `ios.release.` variants work as they do for `CN1Widgets`
+| `android.surfaces.complicationUpdateSeconds` | `0` | How often the system polls a Wear complication data source. Zero means never: the timeline model is push-driven, so a poll spends watch battery asking a question the app has already answered
+| `android.wear.complicationsVersion` / `android.wear.tilesVersion` / `android.wear.protoLayoutVersion` | pinned | Override the `androidx.wear` library versions the generated complication and Tile services compile against
+| `android.watchModule` | `true` | Set to `false` to keep the wearable link without generating a companion Wear module; the phone build is then unchanged
+| `android.watchVersionCode` / `android.watchVersionCodeOffset` | `+100000000` | The Wear artifact's version code, which must outrank the phone's for Play to pick it on a watch. The offset is wide so the phone's next release can't catch up to a watch code already published
| `windows.msix` | `false` | Wrap the Windows build in an MSIX package with a Widgets Board provider
| `windows.msix.identityName` | package name | MSIX package identity name
| `windows.msix.publisher` | `CN=` | MSIX identity publisher; must match the signing certificate subject
@@ -208,4 +269,6 @@ In a desktop build the app shows a tray icon whose menu pins a floating widget p
* Updates originate from the app (timelines, background fetch, live activity updates). Server-pushed widget content and ActivityKit push tokens are planned; the wire format already accommodates them.
* The node catalog is intentionally the lowest common denominator -- there is no arbitrary per-pixel drawing beyond `SurfaceVector`, and no embedding of regular Codename One components.
* `WidgetSize.LOCKSCREEN` maps to the iOS `accessoryRectangular` family and is ignored on Android in this release.
+* A kind declaring only `WATCH_*` families produces no phone surface on either platform -- those kinds are hosted by the watch. Declare a phone family alongside them if you want both.
+* What a watch face shows is narrower than what you laid out, and on Wear OS much narrower. See the wearables chapter for the per-family mapping and what gets dropped.
* The Widgets Board provider requires the `windows.msix` opt-in and its distribution prerequisites; without it, Windows desktop widgets are floating windows.
diff --git a/docs/developer-guide/Wearables.asciidoc b/docs/developer-guide/Wearables.asciidoc
index 689d2359a33..b320ecda4ce 100644
--- a/docs/developer-guide/Wearables.asciidoc
+++ b/docs/developer-guide/Wearables.asciidoc
@@ -57,10 +57,10 @@ there is no phone app to pair with, declare it standalone:
include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-002,indent=0]
----
-Wear OS has no companion form yet. An Android project that sets a watch main
-class without `codename1.watchStandalone` builds the phone APK alone -- the
-build says so -- so a Wear app has to be declared standalone today. Apple Watch
-supports both.
+Both platforms support both forms. On Apple a companion build embeds the watch
+app inside the iOS app so the pair installs together; on Android it produces a
+second artifact, `-wear.apk`, beside the phone one. A standalone build
+on either platform ships the watch app on its own.
On Android a standalone build turns the single APK into the Wear OS app, and that
is what ships. On Apple the watch target is built standalone -- detached from the
@@ -276,16 +276,62 @@ home here, because most complications are a gauge, a dial or a ring.
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.
+==== What a Watch Face Actually Shows
+[[watch-complication-fidelity]]
+
+This is the part that surprises people, so it's worth stating plainly: **a
+complication isn't a small widget.** A watch face asks your data source for one
+typed value -- a short string, a long string, a ranged value, a monochrome glyph
+-- and composes it into its own design. There is no layout to honour.
+
+The node tree you publish is therefore flattened and *mined for content* rather
+than rendered. On Wear OS your kind supplies at most two text nodes and one image;
+containers, padding, background, corner radius, alignment, weight, per-node
+colour, and every action except the root, all belong to the face. Everything
+dropped is reported once per render, so `adb logcat -s CN1Surfaces` tells you
+what a face is showing and what it leaves out.
+
+Apple is less lossy, because a WidgetKit accessory family renders your SwiftUI
+tree -- but the slot is still tiny and monochrome, and the same design advice
+applies.
+
+A Tile is the exception. It renders the node tree in full, and two things come
+out *better* there than on a phone widget:
+
+* **Circular progress renders natively.** The Android home-screen widget has to
+ degrade a circular bar to a linear one; a Tile doesn't.
+* **Per-node tap actions work.** A small iOS widget honors only the root action.
+
+The Tile's own limitation is time: a `SurfaceDynamicText` countdown ticks
+natively on both phone platforms, but freezes on a Tile and refreshes when your
+timeline says the value changes. ProtoLayout can animate one, but only on some
+Wear releases -- a frozen value that's always right beats a ticking one that
+works on some watches.
+
+TIP: Preview the watch families in the simulator (*Widgets > Widgets Preview*)
+before you build to a device. It renders the node tree at the right size and
+clips the round families the way a face does -- which is worth seeing, because a
+watch face shows nothing a circular complication draws into its corners. It
+can't show you the per-platform lowering, though, so a layout that looks right
+there can still arrive on Wear OS as one number.
+
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.
+Declaring a watch family is all it takes. On Apple the build adds a second
+WidgetKit extension, `CN1WatchWidgets`, embedded in the watch app; on Wear OS it
+generates a complication data source per kind, plus a Tile for the rectangular
+family. Both are additive: an app that declares no watch family carries neither.
+
+IMPORTANT: A kind that declares *only* watch families no longer produces a
+home-screen widget on Android. It never produced an iPhone one, and rendering a
+complication as a home-screen widget puts a surface in front of the user that the
+manifest never asked for. Declare a phone family alongside the watch ones if you
+want both.
+
+The one thing to know before you design: **what a watch face shows isn't what
+you laid out**. See <>.
=== Apple Watch (watchOS)
@@ -393,14 +439,25 @@ A Wear OS app is a regular Android app. The Codename One Android port renders th
UI with the same pipeline it uses on phones, so no special rendering backend is
required. The same `codename1.watchMain` declaration drives both platforms.
-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.
+What it produces differs from Apple, 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 you get two
+artifacts -- `.apk` for the phone and `-wear.apk` for the
+watch -- because a Wear companion is a separate product published to the same
+Play listing, where an Apple one is embedded inside the phone app.
+
+The Wear artifact carries a higher version code than the phone's. On a watch Play
+picks among the APKs the device supports by version code, so the wear one has to
+outrank it; on a phone the required watch feature filters the wear APK out
+entirely. The default is the phone's code plus 100,000,000, which sounds
+extravagant and isn't: the gap has to be wide enough that the phone's own
+next release never catches up to a watch code it already published, and
+plus one is consumed by the next phone build.
+`android.watchVersionCodeOffset` changes the gap and
+`android.watchVersionCode` sets the watch code outright.
+
+Set `android.watchModule=false` if you want the wearable link but no watch app of
+your own -- your phone build is then exactly what it was.
A standalone Wear app declares the watch hardware feature in the manifest:
@@ -442,6 +499,50 @@ dependency and the listener service automatically. The
`android.playService.wearable` hint remains for apps that want to call the Data
Layer APIs directly.
+=== Feeding a Complication from the Phone
+[[watch-complication-mirror]]
+
+A watch app has its own storage. Nothing the phone writes is visible there, on
+either platform -- on Apple the App Group identifier is the same string but
+resolves to a watch-local container, and on Wear OS the two apps are separate
+installs. That's the single most counter-intuitive fact here, and it's got
+one consequence: **a complication is fed by the watch's own
+`Surfaces.publish()`.**
+
+Which is often inconvenient, because the data usually lives on the phone. A
+phone-side publish of a watch-bearing kind is therefore mirrored to the watch for
+you, over the same link `com.codename1.wearable` uses. You write the same
+`Surfaces.publish(...)` you always did.
+
+The mirror is best-effort by design, and always runs *after* the local publish
+has succeeded -- nothing it does can leave your phone widget wrong:
+
+* *Apple* uses the one WatchConnectivity API that wakes the watch app in the
+ background to refresh a complication. It's budgeted at about fifty transfers
+ a day. When the user has placed no complication, or the budget is spent, the
+ update is queued instead and applied when the watch app next runs.
+* *Wear OS* replicates the descriptor over the Data Layer, which starts the watch
+ app's process to deliver it. Imagery travels as a file transfer.
+* *Size* is capped -- 48KB on Apple, and on Wear OS 64KB for the descriptor with
+ its own cap on imagery. Over the cap the imagery is dropped first, on the
+ grounds that a complication rendering its numbers with a missing glyph beats one
+ that never updates; over the cap even then, the watch keeps its previous
+ timeline.
+
+Every refusal is logged once. Nothing throws.
+
+NOTE: The mirror is applied on the watch without starting your application: it
+writes the descriptor and asks the watch face to re-read. The wake exists to
+refresh a complication, not to bring a UI forward the user didn't ask for.
+
+The reserved path `/cn1surface/` belongs to the framework on Wear OS --
+don't publish your own data there.
+
+IMPORTANT: Declaring a watch family on Android adds `play-services-wearable` to
+your *phone* APK, because that's what carries the mirror. An app that wants
+complications fed only by the watch itself can avoid that by not declaring watch
+families on kinds the phone publishes.
+
=== Summary
[cols="1,2,2"]
@@ -458,7 +559,7 @@ Layer APIs directly.
|Distribution
|Companion (embedded in the phone app) or standalone
-|Standalone only -- companion doesn't yet produce a Wear artifact
+|Companion (a second `-wear` artifact) or standalone
|Runtime detection
|`CN.isWatch()`
@@ -468,15 +569,15 @@ Layer APIs directly.
|`com.codename1.wearable` over WatchConnectivity
|`com.codename1.wearable` over the Wearable Data Layer
-|Complications (no target generated yet)
-|WidgetKit accessory families, declarable only
-|Complication data source and Tiles, declarable only
+|Complications
+|A WidgetKit extension embedded in the watch app
+|A complication data source per kind, plus a Tile for the rectangular family
|===
-The complication row describes where each platform's watch surfaces will come
-from, not something you can ship today. You can declare the watch families on a
-surface kind, and nothing builds a complication or tile from them yet -- see
-<> for what that means in practice.
+Declare a `WATCH_*` family on a surface kind and the build generates whatever
+that platform needs. What a watch face then *shows* is narrower than what you laid
+out, on Wear OS especially -- see <> before you
+design one.
The wearable build is additive on both platforms: without a watch main class,
your phone builds are unchanged.
diff --git a/docs/website/content/blog/native-apple-watch-and-wear.md b/docs/website/content/blog/native-apple-watch-and-wear.md
index 322163bc9f2..e07cd159833 100644
--- a/docs/website/content/blog/native-apple-watch-and-wear.md
+++ b/docs/website/content/blog/native-apple-watch-and-wear.md
@@ -85,13 +85,9 @@ If you want a distinct watch entry point rather than reusing your phone main cla
codename1.watchMain=com.mycompany.myapp.MyWatchMain
```
-On Android, one hint marks the build as a Wear OS app, which injects the watch hardware feature, declares the app standalone, and raises the minimum SDK to the Wear OS standalone baseline:
+On Android the same `codename1.watchMain` declaration drives the build. Adding `codename1.watchStandalone=true` makes the single APK the watch app itself -- injecting the watch hardware feature, declaring the app standalone and raising the minimum SDK to the Wear OS standalone baseline. Leave it unset and you get a phone APK and a companion Wear APK beside it.
-```properties
-android.wear=true
-```
-
-A project can target both platforms at once by setting the watch hint and `android.wear=true` together.
+> **Update:** this post originally described an `android.wear=true` hint. That hint is retired: `codename1.watchMain` and `codename1.watchStandalone` now drive both platforms from one declaration. The old hint still works for projects that have not migrated.
## What runs on the watch, and what does not
diff --git a/docs/website/content/blog/native-linux-apple-watch-game-builder-crash-protection.md b/docs/website/content/blog/native-linux-apple-watch-game-builder-crash-protection.md
index d71e293c96f..3203f6553fc 100644
--- a/docs/website/content/blog/native-linux-apple-watch-game-builder-crash-protection.md
+++ b/docs/website/content/blog/native-linux-apple-watch-game-builder-crash-protection.md
@@ -32,7 +32,7 @@ The answer is that reuse still happens. Many well known apps skip the watch enti
That is a screenshot from our test framework, which was never designed for a watch: it still has a text field. Because that is a Codename One text field it renders correctly and "just works" right up until you try to edit in it, which on a watch would not give the result you want; a real watch UI would simply leave it out.
-Wear OS is simpler: a Wear OS app is an ordinary Android app, so the existing Android port renders it with the same pipeline it uses on phones. You enable each side with one build hint, and with the hints off your phone build is byte-for-byte unchanged. Both wearables are covered in detail in {{< post-link path="/blog/native-apple-watch-and-wear" text="Sunday's post" >}}.
+Wear OS is simpler: a Wear OS app is an ordinary Android app, so the existing Android port renders it with the same pipeline it uses on phones. You enable both sides with one declaration -- `codename1.watchMain` -- and without it your phone build is byte-for-byte unchanged. Both wearables are covered in detail in {{< post-link path="/blog/native-apple-watch-and-wear" text="Sunday's post" >}}.
## A visual Game Builder
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java
index 81807b0973b..adec679052d 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
@@ -55,6 +55,7 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
@@ -312,6 +313,51 @@ 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;
+ /// The kinds declaring a watch complication family, as {id, label, comma-joined families}.
+ ///
+ /// Collected while the surfaces manifest is parsed and consumed after the module layout is
+ /// known, because where the generated services go -- the phone module or a separate wear one
+ /// -- depends on the distribution and there is only one code path for both.
+ private final List watchSurfaceKinds = new ArrayList();
+ /// The androidx.wear dependency block, which belongs to the WATCH module alone.
+ ///
+ /// These declare minSdk 26, so adding them to a companion build's shared dependency hint
+ /// fails the phone module's manifest merge against a library it never uses.
+ private String watchSurfaceDependencies = "";
+ /// The generated phone stub's source, so the Wear module can derive its own from it.
+ private String generatedStubSource;
+ /**
+ * Permission declarations the Wear manifest needs but is not otherwise given.
+ *
+ *
The companion manifest is generated independently rather than merged, so
+ * anything the phone half computes locally has to be carried across by hand.
+ */
+ private String watchSharedPermissions = "";
+
+ /** Push service declarations the Wear manifest needs too; see the phone manifest. */
+ private String watchPushManifestEntries = "";
+
+ /** The FileProvider declaration the Wear manifest needs too. */
+ private String watchProviderTag = "";
+
+ /** The local-notification receiver the Wear manifest needs too. */
+ private String watchAlarmReceiver = "";
+
+ /** The JobScheduler service declaration the Wear manifest needs too. */
+ private String watchBackgroundWorkService = "";
+
+ /** The background-fetch handler and trampoline the Wear manifest needs too. */
+ private String watchBackgroundFetchService = "";
+
+ /** Location, geofence and foreground-service declarations the Wear manifest needs too. */
+ private String watchFeatureComponents = "";
+ /// The com.codename1.intents wiring, carried into the generated Wear manifest. See where
+ /// they are assigned for why both halves have to travel together.
+ private String watchIntentsActivityMetaData = "";
+ private String watchIntentsManifestEntries = "";
+
+ /** Audio and remote-control declarations the Wear manifest needs too. */
+ private String watchMediaComponents = "";
/// True when the app references com.codename1.intents. Gates the shortcut resources, the
/// trampoline activity and the headless service, so an app that exposes nothing to the
/// launcher carries none of them.
@@ -340,6 +386,41 @@ private static String watchMainClass(BuildRequest request) {
return request.getArg("watchMain", "").trim();
}
+ /**
+ * Which Gradle module is the Wear OS product, or null when this build produces none.
+ *
+ *
One question, answered once, because everything downstream -- where the complication
+ * services are generated, which manifest carries them, which module gets the androidx.wear
+ * dependencies -- is the same code either way and differs only in the destination.
+ *
+ *
+ *
{@code app} when the build is a standalone Wear APK: the single artifact IS the
+ * watch app.
+ *
{@code wear} for a companion build, where the watch app is a second module beside
+ * the phone one.
+ *
null when the project declares no watch lifecycle class, which is every project
+ * that has not asked for a watch.
+ *
+ *
+ * @param request the build being generated
+ * @return the module directory name, or null
+ */
+ static String watchModuleName(BuildRequest request) {
+ if (watchMainClass(request).length() == 0) {
+ return null;
+ }
+ if ("true".equals(request.getArg("watchStandalone", "false"))) {
+ return "app";
+ }
+ // A companion build generates the watch app beside the phone app. Opting out leaves the
+ // phone build exactly as it was, which is what a project that only wants the wearable
+ // link -- not a watch app -- is asking for.
+ if ("false".equals(request.getArg("android.watchModule", "true"))) {
+ return null;
+ }
+ return "wear";
+ }
+
/// Whether the new wearable declaration governs, leaving the retired android.wear hints out.
///
/// The one rule the manifest and the lifecycle selection share. They used to disagree: the
@@ -3349,53 +3430,6 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) {
// into the generated project and add the dependency. The Android port itself cannot
// reference play-services-wearable, which is why these ship as .java resources here and are
// only added for apps that talk to a watch.
- if (usesWearable) {
- File wearImpl = new File(srcDir, "com/codename1/impl/android");
- wearImpl.mkdirs();
- String[] glue = {"CN1WearableBridge.java", "CN1WearableListenerService.java"};
- for (String g : glue) {
- InputStream gin = getResourceAsStream("/com/codename1/builders/wearable/" + g);
- if (gin == null) {
- throw new BuildException("Missing wearable glue resource " + g);
- }
- try {
- copy(gin, new FileOutputStream(new File(wearImpl, g)));
- } catch (IOException ex) {
- throw new BuildException("Failed to write wearable glue " + g, ex);
- }
- }
- playServicesWear = true;
- // The capability the peer half advertises, so isCompanionAppInstalled() can tell a
- // watch running this app from a watch that merely exists.
- // resDir, NOT projectDir + "app/...". projectDir already IS the generated app module,
- // so the extra segment put this at /app/src/main/res/values -- a directory Gradle
- // never packages. The failure is silent and total: the capability is never advertised,
- // so after the first query isCompanionAppInstalled() and isReachable() answer false and
- // message fan-out filters out every valid peer as "not running the app".
- File wearValues = new File(resDir, "values");
- wearValues.mkdirs();
- try {
- createFile(new File(wearValues, "cn1_wearable.xml"),
- ("\n"
- + "\n"
- + " \n"
- + " cn1_wearable\n"
- + " \n"
- + "\n").getBytes("UTF-8"));
- } catch (IOException ex) {
- throw new BuildException("Failed to write the wearable capability declaration", ex);
- }
- }
- if (watchMainClass(request).length() > 0
- && !"true".equals(request.getArg("watchStandalone", "false"))) {
- // Say so rather than quietly producing one artifact: a companion Wear APK is not
- // generated yet (see the wearables chapter of the developer guide).
- log("[wearable] codename1.watchMain is set without codename1.watchStandalone. The "
- + "Apple Watch companion is built, but a companion Wear OS APK is not produced "
- + "yet -- set codename1.watchStandalone=true to build the watch app as the "
- + "Android product.");
- }
-
// External surfaces (com.codename1.surfaces): parse the build-time kinds manifest,
// generate one thin widget provider subclass per kind, copy the pre-baked RemoteViews
// layout/drawable resources shipped with the plugin and emit the per-kind
@@ -3409,8 +3443,17 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) {
// LAUNCHER intent filter, so this half is spliced into the main activity rather than
// sitting beside it at application level, where it would be silently ignored.
String intentsActivityMetaData = intentsShortcutsMetaData;
+ // Carried to the watch as well. The wear module compiles the same lifecycle, so
+ // AndroidIntentBridge.areIntentsSupported() answers true there and publishes shortcuts
+ // aimed at CN1IntentTrampolineActivity -- an activity that manifest never declared. The
+ // static list is read from meta-data on whichever activity carries LAUNCHER, so both
+ // halves have to travel: the meta-data into the watch launcher and the trampoline with
+ // it, or the shortcuts are advertised and then resolve to nothing.
+ watchIntentsActivityMetaData = intentsActivityMetaData;
+ watchIntentsManifestEntries = intentsManifestEntries;
String surfacesManifestEntries = "";
+ String watchSurfacesManifestEntries = "";
if (usesSurfaces) {
File surfacesJsonFile = new File(assetsDir, "surfaces.json");
if (!surfacesJsonFile.exists()) {
@@ -3440,6 +3483,32 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) {
surfaceKinds = new java.util.ArrayList
+ *
+ *
Compiled against the REAL {@code CN1SurfaceMirror} from the Android port, so the mirror hand-off
+ * the listener performs is checked against the actual signatures, and against a stub tree for the
+ * Android and Play services types. The stubs mirror the real API rather than merely satisfying the
+ * caller -- {@code MessageEvent} deliberately does NOT extend {@code Freezable} and
+ * {@code DataEvent} does -- so the tree is an executable record of the API surface this glue
+ * depends on, and a stub written to make an error disappear would be a bug in the stub.
+ */
+public class WearableGlueCompilesTest {
+
+ /** The injected files: typed against play-services-wearable, compiled by no build of ours. */
+ private static final String WEARABLE_RESOURCES =
+ "src/main/resources/com/codename1/builders/wearable";
+
+ /** The port's own mirror, so the listener is checked against the real hand-off. */
+ private static final String PORT_SURFACES =
+ "../../Ports/Android/src/com/codename1/impl/android/surfaces";
+
+ private static final String STUBS = "src/test/resources/wearable-glue-stubs";
+
+ @Test
+ void theInjectedWearableGlueCompiles(@TempDir Path tmp) throws IOException {
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ assertNotNull(javac, "these tests need a JDK, not a JRE");
+
+ File wearable = new File(WEARABLE_RESOURCES);
+ assertTrue(wearable.isDirectory(), "the injected Data Layer glue must be readable: "
+ + wearable.getAbsolutePath());
+
+ List sources = new ArrayList();
+ collectJava(wearable, sources);
+ assertTrue(sources.size() >= 2,
+ "expected the bridge and the listener service in " + WEARABLE_RESOURCES);
+
+ // The real mirror, because the listener calls straight into it and a signature drift there
+ // is exactly the kind of break this test exists to catch.
+ sources.add(new File(PORT_SURFACES, "CN1SurfaceMirror.java"));
+
+ Path stubs = tmp.resolve("stubs");
+ copyStubs(new File(STUBS).toPath(), stubs);
+ collectJava(stubs.toFile(), sources);
+ assertTrue(sources.size() > 20, "the stub tree must be there: " + STUBS);
+
+ // The mirror's collaborators are shimmed rather than compiled: they reach into the
+ // RemoteViews renderer and the wider port, and what matters here is that the glue agrees
+ // with the mirror, which the port's own build already proves for the rest.
+ Path shims = tmp.resolve("shims/com/codename1/impl/android/surfaces");
+ Files.createDirectories(shims);
+ Files.write(shims.resolve("CN1SurfaceStore.java"),
+ ("package com.codename1.impl.android.surfaces;\n"
+ + "import android.content.Context;\n"
+ + "import java.io.File;\n"
+ + "public class CN1SurfaceStore {\n"
+ + " public static File kindDir(Context c, String k) { return null; }\n"
+ + " static void deleteUnreferencedImages(File d, String t) { }\n"
+ // The grace overload the mirror uses: on the watch an unreferenced blob
+ // is either stale art or art whose descriptor has not landed, and age is
+ // what tells them apart.
+ + " static void deleteUnreferencedImages(File d, String t, long g) { }\n"
+ + " public static String readWidgetTimeline(Context c, String k) "
+ + "{ return null; }\n"
+ + " public static void rememberKind(Context c, String k) { }\n"
+ + "}\n").getBytes("UTF-8"));
+ Files.write(shims.resolve("CN1WatchSurface.java"),
+ ("package com.codename1.impl.android.surfaces;\n"
+ + "import android.content.Context;\n"
+ + "public class CN1WatchSurface {\n"
+ + " public static boolean isWatchKind(Context c, String k) "
+ + "{ return false; }\n"
+ + "}\n").getBytes("UTF-8"));
+ Files.write(shims.resolve("CN1WatchSurfaceNotifier.java"),
+ ("package com.codename1.impl.android.surfaces;\n"
+ + "import android.content.Context;\n"
+ + "public class CN1WatchSurfaceNotifier {\n"
+ + " public static void requestUpdate(Context c, String k) { }\n"
+ + "}\n").getBytes("UTF-8"));
+ // The widget provider, which the mirror now reaches for a watch's reload request. Shimmed
+ // like the rest: the real one extends AppWidgetProvider and pulls the whole RemoteViews
+ // surface in behind it, none of which this test is about.
+ Files.write(shims.resolve("CN1WidgetProvider.java"),
+ ("package com.codename1.impl.android.surfaces;\n"
+ + "import android.content.Context;\n"
+ + "public class CN1WidgetProvider {\n"
+ + " static void requestAppRefresh(Context c, String k) { }\n"
+ // The form that refuses to ask the peer, which is what an answer to a
+ // peer's own request must use or the two bounce messages for ever.
+ + " static void requestAppRefresh(Context c, String k, boolean p) { }\n"
+ + "}\n").getBytes("UTF-8"));
+ collectJava(tmp.resolve("shims").toFile(), sources);
+
+ Path out = tmp.resolve("classes");
+ Files.createDirectories(out);
+ DiagnosticCollector problems = new DiagnosticCollector();
+ StandardJavaFileManager files = javac.getStandardFileManager(problems, null, null);
+ boolean ok = javac.getTask(null, files, problems,
+ Arrays.asList("-d", out.toString(), "-nowarn", "-proc:none"),
+ null, files.getJavaFileObjectsFromFiles(sources)).call();
+ files.close();
+
+ StringBuilder errors = new StringBuilder();
+ for (Diagnostic extends JavaFileObject> d : problems.getDiagnostics()) {
+ if (d.getKind() == Diagnostic.Kind.ERROR) {
+ errors.append("\n ").append(d.getSource() == null ? "?"
+ : new File(d.getSource().toUri()).getName())
+ .append(':').append(d.getLineNumber()).append(' ')
+ .append(d.getMessage(null));
+ }
+ }
+ assertTrue(ok && errors.length() == 0,
+ "the injected Data Layer glue does not compile:" + errors);
+ }
+
+ /// Copies the stub tree, renaming each `.javas` to the `.java` javac insists on.
+ private static void copyStubs(final Path from, final Path to) throws IOException {
+ Files.walkFileTree(from, new SimpleFileVisitor() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+ throws IOException {
+ if (!file.toString().endsWith(".javas")) {
+ return FileVisitResult.CONTINUE;
+ }
+ String relative = from.relativize(file).toString();
+ Path target = to.resolve(
+ relative.substring(0, relative.length() - "s".length()));
+ Files.createDirectories(target.getParent());
+ Files.copy(file, target);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+
+ private static void collectJava(File dir, List into) {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ collectJava(child, into);
+ } else if (child.getName().endsWith(".java")) {
+ into.add(child);
+ }
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildResultArtifactRoleTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildResultArtifactRoleTest.java
new file mode 100644
index 00000000000..568eed7d941
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildResultArtifactRoleTest.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.maven;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/// A build may return more than one artifact of the same kind -- an Android companion build
+/// hands back the phone APK and the Wear APK beside it. The result extractor used to name every
+/// entry `target/<finalName><extension>`, keyed on the extension alone, so two `.apk`
+/// entries collapsed onto one path and the last one written won. That corrupts the PRIMARY
+/// artifact, not merely the secondary one, and it does it silently.
+class CN1BuildResultArtifactRoleTest {
+
+ @Test
+ void wearArtifactKeepsItsRoleSuffix() {
+ assertEquals("-wear", CN1BuildMojo.roleSuffixOf("myapp-wear"));
+ assertEquals("-wear", CN1BuildMojo.roleSuffixOf("wear-release-wear"));
+ }
+
+ /// Anything not on the closed role list is the primary artifact and must keep the plain
+ /// name it has always had, so an unrelated build's output cannot be re-routed by accident.
+ @Test
+ void everythingElseIsThePrimaryArtifact() {
+ assertEquals("", CN1BuildMojo.roleSuffixOf("myapp"));
+ assertEquals("", CN1BuildMojo.roleSuffixOf("app-release"));
+ assertEquals("", CN1BuildMojo.roleSuffixOf("wearable"));
+ assertEquals("", CN1BuildMojo.roleSuffixOf("-wearing"));
+ assertEquals("", CN1BuildMojo.roleSuffixOf(""));
+ assertEquals("", CN1BuildMojo.roleSuffixOf(null));
+ }
+
+ private static java.util.Map> returned(String... names) {
+ java.util.Map> out =
+ new java.util.HashMap>();
+ for (String name : names) {
+ int dot = name.lastIndexOf('.');
+ String ext = name.substring(dot);
+ java.util.Set bases = out.get(ext);
+ if (bases == null) {
+ bases = new java.util.HashSet();
+ out.put(ext, bases);
+ }
+ bases.add(name.substring(0, dot));
+ }
+ return out;
+ }
+
+ /// A role suffix is a claim about a set. An app named "fitness-wear" returns one APK whose
+ /// base ends in "-wear" and it IS the primary artifact -- reading the name alone copied it to
+ /// -wear.apk under a classifier and left the artifact the build was for missing.
+ @Test
+ void aLoneArtifactIsPrimaryWhateverItIsCalled() {
+ java.util.Map> one = returned("fitness-wear.apk");
+
+ assertEquals("", CN1BuildMojo.roleSuffixFor("fitness-wear", ".apk", one));
+ }
+
+ /// ...and when the phone artifact did come back, the suffixed one is the companion.
+ @Test
+ void aSuffixedArtifactBesideAPrimaryOneIsTheCompanion() {
+ java.util.Map> pair =
+ returned("myapp.apk", "myapp-wear.apk", "myapp-wear-debug.apk");
+
+ assertEquals("-wear", CN1BuildMojo.roleSuffixFor("myapp-wear", ".apk", pair));
+ assertEquals("-wear-debug", CN1BuildMojo.roleSuffixFor("myapp-wear-debug", ".apk", pair));
+ assertEquals("", CN1BuildMojo.roleSuffixFor("myapp", ".apk", pair));
+ }
+
+ /// The hard case: an app whose own name ends in the suffix AND has a companion. Nothing here
+ /// is unsuffixed, so "is there a primary" cannot separate them -- but stripping the suffix
+ /// can, because only the companion names something else in the set.
+ @Test
+ void anAppNamedWearWithACompanionKeepsBothArtifacts() {
+ java.util.Map> both =
+ returned("fitness-wear.apk", "fitness-wear-wear.apk");
+
+ assertEquals("", CN1BuildMojo.roleSuffixFor("fitness-wear", ".apk", both));
+ assertEquals("-wear", CN1BuildMojo.roleSuffixFor("fitness-wear-wear", ".apk", both));
+ }
+
+ /// The R8 retrace maps. A minified companion build hands back two of them, and the Wear
+ /// module's is worthless if it lands on the phone map's path -- so this pins the naming the
+ /// build server relies on from the other side of the repo boundary.
+ @Test
+ void theWearRetraceMapLandsBesideThePhoneOne() {
+ java.util.Map> maps =
+ returned("myapp.apk", "myapp-wear.apk", "mapping.txt", "mapping-wear.txt");
+
+ assertEquals("", CN1BuildMojo.roleSuffixFor("mapping", ".txt", maps));
+ assertEquals("-wear", CN1BuildMojo.roleSuffixFor("mapping-wear", ".txt", maps));
+ }
+
+ /// Per extension, because a build can return a companion APK and no companion AAB.
+ @Test
+ void theQuestionIsAskedPerExtension() {
+ java.util.Map> mixed =
+ returned("myapp.apk", "myapp-wear.apk", "myapp-wear.aab");
+
+ assertEquals("-wear", CN1BuildMojo.roleSuffixFor("myapp-wear", ".apk", mixed));
+ assertEquals("", CN1BuildMojo.roleSuffixFor("myapp-wear", ".aab", mixed));
+ }
+}
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
index 212d6749a85..e32f526fb9d 100644
--- 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
@@ -41,10 +41,10 @@
/// 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.
+ /// A project declaring only complications has no iOS surface: those kinds are hosted by the
+ /// watch flavour of the extension instead. The iOS 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");
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java
new file mode 100644
index 00000000000..18420efa7fb
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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 org.junit.jupiter.api.function.Executable;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// The watch flavour of the extension. It is a second target in the same project, embedded in
+/// the watch app rather than the phone app, and it may name a different set of WidgetKit
+/// families than the iOS one -- narrower in one direction and wider in the other.
+///
+/// The sibling IOSWidgetExtensionWatchFamilyTest pins the same separation from the iOS side.
+class IOSWidgetExtensionWatchTargetTest {
+
+ private static IOSWidgetExtensionBuilder watchBuilder(String... families) {
+ return new IOSWidgetExtensionBuilder()
+ .setWatchTarget(true)
+ .setExtensionName("CN1WatchWidgets")
+ .setHostBundleId("com.example.app.watchkitapp")
+ .setAppGroupId("group.com.example.app")
+ .addKind(new IOSWidgetExtensionBuilder.Kind("status")
+ .setName("Status")
+ .setDescription("d")
+ .setIosFamilies(Arrays.asList(families)));
+ }
+
+ private static String bundleOf(IOSWidgetExtensionBuilder b) throws IOException {
+ return new String(b.buildFileMap().get("CN1WidgetBundle.swift"), StandardCharsets.UTF_8);
+ }
+
+ private static String settingsOf(IOSWidgetExtensionBuilder b) throws IOException {
+ return new String(b.buildFileMap().get("buildSettings.properties"), StandardCharsets.UTF_8);
+ }
+
+ private static String plistOf(IOSWidgetExtensionBuilder b) throws IOException {
+ return new String(b.buildFileMap().get("Info.plist"), StandardCharsets.UTF_8);
+ }
+
+ /// Apple validates an embedded bundle's versions against the app containing it, and this
+ /// extension is nested two deep -- inside the watch app, inside the phone app. Pinned to
+ /// 1.0/1 it was rejected at submission for every project on any other version, which is the
+ /// one failure that appears after every build has already gone green.
+ @Test
+ void theExtensionDeclaresTheVersionsItIsToldTo() throws IOException {
+ String plist = plistOf(watchBuilder("watchCircular").setVersions("3.7", "412"));
+
+ assertTrue(plist.contains("3.7"), plist);
+ assertTrue(plist.contains("412"), plist);
+ assertFalse(plist.contains("CFBundleShortVersionString\n 1.0"),
+ plist);
+ }
+
+ /// A caller that says nothing keeps the historical output, so this cannot change what an
+ /// existing build emits on its own.
+ @Test
+ void theVersionsFallBackToWhatWasAlwaysEmitted() throws IOException {
+ String plist = plistOf(watchBuilder("watchCircular"));
+
+ assertTrue(plist.contains("1.0"), plist);
+ assertTrue(plist.contains("1"), plist);
+ }
+
+ /// An empty resolution must not blank the key -- a plist with an empty version string is
+ /// worse than one with the default.
+ @Test
+ void anEmptyVersionIsIgnoredRatherThanWritten() throws IOException {
+ String plist = plistOf(watchBuilder("watchCircular").setVersions("", null));
+
+ assertTrue(plist.contains("1.0"), plist);
+ assertTrue(plist.contains("1"), plist);
+ }
+
+ /// The regression test for the hole this flavour was built around. WidgetFamily.systemSmall
+ /// and its siblings are @available(watchOS, unavailable) -- unnameable, not merely absent --
+ /// so a phone family reaching the watch bundle fails the build outright.
+ @Test
+ void systemFamiliesNeverReachTheWatchBundle() throws IOException {
+ String swift = bundleOf(watchBuilder("small", "medium", "large", "watchCircular"));
+
+ assertFalse(swift.contains(".systemSmall"), swift);
+ assertFalse(swift.contains(".systemMedium"), swift);
+ assertFalse(swift.contains(".systemLarge"), swift);
+ assertTrue(swift.contains(".accessoryCircular"), swift);
+ }
+
+ /// An iPhone lock screen is not a watch face, so the portable lockscreen family has no
+ /// surface here either -- even though it maps to an accessory family that watchOS does have.
+ @Test
+ void lockscreenIsNotAWatchFamily() throws IOException {
+ String swift = bundleOf(watchBuilder("lockscreen", "watchInline"));
+
+ assertTrue(swift.contains(".accessoryInline"), swift);
+ assertFalse(swift.contains(".accessoryRectangular"), swift);
+ }
+
+ /// buildSettings.properties is read back with Properties.load, which takes the first
+ /// unescaped '=' as the separator -- so a conditional Xcode key has to escape its own. Loaded
+ /// rather than string-matched, because the whole failure was that the text looked right and
+ /// parsed wrong: the key became "ARCHS[sdk" and the extension silently built for the
+ /// containing project's architectures.
+ @Test
+ void theConditionalArchsKeySurvivesAPropertiesLoad() throws Exception {
+ IOSWidgetExtensionBuilder b = watchBuilder("watchCircular");
+ String text = new String(b.buildFileMap().get("buildSettings.properties"), "UTF-8");
+
+ java.util.Properties props = new java.util.Properties();
+ props.load(new java.io.StringReader(text));
+ assertEquals("arm64_32", props.getProperty("ARCHS[sdk=watchos*]"), text);
+ assertNull(props.getProperty("ARCHS[sdk"), text);
+ }
+
+ /// Nor are the WidgetKit accessory spellings. SurfaceKindFamilies already says they are not
+ /// watch families -- a kind declaring only accessoryCircular produces no watch extension at
+ /// all -- so letting one INTO a watch extension that some other family opened would be the
+ /// system contradicting itself: this kind asked for a lock-screen circular and a rectangular
+ /// complication, and would have been given a circular complication it never asked for.
+ @Test
+ void accessoryFamiliesAreNotWatchFamiliesEither() throws IOException {
+ String swift = bundleOf(watchBuilder("accessoryCircular", "watchRectangular"));
+
+ assertTrue(swift.contains(".accessoryRectangular"), swift);
+ assertFalse(swift.contains(".accessoryCircular"), swift);
+ }
+
+ /// And nothing is lost by refusing them: every accessory family the watch can show has a
+ /// watch* name that maps to it, which is how a developer asks for it there.
+ @Test
+ void everyWatchAccessoryFamilyStaysReachableByItsWatchName() throws IOException {
+ String swift = bundleOf(watchBuilder("watchCircular", "watchRectangular", "watchInline"));
+
+ assertTrue(swift.contains(".accessoryCircular"), swift);
+ assertTrue(swift.contains(".accessoryRectangular"), swift);
+ assertTrue(swift.contains(".accessoryInline"), swift);
+ }
+
+ /// Inside a watchOS-only target the corner family needs no platform guard; carrying one
+ /// would be noise in a file that can only ever be compiled for the watch.
+ @Test
+ void cornerFamilyNeedsNoPlatformGuardInTheWatchTarget() throws IOException {
+ String swift = bundleOf(watchBuilder("watchCorner", "watchCircular"));
+
+ assertTrue(swift.contains(".accessoryCorner"), swift);
+ assertFalse(swift.contains("#if os(watchOS)"), swift);
+ }
+
+ @Test
+ void aKindWithNoWatchFamilyIsNotHosted() throws IOException {
+ IOSWidgetExtensionBuilder b = new IOSWidgetExtensionBuilder()
+ .setWatchTarget(true)
+ .setExtensionName("CN1WatchWidgets")
+ .setHostBundleId("com.example.app.watchkitapp")
+ .setAppGroupId("group.com.example.app")
+ .addKind(new IOSWidgetExtensionBuilder.Kind("phone")
+ .setIosFamilies(Arrays.asList("small")))
+ .addKind(new IOSWidgetExtensionBuilder.Kind("wrist")
+ .setIosFamilies(Arrays.asList("watchCircular")));
+
+ String swift = bundleOf(b);
+
+ assertTrue(swift.contains("CN1Widget_wrist"), swift);
+ assertFalse(swift.contains("CN1Widget_phone"), swift);
+ }
+
+ @Test
+ void watchOnlyManifestHasAWatchSurfaceButNoIosOne() {
+ IOSWidgetExtensionBuilder b = watchBuilder("watchCircular");
+
+ assertTrue(b.hasWatchSurface());
+ assertFalse(b.hasIosSurface());
+ assertTrue(b.hasSurface());
+ }
+
+ @Test
+ void phoneOnlyManifestHasNoWatchSurface() {
+ IOSWidgetExtensionBuilder b = watchBuilder("small", "medium");
+
+ assertFalse(b.hasWatchSurface());
+ assertFalse(b.hasSurface());
+ }
+
+ /// Generating anyway must fail loudly rather than emit a WidgetBundle with an empty body,
+ /// which does not compile and would break the whole watch build.
+ @Test
+ void generatingAWatchExtensionWithNoComplicationIsRefused() {
+ final IOSWidgetExtensionBuilder b = watchBuilder("small");
+
+ assertThrows(IllegalStateException.class, new Executable() {
+ public void execute() throws Throwable {
+ b.buildFileMap();
+ }
+ });
+ }
+
+ @Test
+ void buildSettingsDescribeAWatchTargetAndNotAPhoneOne() throws IOException {
+ String props = settingsOf(watchBuilder("watchCircular"));
+
+ assertTrue(props.contains("WATCHOS_DEPLOYMENT_TARGET=9.0"), props);
+ assertTrue(props.contains("SDKROOT=watchos"), props);
+ assertTrue(props.contains("SUPPORTED_PLATFORMS=watchos watchsimulator"), props);
+ assertTrue(props.contains("TARGETED_DEVICE_FAMILY=4"), props);
+ // Escaped, because Properties.load reads this file back and would otherwise split the
+ // key at the first '='. theConditionalArchsKeySurvivesAPropertiesLoad asserts what it
+ // PARSES as; this line only pins what is written.
+ assertTrue(props.contains("ARCHS[sdk\\=watchos*]=arm64_32"), props);
+ assertFalse(props.contains("IPHONEOS_DEPLOYMENT_TARGET"), props);
+ }
+
+ /// The watch app embeds the Swift runtime once for everything nested inside it. A second
+ /// copy in the extension is dead weight and can fail submission validation.
+ @Test
+ void theNestedExtensionDoesNotEmbedItsOwnSwiftRuntime() throws IOException {
+ assertTrue(settingsOf(watchBuilder("watchCircular"))
+ .contains("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=NO"));
+ assertTrue(settingsOf(new IOSWidgetExtensionBuilder()
+ .setExtensionName("CN1Widgets")
+ .setHostBundleId("com.example.app")
+ .setAppGroupId("group.com.example.app")
+ .addKind(new IOSWidgetExtensionBuilder.Kind("k")
+ .setIosFamilies(Arrays.asList("small"))))
+ .contains("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=YES"),
+ "the iOS extension is not nested and keeps its own copy");
+ }
+
+ /// watchOS has no ActivityKit, so neither the live activity widget nor the attributes it
+ /// shares with the app belong in a watch target -- even asking for them.
+ @Test
+ void liveActivitySourcesAreNeverShippedToTheWatch() throws IOException {
+ Map files = watchBuilder("watchCircular")
+ .setLiveActivitiesEnabled(true)
+ .buildFileMap();
+
+ assertFalse(files.containsKey("CN1LiveActivityWidget.swift"), files.keySet().toString());
+ assertFalse(files.containsKey("CN1SurfaceAttributes.swift"), files.keySet().toString());
+ assertFalse(new String(files.get("CN1WidgetBundle.swift"), StandardCharsets.UTF_8)
+ .contains("CN1LiveActivityWidget()"));
+ }
+
+ /// WidgetKit's own floor is watchOS 9, and that is where this sits. The accessory families
+ /// arrived in 9; containerBackground(for:) is watchOS 10 but is applied inside an
+ /// availability check, which compiles below the version it names. Anything under 9 has no
+ /// WidgetKit to build against at all.
+ @Test
+ void aDeploymentTargetBelowTheWatchFloorIsRejected() {
+ final IOSWidgetExtensionBuilder b = watchBuilder("watchCircular").setDeploymentTarget("8.0");
+
+ IllegalStateException ex = assertThrows(IllegalStateException.class, new Executable() {
+ public void execute() throws Throwable {
+ b.buildFileMap();
+ }
+ });
+ assertTrue(ex.getMessage().contains("9.0"), ex.getMessage());
+ }
+
+ /// "10.0" orders above "9.0" only under a numeric comparison; string order says otherwise --
+ /// which is why a floor of 9.0 has to accept 10.0 and reject 8.0 rather than compare text.
+ @Test
+ void theFloorCheckComparesVersionsNumerically() throws IOException {
+ assertEquals("9.0", watchBuilder("watchCircular").getDeploymentTarget());
+ watchBuilder("watchCircular").setDeploymentTarget("10.0").buildFileMap();
+ watchBuilder("watchCircular").setDeploymentTarget("11.2").buildFileMap();
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java
new file mode 100644
index 00000000000..37e7953541a
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java
@@ -0,0 +1,241 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// Both device builders classify a kind's families through this one class, so the rule it
+/// encodes has to be pinned here rather than at either call site. The cases below are the ones
+/// that have actually been got wrong.
+class SurfaceKindFamiliesTest {
+
+ private static Map kind(String key, Object value) {
+ Map m = new LinkedHashMap();
+ m.put("id", "k");
+ if (key != null) {
+ m.put(key, value);
+ }
+ return m;
+ }
+
+ /// The portable key is what a manifest should say now, so it wins outright.
+ @Test
+ void familiesWinsOverIosFamilies() {
+ Map m = kind("families", Arrays.asList("watchCircular"));
+ m.put("iosFamilies", Arrays.asList("small", "medium"));
+
+ assertEquals(Arrays.asList("watchCircular"), SurfaceKindFamilies.read(m),
+ "families is the portable spelling and must not be merged with the legacy one");
+ }
+
+ /// A manifest carrying both is far likelier to be mid-migration than to mean the union, and
+ /// unioning would resurrect a family the author had just removed.
+ @Test
+ void iosFamiliesIsReadOnlyWhenFamiliesIsAbsent() {
+ assertEquals(Arrays.asList("small"),
+ SurfaceKindFamilies.read(kind("iosFamilies", Arrays.asList("small"))));
+ assertEquals(0, SurfaceKindFamilies.read(kind(null, null)).size());
+ }
+
+ @Test
+ void nonStringEntriesAreSkippedRatherThanFailing() {
+ Map m = kind("families", Arrays.asList("small", Integer.valueOf(7), null));
+
+ assertEquals(Arrays.asList("small"), SurfaceKindFamilies.read(m));
+ }
+
+ /// accessoryCorner is the ONLY WidgetKit spelling that names a watch-only family.
+ @Test
+ void onlyAccessoryCornerNormalizesToAWatchFamily() {
+ assertEquals("watchCorner", SurfaceKindFamilies.normalize("accessoryCorner"));
+ assertEquals("accessoryCircular", SurfaceKindFamilies.normalize("accessoryCircular"));
+ assertEquals("small", SurfaceKindFamilies.normalize("small"));
+ }
+
+ /// The trap this class exists for, in both directions.
+ ///
+ /// accessoryCircular / accessoryInline / accessoryRectangular are the iPhone LOCK-SCREEN
+ /// families as well as watch ones, so treating them as watch families withholds a
+ /// lock-screen widget the manifest asked for. The portable watch* names mean "complication
+ /// only" and must not be treated as phone families.
+ @Test
+ void widgetKitAccessorySpellingsAreNotWatchFamilies() {
+ assertFalse(SurfaceKindFamilies.isWatch("accessoryCircular"));
+ assertFalse(SurfaceKindFamilies.isWatch("accessoryInline"));
+ assertFalse(SurfaceKindFamilies.isWatch("accessoryRectangular"));
+ assertFalse(SurfaceKindFamilies.isWatch("lockscreen"));
+
+ assertTrue(SurfaceKindFamilies.isWatch("watchCircular"));
+ assertTrue(SurfaceKindFamilies.isWatch("watchRectangular"));
+ assertTrue(SurfaceKindFamilies.isWatch("watchInline"));
+ assertTrue(SurfaceKindFamilies.isWatch("watchCorner"));
+ assertTrue(SurfaceKindFamilies.isWatch("accessoryCorner"));
+ }
+
+ @Test
+ void hasWatchFamilyIsTrueForAMixedKindButWatchOnlyIsNot() {
+ List mixed = Arrays.asList("small", "watchCircular");
+
+ assertTrue(SurfaceKindFamilies.hasWatchFamily(mixed));
+ assertFalse(SurfaceKindFamilies.isWatchOnly(mixed),
+ "a kind that also offers a home-screen widget is not watch-only");
+ assertTrue(SurfaceKindFamilies.hasPhoneFamily(mixed));
+ }
+
+ @Test
+ void watchOnlyKindHasNoPhoneFamily() {
+ List watchOnly = Arrays.asList("watchCircular", "watchInline");
+
+ assertTrue(SurfaceKindFamilies.isWatchOnly(watchOnly));
+ assertFalse(SurfaceKindFamilies.hasPhoneFamily(watchOnly));
+ }
+
+ /// An empty declaration means the kind took the default -- the three home-screen sizes --
+ /// not that it opted out of every surface.
+ @Test
+ void emptyDeclarationIsAPhoneKind() {
+ List none = Arrays.asList();
+
+ assertFalse(SurfaceKindFamilies.isWatchOnly(none));
+ assertFalse(SurfaceKindFamilies.hasWatchFamily(none));
+ assertTrue(SurfaceKindFamilies.hasPhoneFamily(none));
+ }
+
+ /// The four names exactly. A prefix test made a mistyped "watchCircle" a watch family here
+ /// while every mapping downstream recognised only the real four -- so the kind lost its phone
+ /// widget, gained watch codegen, and produced no usable surface anywhere, in a build that
+ /// went green.
+ @Test
+ void aMistypedWatchNameIsNotAWatchFamily() {
+ assertTrue(SurfaceKindFamilies.isWatch("watchCircular"));
+ assertTrue(SurfaceKindFamilies.isWatch("watchRectangular"));
+ assertTrue(SurfaceKindFamilies.isWatch("watchInline"));
+ assertTrue(SurfaceKindFamilies.isWatch("watchCorner"));
+
+ assertFalse(SurfaceKindFamilies.isWatch("watchCircle"));
+ assertFalse(SurfaceKindFamilies.isWatch("watch"));
+ assertFalse(SurfaceKindFamilies.isWatch("watchSquare"));
+ assertFalse(SurfaceKindFamilies.isWatch("watchcircular"));
+ }
+
+ /// And a typo is not a phone family either, so the builder can tell the author which of the
+ /// two answers it has rather than quietly rendering a widget nobody asked for.
+ @Test
+ void anUnknownNameIsNeitherWatchNorPhone() {
+ assertTrue(SurfaceKindFamilies.isKnown("small"));
+ assertTrue(SurfaceKindFamilies.isKnown("lockscreen"));
+ assertTrue(SurfaceKindFamilies.isKnown("accessoryCorner"));
+ assertTrue(SurfaceKindFamilies.isKnown("watchCircular"));
+
+ assertFalse(SurfaceKindFamilies.isKnown("watchCircle"));
+ assertFalse(SurfaceKindFamilies.isKnown("enormous"));
+ assertFalse(SurfaceKindFamilies.isKnown(null));
+ }
+
+ @Test
+ void nullsAreTolerated() {
+ assertEquals(0, SurfaceKindFamilies.read(null).size());
+ assertFalse(SurfaceKindFamilies.hasWatchFamily(null));
+ assertFalse(SurfaceKindFamilies.isWatchOnly(null));
+ assertFalse(SurfaceKindFamilies.isWatch(null));
+ }
+
+ /// The portable key wins when PRESENT, not merely when well-formed. A manifest mid-migration
+ /// is the one case carrying both keys, so falling through to the legacy list on a malformed
+ /// portable value silently built the surface the author had just replaced.
+ @Test
+ void aMalformedFamiliesValueDoesNotResurrectTheLegacyList() {
+ Map kind = new LinkedHashMap();
+ kind.put("id", "status");
+ kind.put("families", Integer.valueOf(7));
+ kind.put("iosFamilies", Arrays.asList("small"));
+
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+ () -> SurfaceKindFamilies.read(kind));
+ assertTrue(ex.getMessage().contains("status"), ex.getMessage());
+ assertTrue(ex.getMessage().contains("families"), ex.getMessage());
+ }
+
+ /// A bare string is the obvious shorthand and the obvious mistype, so it is read the way it
+ /// was plainly meant rather than refused.
+ @Test
+ void aSingleFamilyNameIsReadAsOneFamily() {
+ Map kind = new LinkedHashMap();
+ kind.put("id", "status");
+ kind.put("families", "watchCircular");
+ kind.put("iosFamilies", Arrays.asList("small"));
+
+ assertEquals(Arrays.asList("watchCircular"), SurfaceKindFamilies.read(kind));
+ }
+
+ /// The legacy key keeps its old tolerance: manifests carrying it predate this check, and
+ /// refusing one now would fail a build that has always worked.
+ @Test
+ void aMalformedLegacyValueStillDegradesQuietly() {
+ Map kind = new LinkedHashMap();
+ kind.put("id", "status");
+ kind.put("iosFamilies", Integer.valueOf(7));
+
+ assertTrue(SurfaceKindFamilies.read(kind).isEmpty());
+ }
+
+ /// An explicit null is PRESENT, so it must not fall through to the legacy list -- and it is
+ /// an authoring mistake rather than a way to say "no families", because there is no empty
+ /// answer that means that: an empty declaration deliberately takes the home-screen default,
+ /// which is what a kind with no families key gets. Returning empty would have produced the
+ /// three default sizes and an Android provider, the opposite of what a null plainly intends.
+ @Test
+ void anExplicitNullFamiliesKeyIsRefused() {
+ Map kind = new LinkedHashMap();
+ kind.put("id", "status");
+ kind.put("families", null);
+ kind.put("iosFamilies", Arrays.asList("small"));
+
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+ () -> SurfaceKindFamilies.read(kind));
+ assertTrue(ex.getMessage().contains("status"), ex.getMessage());
+ }
+
+ /// And the empty declaration keeps its own meaning, which the refusal above depends on.
+ @Test
+ void anEmptyFamiliesListStillTakesTheDefault() {
+ Map kind = new LinkedHashMap();
+ kind.put("id", "status");
+ kind.put("families", new ArrayList());
+
+ assertTrue(SurfaceKindFamilies.read(kind).isEmpty());
+ assertTrue(SurfaceKindFamilies.hasPhoneFamily(SurfaceKindFamilies.read(kind)),
+ "an empty declaration takes the home-screen default");
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java
new file mode 100644
index 00000000000..0aee2ad9e2b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/// The surfaces Swift sources are compiled into a watchOS widget extension as well as an iOS
+/// one, and several of the symbols they use do not exist on watchOS: the four system widget
+/// families are `@available(watchOS, unavailable)`, and `UIColor.systemBackground`,
+/// `UIColor(dynamicProvider:)` and `UIGraphicsImageRenderer` are all `API_UNAVAILABLE(watchos)`.
+/// Naming any of them outside a platform guard fails the watch build.
+///
+/// The real proof is a watchOS compile, which the `build-ios-watch` CI job performs. This test
+/// is the cheap half that also runs on a Linux leg with no Xcode: it reads the shipped
+/// resources and checks that each forbidden symbol appears only inside a `#if !os(watchOS)`
+/// region. It cannot prove the sources compile -- only that the specific mistakes that have
+/// actually been made here have not been made again.
+class SurfacesSwiftWatchPortabilityTest {
+
+ private static final String ROOT = "/com/codename1/builders/surfaces/ios/";
+
+ /// Symbols that must never be reachable when compiling for watchOS.
+ ///
+ /// The dynamic-provider entry is spelled with its closure parameter because a bare
+ /// "UIColor {" also matches the trailing brace of `func cn1UIColor(...) -> UIColor {`,
+ /// which is a perfectly portable declaration.
+ private static final String[] IOS_ONLY_SYMBOLS = {
+ ".systemSmall", ".systemMedium", ".systemLarge", ".systemExtraLarge",
+ "UIColor.systemBackground", "UIColor { trait", "UIGraphicsImageRenderer"
+ };
+
+ private static final String[] SHARED_SOURCES = {
+ "CN1DescriptorWidget.swift", "CN1SurfaceModel.swift",
+ "CN1SurfaceRenderer.swift", "CN1WidgetProvider.swift"
+ };
+
+ private static String load(String name) throws IOException {
+ InputStream in = SurfacesSwiftWatchPortabilityTest.class.getResourceAsStream(ROOT + name);
+ if (in == null) {
+ fail("missing surfaces Swift resource " + name);
+ }
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int read;
+ while ((read = in.read(buf)) > 0) {
+ out.write(buf, 0, read);
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ } finally {
+ in.close();
+ }
+ }
+
+ /// True when the line sits inside a region the watch compiler never sees.
+ ///
+ /// Tracks the `#if` nesting rather than pattern-matching a single line, because the guard
+ /// that matters is often several lines above the symbol and may be nested inside another.
+ private static boolean[] excludedFromWatch(String source) {
+ String[] lines = source.split("\n", -1);
+ boolean[] excluded = new boolean[lines.length];
+ // One entry per open #if: true when that block's ACTIVE branch is invisible to watchOS.
+ Deque stack = new ArrayDeque();
+ boolean hidden = false;
+ for (int i = 0; i < lines.length; i++) {
+ String trimmed = lines[i].trim();
+ if (trimmed.startsWith("#if ")) {
+ boolean blockHidden = trimmed.contains("!os(watchOS)");
+ stack.push(Boolean.valueOf(blockHidden));
+ hidden = hidden || blockHidden;
+ } else if (trimmed.equals("#else") && !stack.isEmpty()) {
+ // The other branch of a `#if os(watchOS)` is equally invisible to the watch.
+ boolean wasHidden = stack.pop().booleanValue();
+ boolean nowHidden = !wasHidden && wasElseOfWatchOnly(lines, i);
+ stack.push(Boolean.valueOf(nowHidden));
+ hidden = anyTrue(stack);
+ } else if (trimmed.equals("#endif") && !stack.isEmpty()) {
+ stack.pop();
+ hidden = anyTrue(stack);
+ }
+ excluded[i] = hidden;
+ }
+ return excluded;
+ }
+
+ /// Whether the `#else` at {@code idx} closes a `#if os(watchOS)` block, which makes the
+ /// else-branch the non-watch one.
+ private static boolean wasElseOfWatchOnly(String[] lines, int idx) {
+ int depth = 0;
+ for (int i = idx - 1; i >= 0; i--) {
+ String trimmed = lines[i].trim();
+ if (trimmed.equals("#endif")) {
+ depth++;
+ } else if (trimmed.startsWith("#if ")) {
+ if (depth == 0) {
+ return trimmed.contains("os(watchOS)") && !trimmed.contains("!os(watchOS)");
+ }
+ depth--;
+ }
+ }
+ return false;
+ }
+
+ private static boolean anyTrue(Deque stack) {
+ for (Boolean b : stack) {
+ if (b.booleanValue()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Test
+ void iosOnlySymbolsAreNeverReachableFromTheWatchSlice() throws IOException {
+ StringBuilder problems = new StringBuilder();
+ for (String name : SHARED_SOURCES) {
+ String source = load(name);
+ String[] lines = source.split("\n", -1);
+ boolean[] excluded = excludedFromWatch(source);
+ for (int i = 0; i < lines.length; i++) {
+ if (excluded[i] || lines[i].trim().startsWith("//")) {
+ continue;
+ }
+ for (String symbol : IOS_ONLY_SYMBOLS) {
+ if (lines[i].contains(symbol)) {
+ problems.append(name).append(':').append(i + 1)
+ .append(" uses ").append(symbol)
+ .append(" outside a #if !os(watchOS) guard\n");
+ }
+ }
+ }
+ }
+ assertTrue(problems.length() == 0,
+ "these are unavailable on watchOS and will fail the watch build:\n" + problems);
+ }
+
+ /// containerBackground(for:) is watchOS 10.0, and the watch extension's floor is exactly
+ /// 10.0. Leaving the availability check as a bare `*` compiles today and would silently
+ /// stop guarding if that floor were ever lowered.
+ @Test
+ void containerBackgroundNamesItsWatchAvailability() throws IOException {
+ String source = load("CN1DescriptorWidget.swift");
+
+ assertTrue(source.contains("#available(iOS 17.0, watchOS 10.0, *)"),
+ "containerBackground must declare its watchOS availability explicitly");
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/README.md b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/README.md
new file mode 100644
index 00000000000..518b3db2b7d
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/README.md
@@ -0,0 +1,13 @@
+# Wear surface stubs
+
+Minimal stand-ins for the Android, AndroidX Wear and Guava types the two injected Wear surface
+services use, so `WearGlueCompilesTest` can compile them without an Android SDK or the
+`androidx.wear` artifacts.
+
+They deliberately declare only what the services actually touch. A member that goes missing is a
+compile error naming it, which is the right outcome: it means a service started using something
+new and this tree has to say so. That makes the tree an executable record of exactly how much of
+the AndroidX Wear API surface Codename One depends on.
+
+Files carry the `.javas` extension so this module's own compilation ignores them; the test copies
+and renames them.
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas
new file mode 100644
index 00000000000..3648b413eb7
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas
@@ -0,0 +1,7 @@
+package android.app;
+import android.content.Context;
+import android.content.Intent;
+public class PendingIntent {
+ public static final int FLAG_UPDATE_CURRENT = 134217728;
+ public static PendingIntent getActivity(Context c, int r, Intent i, int f) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Context.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Context.javas
new file mode 100644
index 00000000000..55dda3b55a5
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Context.javas
@@ -0,0 +1,6 @@
+package android.content;
+import android.content.res.Resources;
+public class Context {
+ public String getPackageName() { return ""; }
+ public Resources getResources() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Intent.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Intent.javas
new file mode 100644
index 00000000000..38c993a7c29
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Intent.javas
@@ -0,0 +1,7 @@
+package android.content;
+public class Intent {
+ public Intent(Context c, Class> k) { }
+ public Intent putExtra(String n, String v) { return this; }
+ public void setData(android.net.Uri u) { }
+ public String getDataString() { return ""; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas
new file mode 100644
index 00000000000..7962ea3d55b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas
@@ -0,0 +1,2 @@
+package android.content.res;
+public class Configuration { public int uiMode; public static final int UI_MODE_NIGHT_MASK = 48; public static final int UI_MODE_NIGHT_YES = 32; }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas
new file mode 100644
index 00000000000..9623537a8e9
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas
@@ -0,0 +1,7 @@
+package android.content.res;
+public class Resources {
+ public int getIdentifier(String n, String t, String p) { return 0; }
+ public String[] getStringArray(int id) { return new String[0]; }
+ public Configuration getConfiguration() { return null; }
+ public android.util.DisplayMetrics getDisplayMetrics() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas
new file mode 100644
index 00000000000..0a56d89b5cf
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas
@@ -0,0 +1,8 @@
+package android.graphics;
+import java.io.OutputStream;
+public class Bitmap {
+ public enum CompressFormat { PNG }
+ public boolean compress(CompressFormat f, int q, OutputStream o) { return true; }
+ public int getWidth() { return 0; }
+ public int getHeight() { return 0; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas
new file mode 100644
index 00000000000..64d205f1537
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas
@@ -0,0 +1,3 @@
+package android.graphics.drawable;
+import android.graphics.Bitmap;
+public class Icon { public static Icon createWithBitmap(Bitmap b) { return null; } }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/net/Uri.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/net/Uri.javas
new file mode 100644
index 00000000000..338ca4842de
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/net/Uri.javas
@@ -0,0 +1,2 @@
+package android.net;
+public class Uri { public static Uri parse(String s) { return null; } public static String encode(String s) { return s; } }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/os/Build.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/os/Build.javas
new file mode 100644
index 00000000000..00fe1983707
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/os/Build.javas
@@ -0,0 +1,2 @@
+package android.os;
+public class Build { public static class VERSION { public static int SDK_INT = 33; } }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas
new file mode 100644
index 00000000000..a34925700e1
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas
@@ -0,0 +1,2 @@
+package android.util;
+public class DisplayMetrics { public float density = 1f; }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/Log.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/Log.javas
new file mode 100644
index 00000000000..80a9a7eb2d8
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/Log.javas
@@ -0,0 +1,6 @@
+package android.util;
+public class Log {
+ public static int w(String t, String m) { return 0; }
+ public static int w(String t, String m, Throwable e) { return 0; }
+ public static int i(String t, String m) { return 0; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas
new file mode 100644
index 00000000000..f9b9e58ee51
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas
@@ -0,0 +1,7 @@
+package androidx.concurrent.futures;
+import com.google.common.util.concurrent.ListenableFuture;
+public final class CallbackToFutureAdapter {
+ public interface Completer { boolean set(T value); }
+ public interface Resolver { Object attachCompleter(Completer completer); }
+ public static ListenableFuture getFuture(Resolver resolver) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas
new file mode 100644
index 00000000000..51d03dd70e1
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas
@@ -0,0 +1,25 @@
+package androidx.wear.protolayout;
+public final class ActionBuilders {
+ public interface Action { }
+ public interface AndroidExtra { }
+ public static class AndroidStringExtra implements AndroidExtra {
+ public static class Builder {
+ public Builder setValue(String v) { return this; }
+ public AndroidStringExtra build() { return null; }
+ }
+ }
+ public static class AndroidActivity {
+ public static class Builder {
+ public Builder setPackageName(String p) { return this; }
+ public Builder setClassName(String c) { return this; }
+ public Builder addKeyToExtraMapping(String key, AndroidExtra value) { return this; }
+ public AndroidActivity build() { return null; }
+ }
+ }
+ public static class LaunchAction implements Action {
+ public static class Builder {
+ public Builder setAndroidActivity(AndroidActivity a) { return this; }
+ public LaunchAction build() { return null; }
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas
new file mode 100644
index 00000000000..eac744c9a8d
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas
@@ -0,0 +1,5 @@
+package androidx.wear.protolayout;
+public final class ColorBuilders {
+ public static class ColorProp { }
+ public static ColorProp argb(int c) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas
new file mode 100644
index 00000000000..ce97847ac65
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas
@@ -0,0 +1,20 @@
+package androidx.wear.protolayout;
+public final class DimensionBuilders {
+ // The real API's dimension types are distinguished by which layout slots accept them, and
+ // the spacer case is the one this depends on: both a fixed dp and an expanded dimension are
+ // a SpacerDimension, which is what lets a flexible spacer say expand() where a sized one
+ // says dp(min).
+ public interface SpacerDimension { }
+ public interface ContainerDimension { }
+ public interface ImageDimension { }
+ public static class DpProp implements SpacerDimension, ImageDimension, ContainerDimension { }
+ public static class ExpandedDimensionProp
+ implements SpacerDimension, ImageDimension, ContainerDimension { }
+ public static class SpProp { }
+ public static class DegreesProp { }
+ public static DpProp dp(float v) { return null; }
+ public static ExpandedDimensionProp expand() { return null; }
+ public static ExpandedDimensionProp weight(float w) { return null; }
+ public static SpProp sp(float v) { return null; }
+ public static DegreesProp degrees(float v) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas
new file mode 100644
index 00000000000..7d41aab8f91
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas
@@ -0,0 +1,100 @@
+package androidx.wear.protolayout;
+public final class LayoutElementBuilders {
+ public static final int CONTENT_SCALE_MODE_FIT = 1;
+ public static final int CONTENT_SCALE_MODE_CROP = 2;
+ public static final int CONTENT_SCALE_MODE_FILL_BOUNDS = 3;
+ public static final int HORIZONTAL_ALIGN_START = 1;
+ public static final int HORIZONTAL_ALIGN_CENTER = 2;
+ public static final int HORIZONTAL_ALIGN_END = 3;
+ public static final int VERTICAL_ALIGN_TOP = 1;
+ public static final int VERTICAL_ALIGN_CENTER = 2;
+ public static final int VERTICAL_ALIGN_BOTTOM = 3;
+ public static final int FONT_WEIGHT_BOLD = 700;
+ public interface LayoutElement { }
+ public static class FontStyle {
+ public static class Builder {
+ public Builder setSize(DimensionBuilders.SpProp s) { return this; }
+ public Builder setWeight(int w) { return this; }
+ public Builder setColor(ColorBuilders.ColorProp c) { return this; }
+ public FontStyle build() { return null; }
+ }
+ }
+ public static class Text implements LayoutElement {
+ public static class Builder {
+ public Builder setText(String t) { return this; }
+ public Builder setFontStyle(FontStyle f) { return this; }
+ public Builder setMaxLines(int m) { return this; }
+ public Builder setModifiers(ModifiersBuilders.Modifiers m) { return this; }
+ public Text build() { return null; }
+ }
+ }
+ public static class Column implements LayoutElement {
+ public static class Builder {
+ public Builder addContent(LayoutElement e) { return this; }
+ public Builder setModifiers(ModifiersBuilders.Modifiers m) { return this; }
+ public Column build() { return null; }
+ }
+ }
+ public static class Row implements LayoutElement {
+ public static class Builder {
+ public Builder addContent(LayoutElement e) { return this; }
+ public Builder setModifiers(ModifiersBuilders.Modifiers m) { return this; }
+ public Row build() { return null; }
+ }
+ }
+ public static class Box implements LayoutElement {
+ public static class Builder {
+ public Builder addContent(LayoutElement e) { return this; }
+ public Builder setWidth(DimensionBuilders.ContainerDimension d) { return this; }
+ public Builder setHeight(DimensionBuilders.ContainerDimension d) { return this; }
+ public Builder setHorizontalAlignment(int a) { return this; }
+ public Builder setVerticalAlignment(int a) { return this; }
+ public Builder setModifiers(ModifiersBuilders.Modifiers m) { return this; }
+ public Box build() { return null; }
+ }
+ }
+ public static class Spacer implements LayoutElement {
+ public static class Builder {
+ public Builder setWidth(DimensionBuilders.SpacerDimension d) { return this; }
+ public Builder setHeight(DimensionBuilders.SpacerDimension d) { return this; }
+ public Spacer build() { return null; }
+ }
+ }
+ public static class ColorFilter {
+ public static class Builder {
+ public Builder setTint(ColorBuilders.ColorProp c) { return this; }
+ public ColorFilter build() { return null; }
+ }
+ }
+ public static class Image implements LayoutElement {
+ public static class Builder {
+ public Builder setColorFilter(ColorFilter f) { return this; }
+ public Builder setContentScaleMode(int m) { return this; }
+ public Builder setResourceId(String id) { return this; }
+ public Builder setWidth(DimensionBuilders.DpProp d) { return this; }
+ public Builder setHeight(DimensionBuilders.DpProp d) { return this; }
+ public Builder setModifiers(ModifiersBuilders.Modifiers m) { return this; }
+ public Image build() { return null; }
+ }
+ }
+ public static class ArcLine {
+ public static class Builder {
+ public Builder setLength(DimensionBuilders.DegreesProp d) { return this; }
+ public Builder setThickness(DimensionBuilders.DpProp d) { return this; }
+ public Builder setColor(ColorBuilders.ColorProp c) { return this; }
+ public ArcLine build() { return null; }
+ }
+ }
+ public static class Arc implements LayoutElement {
+ public static class Builder {
+ public Builder addContent(ArcLine a) { return this; }
+ public Arc build() { return null; }
+ }
+ }
+ public static class Layout {
+ public static class Builder {
+ public Builder setRoot(LayoutElement e) { return this; }
+ public Layout build() { return null; }
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas
new file mode 100644
index 00000000000..bbd6e0e5515
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas
@@ -0,0 +1,40 @@
+package androidx.wear.protolayout;
+public final class ModifiersBuilders {
+ public static class Padding {
+ public static class Builder {
+ public Builder setStart(DimensionBuilders.DpProp d) { return this; }
+ public Builder setEnd(DimensionBuilders.DpProp d) { return this; }
+ public Builder setTop(DimensionBuilders.DpProp d) { return this; }
+ public Builder setBottom(DimensionBuilders.DpProp d) { return this; }
+ public Padding build() { return null; }
+ }
+ }
+ public static class Corner {
+ public static class Builder {
+ public Builder setRadius(DimensionBuilders.DpProp d) { return this; }
+ public Corner build() { return null; }
+ }
+ }
+ public static class Background {
+ public static class Builder {
+ public Builder setColor(ColorBuilders.ColorProp c) { return this; }
+ public Builder setCorner(Corner c) { return this; }
+ public Background build() { return null; }
+ }
+ }
+ public static class Clickable {
+ public static class Builder {
+ public Builder setId(String id) { return this; }
+ public Builder setOnClick(ActionBuilders.Action a) { return this; }
+ public Clickable build() { return null; }
+ }
+ }
+ public static class Modifiers {
+ public static class Builder {
+ public Builder setPadding(Padding p) { return this; }
+ public Builder setBackground(Background b) { return this; }
+ public Builder setClickable(Clickable c) { return this; }
+ public Modifiers build() { return null; }
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas
new file mode 100644
index 00000000000..c012a65c9f7
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas
@@ -0,0 +1,26 @@
+package androidx.wear.protolayout;
+public final class ResourceBuilders {
+ public static final int IMAGE_FORMAT_UNDEFINED = 0;
+ public static class InlineImageResource {
+ public static class Builder {
+ public Builder setData(byte[] d) { return this; }
+ public Builder setWidthPx(int w) { return this; }
+ public Builder setHeightPx(int h) { return this; }
+ public Builder setFormat(int f) { return this; }
+ public InlineImageResource build() { return null; }
+ }
+ }
+ public static class ImageResource {
+ public static class Builder {
+ public Builder setInlineResource(InlineImageResource r) { return this; }
+ public ImageResource build() { return null; }
+ }
+ }
+ public static class Resources {
+ public static class Builder {
+ public Builder addIdToImageMapping(String id, ImageResource r) { return this; }
+ public Builder setVersion(String v) { return this; }
+ public Resources build() { return null; }
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas
new file mode 100644
index 00000000000..fef8ac344a8
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas
@@ -0,0 +1,15 @@
+package androidx.wear.protolayout;
+public final class TimelineBuilders {
+ public static class TimelineEntry {
+ public static class Builder {
+ public Builder setLayout(LayoutElementBuilders.Layout l) { return this; }
+ public TimelineEntry build() { return null; }
+ }
+ }
+ public static class Timeline {
+ public static class Builder {
+ public Builder addTimelineEntry(TimelineEntry e) { return this; }
+ public Timeline build() { return null; }
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas
new file mode 100644
index 00000000000..b2ef72050a7
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas
@@ -0,0 +1,7 @@
+package androidx.wear.tiles;
+public final class RequestBuilders {
+ public static class TileRequest { }
+ public static class ResourcesRequest {
+ public String getVersion() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas
new file mode 100644
index 00000000000..a6b12f1fe0a
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas
@@ -0,0 +1,12 @@
+package androidx.wear.tiles;
+import androidx.wear.protolayout.TimelineBuilders;
+public final class TileBuilders {
+ public static class Tile {
+ public static class Builder {
+ public Builder setResourcesVersion(String v) { return this; }
+ public Builder setFreshnessIntervalMillis(long m) { return this; }
+ public Builder setTileTimeline(TimelineBuilders.Timeline t) { return this; }
+ public Tile build() { return null; }
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas
new file mode 100644
index 00000000000..b8ec7cb64e3
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas
@@ -0,0 +1,10 @@
+package androidx.wear.tiles;
+import android.content.Context;
+import androidx.wear.protolayout.ResourceBuilders;
+import com.google.common.util.concurrent.ListenableFuture;
+public abstract class TileService extends Context {
+ protected abstract ListenableFuture onTileRequest(
+ RequestBuilders.TileRequest request);
+ protected abstract ListenableFuture onTileResourcesRequest(
+ RequestBuilders.ResourcesRequest request);
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas
new file mode 100644
index 00000000000..36e156fb955
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas
@@ -0,0 +1,2 @@
+package androidx.wear.watchface.complications.data;
+public abstract class ComplicationData { }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas
new file mode 100644
index 00000000000..7b9d543ad04
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas
@@ -0,0 +1,2 @@
+package androidx.wear.watchface.complications.data;
+public interface ComplicationText { }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas
new file mode 100644
index 00000000000..270ed560aac
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas
@@ -0,0 +1,8 @@
+package androidx.wear.watchface.complications.data;
+public final class ComplicationType {
+ public static final ComplicationType SHORT_TEXT = new ComplicationType();
+ public static final ComplicationType LONG_TEXT = new ComplicationType();
+ public static final ComplicationType RANGED_VALUE = new ComplicationType();
+ public static final ComplicationType MONOCHROMATIC_IMAGE = new ComplicationType();
+ public static final ComplicationType SMALL_IMAGE = new ComplicationType();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas
new file mode 100644
index 00000000000..dfb905563b2
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas
@@ -0,0 +1,5 @@
+package androidx.wear.watchface.complications.data;
+/** Stub mirroring the real API: a reference instant a countdown runs toward. */
+public class CountDownTimeReference {
+ public CountDownTimeReference(java.time.Instant instant) { }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas
new file mode 100644
index 00000000000..6929e879efe
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas
@@ -0,0 +1,5 @@
+package androidx.wear.watchface.complications.data;
+/** Stub mirroring the real API: a reference instant a stopwatch counts up from. */
+public class CountUpTimeReference {
+ public CountUpTimeReference(java.time.Instant instant) { }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas
new file mode 100644
index 00000000000..53d13b5328c
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas
@@ -0,0 +1,11 @@
+package androidx.wear.watchface.complications.data;
+import android.app.PendingIntent;
+public class LongTextComplicationData extends ComplicationData {
+ public static class Builder {
+ public Builder setValidTimeRange(TimeRange r) { return this; }
+ public Builder(ComplicationText text, ComplicationText contentDescription) { }
+ public Builder setTitle(ComplicationText t) { return this; }
+ public Builder setTapAction(PendingIntent p) { return this; }
+ public LongTextComplicationData build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas
new file mode 100644
index 00000000000..63e99816351
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas
@@ -0,0 +1,8 @@
+package androidx.wear.watchface.complications.data;
+import android.graphics.drawable.Icon;
+public class MonochromaticImage {
+ public static class Builder {
+ public Builder(Icon image) { }
+ public MonochromaticImage build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas
new file mode 100644
index 00000000000..e342bf6602a
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas
@@ -0,0 +1,10 @@
+package androidx.wear.watchface.complications.data;
+import android.app.PendingIntent;
+public class MonochromaticImageComplicationData extends ComplicationData {
+ public static class Builder {
+ public Builder setValidTimeRange(TimeRange r) { return this; }
+ public Builder(MonochromaticImage image, ComplicationText contentDescription) { }
+ public Builder setTapAction(PendingIntent p) { return this; }
+ public MonochromaticImageComplicationData build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas
new file mode 100644
index 00000000000..7ae016ca65c
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas
@@ -0,0 +1,2 @@
+package androidx.wear.watchface.complications.data;
+public class NoDataComplicationData extends ComplicationData { public NoDataComplicationData() { } }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas
new file mode 100644
index 00000000000..0f24cd85a91
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas
@@ -0,0 +1,7 @@
+package androidx.wear.watchface.complications.data;
+public class PlainComplicationText implements ComplicationText {
+ public static class Builder {
+ public Builder(CharSequence text) { }
+ public PlainComplicationText build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas
new file mode 100644
index 00000000000..641f53c3f6d
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas
@@ -0,0 +1,11 @@
+package androidx.wear.watchface.complications.data;
+import android.app.PendingIntent;
+public class RangedValueComplicationData extends ComplicationData {
+ public static class Builder {
+ public Builder setValidTimeRange(TimeRange r) { return this; }
+ public Builder(float value, float min, float max, ComplicationText contentDescription) { }
+ public Builder setText(ComplicationText t) { return this; }
+ public Builder setTapAction(PendingIntent p) { return this; }
+ public RangedValueComplicationData build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas
new file mode 100644
index 00000000000..245ad5e97bd
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas
@@ -0,0 +1,11 @@
+package androidx.wear.watchface.complications.data;
+import android.app.PendingIntent;
+public class ShortTextComplicationData extends ComplicationData {
+ public static class Builder {
+ public Builder setValidTimeRange(TimeRange r) { return this; }
+ public Builder(ComplicationText text, ComplicationText contentDescription) { }
+ public Builder setTitle(ComplicationText t) { return this; }
+ public Builder setTapAction(PendingIntent p) { return this; }
+ public ShortTextComplicationData build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas
new file mode 100644
index 00000000000..222717ea0f7
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas
@@ -0,0 +1,16 @@
+package androidx.wear.watchface.complications.data;
+/**
+ * Stub mirroring the real API. The two constructor overloads are the point: a countdown and a
+ * count-up are distinguished by the TYPE of the reference, not by a flag, so a generated source
+ * that passes the wrong one does not compile here either.
+ */
+public class TimeDifferenceComplicationText implements ComplicationText {
+ public static class Builder {
+ public Builder(TimeDifferenceStyle style, CountUpTimeReference reference) { }
+ public Builder(TimeDifferenceStyle style, CountDownTimeReference reference) { }
+ public Builder setText(CharSequence text) { return this; }
+ public Builder setDisplayAsNow(boolean displayAsNow) { return this; }
+ public Builder setMinimumTimeUnit(java.util.concurrent.TimeUnit unit) { return this; }
+ public TimeDifferenceComplicationText build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas
new file mode 100644
index 00000000000..c80adecd1a1
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas
@@ -0,0 +1,5 @@
+package androidx.wear.watchface.complications.data;
+/** Stub mirroring the real enum. Only the constants the generated source names are listed. */
+public enum TimeDifferenceStyle {
+ STOPWATCH, SHORT_SINGLE_UNIT, SHORT_DUAL_UNIT, WORDS_SINGLE_UNIT, SHORT_WORDS_SINGLE_UNIT
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas
new file mode 100644
index 00000000000..ebe762dc176
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas
@@ -0,0 +1,7 @@
+package androidx.wear.watchface.complications.data;
+public final class TimeRange {
+ public static final TimeRange ALWAYS = null;
+ public static TimeRange after(java.time.Instant i) { return null; }
+ public static TimeRange before(java.time.Instant i) { return null; }
+ public static TimeRange between(java.time.Instant a, java.time.Instant b) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas
new file mode 100644
index 00000000000..b21c6292413
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas
@@ -0,0 +1,14 @@
+package androidx.wear.watchface.complications.datasource;
+import android.content.Context;
+import androidx.wear.watchface.complications.data.ComplicationData;
+import androidx.wear.watchface.complications.data.ComplicationType;
+public abstract class ComplicationDataSourceService extends Context {
+ public interface ComplicationRequestListener {
+ void onComplicationData(ComplicationData d);
+ // Default in the real API, which is why a service that overrides neither still compiles;
+ // this is the one a timeline answer uses.
+ void onComplicationDataTimeline(ComplicationDataTimeline t);
+ }
+ public abstract void onComplicationRequest(ComplicationRequest r, ComplicationRequestListener l);
+ public abstract ComplicationData getPreviewData(ComplicationType type);
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas
new file mode 100644
index 00000000000..4f94137df42
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas
@@ -0,0 +1,6 @@
+package androidx.wear.watchface.complications.datasource;
+import androidx.wear.watchface.complications.data.ComplicationData;
+public final class ComplicationDataTimeline {
+ public ComplicationDataTimeline(ComplicationData defaultData,
+ java.util.Collection entries) { }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas
new file mode 100644
index 00000000000..16c3d489434
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas
@@ -0,0 +1,3 @@
+package androidx.wear.watchface.complications.datasource;
+import androidx.wear.watchface.complications.data.ComplicationType;
+public class ComplicationRequest { public ComplicationType getComplicationType() { return null; } }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas
new file mode 100644
index 00000000000..8ef90b83d5c
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas
@@ -0,0 +1,4 @@
+package androidx.wear.watchface.complications.datasource;
+public final class TimeInterval {
+ public TimeInterval(java.time.Instant start, java.time.Instant end) { }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas
new file mode 100644
index 00000000000..7cb2a3bc225
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas
@@ -0,0 +1,5 @@
+package androidx.wear.watchface.complications.datasource;
+import androidx.wear.watchface.complications.data.ComplicationData;
+public final class TimelineEntry {
+ public TimelineEntry(TimeInterval validity, ComplicationData data) { }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas
new file mode 100644
index 00000000000..7c4c416252b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas
@@ -0,0 +1,2 @@
+package com.google.common.util.concurrent;
+public interface ListenableFuture { }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas
new file mode 100644
index 00000000000..46f14161c84
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas
@@ -0,0 +1,6 @@
+package org.json;
+public class JSONArray {
+ public int length() { return 0; }
+ public JSONObject optJSONObject(int i) { return null; }
+ public int optInt(int i) { return 0; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas
new file mode 100644
index 00000000000..71d3194a950
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas
@@ -0,0 +1,17 @@
+package org.json;
+public class JSONObject {
+ public JSONObject() { }
+ public JSONObject(String s) { }
+ public boolean has(String k) { return false; }
+ public String optString(String k, String d) { return d; }
+ public String optString(String k) { return ""; }
+ public int optInt(String k, int d) { return d; }
+ public int optInt(String k) { return 0; }
+ public long optLong(String k) { return 0L; }
+ public long optLong(String k, long d) { return d; }
+ public double optDouble(String k, double d) { return d; }
+ public Object opt(String k) { return null; }
+ public JSONObject optJSONObject(String k) { return null; }
+ public JSONArray optJSONArray(String k) { return null; }
+ public String toString() { return ""; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/app/Service.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/app/Service.javas
new file mode 100644
index 00000000000..d4142108ebd
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/app/Service.javas
@@ -0,0 +1,6 @@
+package android.app;
+public class Service extends android.content.Context {
+ public void onCreate() { }
+ public void onDestroy() { }
+ public int onStartCommand(android.content.Intent i, int f, int id) { return 0; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Context.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Context.javas
new file mode 100644
index 00000000000..121a0ab9b8d
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Context.javas
@@ -0,0 +1,12 @@
+package android.content;
+public class Context {
+ public static final int MODE_PRIVATE = 0;
+ public String getPackageName() { return ""; }
+ public SharedPreferences getSharedPreferences(String n, int m) { return null; }
+ public void startActivity(Intent i) { }
+ public java.io.File getFilesDir() { return null; }
+ public java.io.File getCacheDir() { return null; }
+ public Context getApplicationContext() { return this; }
+ public android.content.pm.ApplicationInfo getApplicationInfo() { return null; }
+ public android.content.pm.PackageManager getPackageManager() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Intent.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Intent.javas
new file mode 100644
index 00000000000..7a66f2b9df9
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Intent.javas
@@ -0,0 +1,18 @@
+package android.content;
+public class Intent {
+ public static final int FLAG_ACTIVITY_NEW_TASK = 0x10000000;
+ public Intent() { }
+ public Intent(String action) { }
+ public Intent(Context c, Class> k) { }
+ public Intent putExtra(String n, String v) { return this; }
+ public Intent putExtra(String n, byte[] v) { return this; }
+ public Intent putExtra(String n, boolean v) { return this; }
+ public Intent putExtra(String n, int v) { return this; }
+ public Intent putExtra(String n, long v) { return this; }
+ public Intent setFlags(int f) { return this; }
+ public Intent addFlags(int f) { return this; }
+ public Intent setPackage(String p) { return this; }
+ public void setData(android.net.Uri u) { }
+ public String getDataString() { return ""; }
+ public String getAction() { return ""; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas
new file mode 100644
index 00000000000..c6ed100ecd3
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas
@@ -0,0 +1,22 @@
+package android.content;
+public interface SharedPreferences {
+ String getString(String k, String def);
+ long getLong(String k, long def);
+ int getInt(String k, int def);
+ boolean getBoolean(String k, boolean def);
+ java.util.Set getStringSet(String k, java.util.Set def);
+ boolean contains(String k);
+ java.util.Map getAll();
+ Editor edit();
+ interface Editor {
+ Editor putString(String k, String v);
+ Editor putLong(String k, long v);
+ Editor putInt(String k, int v);
+ Editor putBoolean(String k, boolean v);
+ Editor putStringSet(String k, java.util.Set v);
+ Editor remove(String k);
+ Editor clear();
+ boolean commit();
+ void apply();
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas
new file mode 100644
index 00000000000..12e4c5fe87c
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas
@@ -0,0 +1,4 @@
+package android.content.pm;
+public class ApplicationInfo {
+ public String packageName;
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas
new file mode 100644
index 00000000000..5acf6d0eb24
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas
@@ -0,0 +1,4 @@
+package android.content.pm;
+public class PackageManager {
+ public android.content.Intent getLaunchIntentForPackage(String pkg) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/net/Uri.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/net/Uri.javas
new file mode 100644
index 00000000000..90c3388ad2d
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/net/Uri.javas
@@ -0,0 +1,14 @@
+package android.net;
+public class Uri {
+ public static Uri parse(String s) { return null; }
+ public static String encode(String s) { return s; }
+ public String getPath() { return ""; }
+ public String getHost() { return ""; }
+ public String toString() { return ""; }
+ public static class Builder {
+ public Builder scheme(String s) { return this; }
+ public Builder authority(String a) { return this; }
+ public Builder path(String p) { return this; }
+ public Uri build() { return null; }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Handler.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Handler.javas
new file mode 100644
index 00000000000..3a24942c847
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Handler.javas
@@ -0,0 +1,10 @@
+package android.os;
+
+/**
+ * Stub mirroring the real API. postDelayed is what the surface mirror uses to run its stale-image
+ * sweep once the grace has passed, so the signature is pinned here.
+ */
+public class Handler {
+ public Handler(Looper looper) { }
+ public boolean postDelayed(Runnable r, long delayMillis) { return true; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Looper.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Looper.javas
new file mode 100644
index 00000000000..443b53c6373
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Looper.javas
@@ -0,0 +1,5 @@
+package android.os;
+public class Looper {
+ public static Looper getMainLooper() { return null; }
+ public static Looper myLooper() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Base64.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Base64.javas
new file mode 100644
index 00000000000..968f828b4a0
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Base64.javas
@@ -0,0 +1,7 @@
+package android.util;
+public class Base64 {
+ public static final int DEFAULT = 0;
+ public static final int NO_WRAP = 2;
+ public static String encodeToString(byte[] input, int flags) { return ""; }
+ public static byte[] decode(String str, int flags) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Log.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Log.javas
new file mode 100644
index 00000000000..fa7a28901a1
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Log.javas
@@ -0,0 +1,10 @@
+package android.util;
+public class Log {
+ public static int v(String t, String m) { return 0; }
+ public static int d(String t, String m) { return 0; }
+ public static int i(String t, String m) { return 0; }
+ public static int w(String t, String m) { return 0; }
+ public static int w(String t, String m, Throwable e) { return 0; }
+ public static int e(String t, String m) { return 0; }
+ public static int e(String t, String m, Throwable e) { return 0; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas
new file mode 100644
index 00000000000..27fffc8fe1b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas
@@ -0,0 +1,5 @@
+package com.google.android.gms.common.data;
+public interface Freezable {
+ T freeze();
+ boolean isDataValid();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas
new file mode 100644
index 00000000000..aca83f2e960
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas
@@ -0,0 +1,2 @@
+package com.google.android.gms.tasks;
+public interface OnCompleteListener { void onComplete(Task task); }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas
new file mode 100644
index 00000000000..ce32e8cc92e
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas
@@ -0,0 +1,2 @@
+package com.google.android.gms.tasks;
+public interface OnFailureListener { void onFailure(Exception e); }
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas
new file mode 100644
index 00000000000..cbc0dbca5c0
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas
@@ -0,0 +1,9 @@
+package com.google.android.gms.tasks;
+public abstract class Task {
+ public abstract T getResult();
+ public abstract Exception getException();
+ public abstract boolean isSuccessful();
+ public abstract boolean isComplete();
+ public Task addOnCompleteListener(OnCompleteListener l) { return this; }
+ public Task addOnFailureListener(OnFailureListener l) { return this; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas
new file mode 100644
index 00000000000..9bfda2a744b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas
@@ -0,0 +1,11 @@
+package com.google.android.gms.tasks;
+public class Tasks {
+ public static T await(Task t) throws java.util.concurrent.ExecutionException,
+ InterruptedException { return null; }
+ public static T await(Task t, long timeout, java.util.concurrent.TimeUnit unit)
+ throws java.util.concurrent.ExecutionException, InterruptedException,
+ java.util.concurrent.TimeoutException { return null; }
+ public static Task>> whenAllComplete(
+ java.util.Collection extends Task>> tasks) { return null; }
+ public static Task>> whenAllComplete(Task>... tasks) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas
new file mode 100644
index 00000000000..a141dd82ddc
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas
@@ -0,0 +1,8 @@
+package com.google.android.gms.wearable;
+public class Asset {
+ public static Asset createFromBytes(byte[] data) { return null; }
+ public static Asset createFromRef(String ref) { return null; }
+ public static Asset createFromUri(android.net.Uri uri) { return null; }
+ public android.net.Uri getUri() { return null; }
+ public String getDigest() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas
new file mode 100644
index 00000000000..5ac57062f05
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas
@@ -0,0 +1,10 @@
+package com.google.android.gms.wearable;
+import com.google.android.gms.tasks.Task;
+public abstract class CapabilityClient {
+ public static final int FILTER_ALL = 0;
+ public static final int FILTER_REACHABLE = 1;
+ public abstract Task addLocalCapability(String capability);
+ public abstract Task removeLocalCapability(String capability);
+ public abstract Task getCapability(String capability, int filter);
+ public abstract Task> getAllCapabilities(int filter);
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas
new file mode 100644
index 00000000000..c5934f8afb1
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas
@@ -0,0 +1,5 @@
+package com.google.android.gms.wearable;
+public interface CapabilityInfo {
+ String getName();
+ java.util.Set getNodes();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas
new file mode 100644
index 00000000000..f5a0bd0d50b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas
@@ -0,0 +1,16 @@
+package com.google.android.gms.wearable;
+import com.google.android.gms.tasks.Task;
+public abstract class DataClient {
+ public abstract Task putDataItem(PutDataRequest request);
+ public abstract Task getDataItem(android.net.Uri uri);
+ public abstract Task getDataItems();
+ public abstract Task getDataItems(android.net.Uri uri);
+ public abstract Task getDataItems(android.net.Uri uri, int filter);
+ public abstract Task deleteDataItems(android.net.Uri uri);
+ public abstract Task deleteDataItems(android.net.Uri uri, int filter);
+ public abstract Task getFdForAsset(Asset asset);
+ public abstract Task getFdForAsset(DataItemAsset asset);
+ public interface GetFdForAssetResponse {
+ java.io.InputStream getInputStream();
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas
new file mode 100644
index 00000000000..c2243033d40
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas
@@ -0,0 +1,8 @@
+package com.google.android.gms.wearable;
+import com.google.android.gms.common.data.Freezable;
+public interface DataEvent extends Freezable {
+ int TYPE_CHANGED = 1;
+ int TYPE_DELETED = 2;
+ int getType();
+ DataItem getDataItem();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas
new file mode 100644
index 00000000000..5f249bc2bde
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas
@@ -0,0 +1,7 @@
+package com.google.android.gms.wearable;
+public class DataEventBuffer implements Iterable {
+ public java.util.Iterator iterator() { return null; }
+ public int getCount() { return 0; }
+ public DataEvent get(int i) { return null; }
+ public void release() { }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas
new file mode 100644
index 00000000000..c221a023481
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas
@@ -0,0 +1,8 @@
+package com.google.android.gms.wearable;
+import com.google.android.gms.common.data.Freezable;
+public interface DataItem extends Freezable {
+ android.net.Uri getUri();
+ DataItem setData(byte[] data);
+ java.util.Map getAssets();
+ byte[] getData();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas
new file mode 100644
index 00000000000..2fa23dd2959
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas
@@ -0,0 +1,5 @@
+package com.google.android.gms.wearable;
+public interface DataItemAsset {
+ String getId();
+ String getDataItemKey();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas
new file mode 100644
index 00000000000..d410cacc915
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas
@@ -0,0 +1,7 @@
+package com.google.android.gms.wearable;
+public class DataItemBuffer implements Iterable {
+ public java.util.Iterator iterator() { return null; }
+ public int getCount() { return 0; }
+ public DataItem get(int i) { return null; }
+ public void release() { }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas
new file mode 100644
index 00000000000..7943fc0f28e
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas
@@ -0,0 +1,26 @@
+package com.google.android.gms.wearable;
+public class DataMap {
+ public static DataMap fromByteArray(byte[] b) { return null; }
+ public byte[] toByteArray() { return null; }
+ public java.util.Set keySet() { return null; }
+ public boolean containsKey(String k) { return false; }
+ public Object remove(String k) { return null; }
+ public void putString(String k, String v) { }
+ public void putLong(String k, long v) { }
+ public void putInt(String k, int v) { }
+ public void putBoolean(String k, boolean v) { }
+ public void putByteArray(String k, byte[] v) { }
+ public void putAsset(String k, Asset v) { }
+ public void putStringArrayList(String k, java.util.ArrayList v) { }
+ public String getString(String k) { return null; }
+ public String getString(String k, String def) { return def; }
+ public long getLong(String k) { return 0L; }
+ public long getLong(String k, long def) { return def; }
+ public int getInt(String k) { return 0; }
+ public int getInt(String k, int def) { return def; }
+ public boolean getBoolean(String k) { return false; }
+ public boolean getBoolean(String k, boolean def) { return def; }
+ public byte[] getByteArray(String k) { return null; }
+ public Asset getAsset(String k) { return null; }
+ public java.util.ArrayList getStringArrayList(String k) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas
new file mode 100644
index 00000000000..ba9cd71b8c6
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas
@@ -0,0 +1,6 @@
+package com.google.android.gms.wearable;
+public class DataMapItem {
+ public static DataMapItem fromDataItem(DataItem item) { return null; }
+ public android.net.Uri getUri() { return null; }
+ public DataMap getDataMap() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas
new file mode 100644
index 00000000000..4c59da54a09
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas
@@ -0,0 +1,5 @@
+package com.google.android.gms.wearable;
+import com.google.android.gms.tasks.Task;
+public abstract class MessageClient {
+ public abstract Task sendMessage(String nodeId, String path, byte[] data);
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas
new file mode 100644
index 00000000000..7cbc971768d
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas
@@ -0,0 +1,13 @@
+package com.google.android.gms.wearable;
+/**
+ * Deliberately NOT Freezable, which is what the real interface says.
+ *
+ *
DataEvent below extends Freezable and this does not. Handing MessageEvent a freeze() it
+ * does not have is exactly the mistake this stub tree exists to catch.
+ */
+public interface MessageEvent {
+ int getRequestId();
+ String getPath();
+ String getSourceNodeId();
+ byte[] getData();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas
new file mode 100644
index 00000000000..eba20afbdcf
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas
@@ -0,0 +1,6 @@
+package com.google.android.gms.wearable;
+public interface Node {
+ String getDisplayName();
+ String getId();
+ boolean isNearby();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas
new file mode 100644
index 00000000000..8a85cd13eab
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas
@@ -0,0 +1,6 @@
+package com.google.android.gms.wearable;
+import com.google.android.gms.tasks.Task;
+public abstract class NodeClient {
+ public abstract Task> getConnectedNodes();
+ public abstract Task getLocalNode();
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas
new file mode 100644
index 00000000000..ef367809721
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas
@@ -0,0 +1,9 @@
+package com.google.android.gms.wearable;
+public class PutDataMapRequest {
+ public static PutDataMapRequest create(String path) { return null; }
+ public static PutDataMapRequest createWithAutoAppendedId(String path) { return null; }
+ public DataMap getDataMap() { return null; }
+ public android.net.Uri getUri() { return null; }
+ public PutDataMapRequest setUrgent() { return this; }
+ public PutDataRequest asPutDataRequest() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas
new file mode 100644
index 00000000000..2d9294af7c1
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas
@@ -0,0 +1,11 @@
+package com.google.android.gms.wearable;
+public class PutDataRequest {
+ public static final String WEAR_URI_SCHEME = "wear";
+ public static PutDataRequest create(String path) { return null; }
+ public static PutDataRequest createWithAutoAppendedId(String path) { return null; }
+ public PutDataRequest setData(byte[] data) { return this; }
+ public PutDataRequest putAsset(String key, Asset asset) { return this; }
+ public PutDataRequest setUrgent() { return this; }
+ public android.net.Uri getUri() { return null; }
+ public byte[] getData() { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas
new file mode 100644
index 00000000000..6b03184c406
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas
@@ -0,0 +1,7 @@
+package com.google.android.gms.wearable;
+public class Wearable {
+ public static DataClient getDataClient(android.content.Context c) { return null; }
+ public static MessageClient getMessageClient(android.content.Context c) { return null; }
+ public static NodeClient getNodeClient(android.content.Context c) { return null; }
+ public static CapabilityClient getCapabilityClient(android.content.Context c) { return null; }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas
new file mode 100644
index 00000000000..624ebc97478
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas
@@ -0,0 +1,10 @@
+package com.google.android.gms.wearable;
+public class WearableListenerService extends android.app.Service {
+ public void onDataChanged(DataEventBuffer events) { }
+ public void onMessageReceived(MessageEvent event) { }
+ public void onCapabilityChanged(CapabilityInfo info) { }
+ public void onPeerConnected(Node peer) { }
+ public void onPeerDisconnected(Node peer) { }
+ public void onCreate() { }
+ public void onDestroy() { }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceRasterizerTest.java b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceRasterizerTest.java
index c0b3ec0f72b..76679cd3047 100644
--- a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceRasterizerTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceRasterizerTest.java
@@ -122,6 +122,62 @@ void layoutForSizePrefersTheExplicitSizeAndFallsBackToDefault() {
assertSame(defaultLayout, SurfaceRasterizer.layoutForSize(doc, null));
}
+ /// A corner complication is round and Wear OS has no corner slot at all, so the circular
+ /// layout is the closest thing to what the developer designed -- closer than "default",
+ /// which may well be a rectangular phone widget. The platform renderers substitute the same
+ /// way, and this is what keeps a preview honest about what the device will show.
+ @Test
+ void watchCornerFallsBackToCircularBeforeDefault() {
+ Map defaultLayout = new LinkedHashMap();
+ defaultLayout.put("t", "col");
+ Map circular = new LinkedHashMap();
+ circular.put("t", "vec");
+ Map layouts = new LinkedHashMap();
+ layouts.put("default", defaultLayout);
+ layouts.put("watchCircular", circular);
+ Map doc = new LinkedHashMap();
+ doc.put("layouts", layouts);
+
+ assertSame(circular, SurfaceRasterizer.layoutForSize(doc, "watchCorner"));
+ assertSame(circular, SurfaceRasterizer.layoutForSize(doc, "watchCircular"));
+ // No circular layout to borrow: default, as before.
+ layouts.remove("watchCircular");
+ assertSame(defaultLayout, SurfaceRasterizer.layoutForSize(doc, "watchCorner"));
+ }
+
+ /// watchRectangular and lockscreen are the same WidgetKit family on Apple, so an app that
+ /// published only one of them still gets a layout designed for that shape.
+ @Test
+ void watchRectangularFallsBackToLockscreen() {
+ Map defaultLayout = new LinkedHashMap();
+ defaultLayout.put("t", "col");
+ Map lockscreen = new LinkedHashMap();
+ lockscreen.put("t", "row");
+ Map layouts = new LinkedHashMap();
+ layouts.put("default", defaultLayout);
+ layouts.put("lockscreen", lockscreen);
+ Map doc = new LinkedHashMap();
+ doc.put("layouts", layouts);
+
+ assertSame(lockscreen, SurfaceRasterizer.layoutForSize(doc, "watchRectangular"));
+ }
+
+ /// An explicit layout always wins over a substitute.
+ @Test
+ void anExplicitWatchLayoutIsNeverSubstituted() {
+ Map circular = new LinkedHashMap();
+ circular.put("t", "vec");
+ Map corner = new LinkedHashMap();
+ corner.put("t", "text");
+ Map layouts = new LinkedHashMap();
+ layouts.put("watchCircular", circular);
+ layouts.put("watchCorner", corner);
+ Map doc = new LinkedHashMap();
+ doc.put("layouts", layouts);
+
+ assertSame(corner, SurfaceRasterizer.layoutForSize(doc, "watchCorner"));
+ }
+
@Test
void layoutForSizeReturnsNullWhenAbsent() {
assertNull(SurfaceRasterizer.layoutForSize(null, "small"));
diff --git a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceTest.java b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceTest.java
index bda213545c5..b5eecb2a627 100644
--- a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceTest.java
@@ -35,6 +35,7 @@
import java.util.List;
import java.util.Map;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -52,7 +53,7 @@
class SurfaceTest {
/** Records bridge calls so publishing and live activity behaviour can be asserted. */
- private static final class FakeBridge implements SurfaceBridge {
+ private static class FakeBridge implements SurfaceBridge {
boolean widgetsSupported = true;
boolean activitiesSupported = true;
String publishedKind;
@@ -434,6 +435,65 @@ void publishForwardsToBridgeAndNoBridgeIsNoOp() {
assertNull(bridge.reloadedKind);
}
+ /// A publish is a write followed by a hand-off to the watch, and the platform bridges pair
+ /// them by doing both inside this one call. Two threads publishing one kind must therefore
+ /// not be inside it at once: interleaved, the later write can be paired with the earlier
+ /// hand-off and the watch keeps a descriptor the phone has already replaced. publish() is
+ /// documented as callable from any thread, so this is a supported call pattern.
+ @Test
+ void concurrentPublishesOfOneKindDoNotInterleave() throws Exception {
+ final java.util.concurrent.atomic.AtomicInteger inFlight =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.atomic.AtomicInteger peak =
+ new java.util.concurrent.atomic.AtomicInteger();
+ FakeBridge bridge = new FakeBridge() {
+ @Override
+ public void publishWidgetTimeline(String kindId, String timelineJson,
+ Map images) {
+ int now = inFlight.incrementAndGet();
+ // Highest seen, not last seen: the failing interleaving is transient.
+ while (true) {
+ int was = peak.get();
+ if (now <= was || peak.compareAndSet(was, now)) {
+ break;
+ }
+ }
+ try {
+ // Wide enough that an unserialized run overlaps rather than merely being
+ // able to. Without the lock this test fails essentially every time.
+ Thread.sleep(20);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ inFlight.decrementAndGet();
+ super.publishWidgetTimeline(kindId, timelineJson, images);
+ }
+ };
+ Surfaces.setBridge(bridge);
+ Surfaces.registerWidgetKind(new WidgetKind("delivery_status")
+ .setDisplayName("Delivery").addSupportedSize(WidgetSize.SMALL));
+
+ Thread[] threads = new Thread[6];
+ for (int i = 0; i < threads.length; i++) {
+ final int n = i;
+ threads[i] = new Thread(new Runnable() {
+ public void run() {
+ Surfaces.publish("delivery_status",
+ new WidgetTimeline().setContent(new SurfaceText("v" + n)));
+ }
+ });
+ }
+ for (Thread t : threads) {
+ t.start();
+ }
+ for (Thread t : threads) {
+ t.join();
+ }
+
+ assertEquals(1, peak.get());
+ assertEquals("delivery_status", bridge.publishedKind);
+ }
+
@Test
void invalidKindIdsAreRejected() {
assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() {
@@ -851,6 +911,52 @@ void diagnosticsCanBeForcedOff() {
.setContent(new SurfaceText("x")));
}
+ /// A timeline names its images rather than embedding them -- the serializer hashes the bytes
+ /// and puts the hash on the wire -- so a descriptor produced elsewhere is only complete if
+ /// its side-map came with it. publishRemote used to discard the map unconditionally, which
+ /// made every referenced image render as a gap.
+ @Test
+ void publishRemoteCarriesTheImagesTheDescriptorReferences() {
+ FakeBridge bridge = new FakeBridge();
+ Surfaces.setBridge(bridge);
+ Map images = new LinkedHashMap();
+ images.put("abc123", new byte[] {1, 2, 3});
+
+ Surfaces.publishRemote("scores", "{\"layouts\":{}}", images);
+
+ assertEquals("scores", bridge.publishedKind);
+ assertEquals(1, bridge.publishedImages.size());
+ assertArrayEquals(new byte[] {1, 2, 3}, bridge.publishedImages.get("abc123"));
+ }
+
+ /// The two-argument form is the older entry point and must keep behaving as it did.
+ @Test
+ void publishRemoteWithoutImagesPassesAnEmptyMap() {
+ FakeBridge bridge = new FakeBridge();
+ Surfaces.setBridge(bridge);
+
+ Surfaces.publishRemote("scores", "{\"layouts\":{}}");
+
+ assertEquals("scores", bridge.publishedKind);
+ assertTrue(bridge.publishedImages.isEmpty());
+ // And a null map is the same thing, not a crash.
+ Surfaces.publishRemote("scores", "{\"layouts\":{}}", null);
+ assertTrue(bridge.publishedImages.isEmpty());
+ }
+
+ /// Nothing reaches an unsupported platform, with or without imagery.
+ @Test
+ void publishRemoteIsInertWhereWidgetsAreUnsupported() {
+ FakeBridge bridge = new FakeBridge();
+ bridge.widgetsSupported = false;
+ Surfaces.setBridge(bridge);
+
+ Surfaces.publishRemote("scores", "{}", new LinkedHashMap());
+ Surfaces.publishRemote("scores", "{}");
+
+ assertNull(bridge.publishedKind);
+ }
+
@Test
void kindSerializationIncludesSizesAndDefaults() throws Exception {
WidgetKind k = new WidgetKind("scores");
diff --git a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java
new file mode 100644
index 00000000000..9d50de859f5
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.surfaces;
+
+import com.codename1.io.JSONParser;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.StringReader;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// The wire-format facts a Wear complication and Tile reader depends on.
+///
+/// Both readers live in the Android port and cannot be unit tested here, and both got these
+/// wrong: they looked for container children under `c` and for a dynamic node's value under
+/// `text`. Neither mistake fails to compile and neither throws -- a complication just renders
+/// empty, which is indistinguishable from an app that published nothing. Pinning the field names
+/// against the serializer is what makes that kind of drift visible.
+class SurfaceWatchWireFormatTest {
+
+ @SuppressWarnings("unchecked")
+ private static Map parse(String json) throws Exception {
+ return new JSONParser().parseJSON(new StringReader(json));
+ }
+
+ private static Map serializeRoot(SurfaceNode root) throws Exception {
+ Map images = new HashMap();
+ String json = SurfaceSerializer.serializeTimeline("k",
+ new WidgetTimeline().setContent(root), images);
+ Map doc = parse(json);
+ Map layouts = (Map) doc.get("layouts");
+ return (Map) layouts.get("default");
+ }
+
+ /// A container's children are `ch`. A reader looking for `c` finds an empty container and
+ /// mines a layout with no text, no progress and no imagery in it.
+ @Test
+ @SuppressWarnings("unchecked")
+ void containersSerializeTheirChildrenUnderCh() throws Exception {
+ Map root = serializeRoot(new SurfaceColumn()
+ .add(new SurfaceText("first"))
+ .add(new SurfaceText("second")));
+
+ assertEquals("col", root.get("t"));
+ assertTrue(root.get("ch") instanceof List, "children belong under \"ch\": " + root);
+ assertEquals(2, ((List) root.get("ch")).size());
+ assertTrue(root.get("c") == null, "nothing is published under \"c\": " + root);
+ }
+
+ /// Rows and boxes use the same key, so a reader that special-cases one of the three is wrong
+ /// about the other two.
+ @Test
+ void everyContainerKindUsesTheSameChildrenKey() throws Exception {
+ assertNotNull(serializeRoot(new SurfaceRow().add(new SurfaceText("x"))).get("ch"));
+ assertNotNull(serializeRoot(new SurfaceBox().add(new SurfaceText("x"))).get("ch"));
+ assertNotNull(serializeRoot(new SurfaceColumn().add(new SurfaceText("x"))).get("ch"));
+ }
+
+ /// Padding is a four-element array in wire order `[top, right, bottom, left]`, not an object
+ /// keyed by side. A reader asking for an object gets null for every valid descriptor and
+ /// silently drops all declared padding.
+ @Test
+ @SuppressWarnings("unchecked")
+ void paddingSerializesAsAnArrayInTopRightBottomLeftOrder() throws Exception {
+ Map root = serializeRoot(
+ new SurfaceBox().setPadding(1, 2, 3, 4).add(new SurfaceText("x")));
+
+ Object pad = root.get("pad");
+ assertTrue(pad instanceof List, "padding belongs in an array: " + root);
+ List values = (List) pad;
+ assertEquals(4, values.size());
+ assertEquals(1, ((Number) values.get(0)).intValue(), "top");
+ assertEquals(2, ((Number) values.get(1)).intValue(), "right");
+ assertEquals(3, ((Number) values.get(2)).intValue(), "bottom");
+ assertEquals(4, ((Number) values.get(3)).intValue(), "left");
+ }
+
+ /// A vector node names no image, which is why a surface that has to key one needs something
+ /// other than the absent name -- and something stable across two separate parses of the same
+ /// timeline, since a Tile requests its layout and its resources in different calls.
+ @Test
+ void aVectorNodeCarriesNoImageName() throws Exception {
+ Map root = serializeRoot(new SurfaceVector(100, 100)
+ .fillRect(0, 0, 10, 10, SurfaceColor.rgb(0xff0000)));
+
+ assertEquals("vec", root.get("t"));
+ assertTrue(root.get("name") == null, "a vector publishes no image name: " + root);
+ }
+
+ /// A dynamic node carries a style and a date, never a `text` field. A reader interpolating
+ /// `text` gets an empty string and the countdown silently disappears.
+ @Test
+ void dynamicTextSerializesAStyleAndADateRatherThanText() throws Exception {
+ Map root = serializeRoot(
+ new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_DOWN,
+ new java.util.Date(1700000000000L)));
+
+ assertEquals("dyn", root.get("t"));
+ assertEquals("timerDown", root.get("style"));
+ assertNotNull(root.get("date"), "a literal date belongs under \"date\": " + root);
+ assertTrue(root.get("text") == null, "a dyn node publishes no \"text\": " + root);
+ }
+
+ /// The state-driven form names its key instead, which a reader has to resolve against the
+ /// entry rather than reading a literal.
+ @Test
+ void aStateDrivenDynamicNodeNamesItsKey() throws Exception {
+ Map root = serializeRoot(
+ new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_DOWN, "deadline"));
+
+ assertEquals("deadline", root.get("dateKey"));
+ assertTrue(root.get("text") == null, "a dyn node publishes no \"text\": " + root);
+ }
+
+ /// The formatter a surface without a native ticking widget uses. Public so the Wear readers
+ /// share it rather than each formatting for themselves -- a countdown has to read the same on
+ /// a watch face as in the simulator preview.
+ @Test
+ void theSharedFormatterCoversEveryStyle() {
+ long now = 1700000000000L;
+ assertEquals("1:00", SurfaceRasterizer.formatDynamicText("timerDown", now + 60000, now));
+ assertEquals("1:00", SurfaceRasterizer.formatDynamicText("timerUp", now - 60000, now));
+ assertNotNull(SurfaceRasterizer.formatDynamicText("time", now, now));
+ assertNotNull(SurfaceRasterizer.formatDynamicText("date", now, now));
+ assertNotNull(SurfaceRasterizer.formatDynamicText("relative", now - 3600000, now));
+ }
+}
diff --git a/scripts/android/lib/PatchGradleFiles.java b/scripts/android/lib/PatchGradleFiles.java
index 2d9252d6005..8d3585be372 100644
--- a/scripts/android/lib/PatchGradleFiles.java
+++ b/scripts/android/lib/PatchGradleFiles.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -32,15 +54,30 @@ public static void main(String[] args) throws Exception {
}
boolean modifiedRoot = patchRootBuildGradle(arguments.root);
- boolean modifiedApp = patchAppBuildGradle(arguments.app, arguments.compileSdk, arguments.targetSdk);
-
if (modifiedRoot) {
System.out.println("Patched " + arguments.root);
}
- if (modifiedApp) {
- System.out.println("Patched " + arguments.app);
+ // Every application module, not only app/. A companion Wear build adds wear/, which is a
+ // second application module with its own compileSdkVersion -- left unpinned it takes the
+ // newest platform installed on the runner, and a platform that has dropped an API the
+ // Codename One port still compiles against (FingerprintManager went in API 37) fails only
+ // that module, in a file nobody edited.
+ boolean modifiedAny = modifiedRoot;
+ for (Path module : arguments.apps) {
+ if (!Files.isRegularFile(module)) {
+ System.out.println("Skipping absent module build.gradle " + module);
+ continue;
+ }
+ // The FIRST --app is the module the instrumentation suite runs against; anything
+ // after it is a companion that only needs its SDK levels pinned.
+ boolean instrumented = module.equals(arguments.apps.get(0));
+ if (patchAppBuildGradle(module, arguments.compileSdk, arguments.targetSdk,
+ instrumented)) {
+ System.out.println("Patched " + module);
+ modifiedAny = true;
+ }
}
- if (!modifiedRoot && !modifiedApp) {
+ if (!modifiedAny) {
System.out.println("Gradle files already normalized");
}
}
@@ -93,7 +130,19 @@ private static boolean patchRootBuildGradle(Path path) throws IOException {
return changed;
}
- private static boolean patchAppBuildGradle(Path path, int compileSdk, int targetSdk) throws IOException {
+ /**
+ * Patches one application module.
+ *
+ *
{@code instrumented} tells the SDK pins apart from the test harness. Every application
+ * module needs the pins -- an unpinned one takes the newest platform on the runner, which is
+ * how a Wear module ended up compiling against an API that had dropped a class the port
+ * uses. None of the rest belongs anywhere but the module the suite actually runs against: a
+ * companion Wear module has no instrumentation sources, so a runner, test dependencies and a
+ * coverage report task there are configuration for tests that do not exist, and the report
+ * finalizer fails on a module with nothing to report.
+ */
+ private static boolean patchAppBuildGradle(Path path, int compileSdk, int targetSdk,
+ boolean instrumented) throws IOException {
String content = Files.readString(path, StandardCharsets.UTF_8);
boolean changed = false;
@@ -101,21 +150,23 @@ private static boolean patchAppBuildGradle(Path path, int compileSdk, int target
content = r.content();
changed |= r.changed();
- r = ensureInstrumentationRunner(content);
- content = r.content();
- changed |= r.changed();
-
r = removeLegacyUseLibrary(content);
content = r.content();
changed |= r.changed();
- r = ensureTestDependencies(content);
- content = r.content();
- changed |= r.changed();
+ if (instrumented) {
+ r = ensureInstrumentationRunner(content);
+ content = r.content();
+ changed |= r.changed();
- r = ensureJacocoConfiguration(content);
- content = r.content();
- changed |= r.changed();
+ r = ensureTestDependencies(content);
+ content = r.content();
+ changed |= r.changed();
+
+ r = ensureJacocoConfiguration(content);
+ content = r.content();
+ changed |= r.changed();
+ }
if (changed) {
Files.writeString(path, ensureTrailingNewline(content), StandardCharsets.UTF_8);
@@ -357,20 +408,21 @@ private record Result(String content, boolean changed) {
private static class Arguments {
final Path root;
- final Path app;
+ /** Every application module to pin; --app may be repeated. */
+ final java.util.List apps;
final int compileSdk;
final int targetSdk;
- Arguments(Path root, Path app, int compileSdk, int targetSdk) {
+ Arguments(Path root, java.util.List apps, int compileSdk, int targetSdk) {
this.root = root;
- this.app = app;
+ this.apps = apps;
this.compileSdk = compileSdk;
this.targetSdk = targetSdk;
}
static Arguments parse(String[] args) {
Path root = null;
- Path app = null;
+ java.util.List apps = new java.util.ArrayList<>();
int compileSdk = 36;
int targetSdk = 36;
for (int i = 0; i < args.length; i++) {
@@ -388,7 +440,7 @@ static Arguments parse(String[] args) {
System.err.println("Missing value for --app");
return null;
}
- app = Path.of(args[++i]);
+ apps.add(Path.of(args[++i]));
}
case "--compile-sdk" -> {
if (i + 1 >= args.length) {
@@ -410,11 +462,11 @@ static Arguments parse(String[] args) {
}
}
}
- if (root == null || app == null) {
- System.err.println("--root and --app are required");
+ if (root == null || apps.isEmpty()) {
+ System.err.println("--root and at least one --app are required");
return null;
}
- return new Arguments(root, app, compileSdk, targetSdk);
+ return new Arguments(root, apps, compileSdk, targetSdk);
}
}
}
diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh
index e51fa1fcc87..afd186e96e7 100755
--- a/scripts/build-android-app.sh
+++ b/scripts/build-android-app.sh
@@ -189,9 +189,19 @@ if [ ! -x "$PATCH_GRADLE_JAVA" ]; then
exit 1
fi
+PATCH_GRADLE_MODULES=(--app "$APP_BUILD_GRADLE")
+# A companion Wear build adds a second application module. It needs the same compileSdk pin as
+# the phone one: unpinned it picks the newest platform on the runner, and an API the port still
+# compiles against can be gone there (FingerprintManager is absent from API 37).
+WEAR_BUILD_GRADLE="$GRADLE_PROJECT_DIR/wear/build.gradle"
+if [ -f "$WEAR_BUILD_GRADLE" ]; then
+ ba_log "Wear module present; pinning its SDK levels too"
+ PATCH_GRADLE_MODULES+=(--app "$WEAR_BUILD_GRADLE")
+fi
+
"$PATCH_GRADLE_JAVA" "$PATCH_GRADLE_SOURCE_PATH/$PATCH_GRADLE_MAIN_CLASS.java" \
--root "$ROOT_BUILD_GRADLE" \
- --app "$APP_BUILD_GRADLE" \
+ "${PATCH_GRADLE_MODULES[@]}" \
--compile-sdk 36 \
--target-sdk 36
# --- END: robust Gradle patch ---
@@ -225,7 +235,17 @@ export JAVA_HOME="${JDK_HOME:-$JAVA17_HOME}"
)
export JAVA_HOME="$ORIGINAL_JAVA_HOME"
-APK_PATH=$(find "$GRADLE_PROJECT_DIR" -path "*/outputs/apk/debug/*.apk" | head -n 1 || true)
+# The PHONE module's APK, named explicitly. A companion build assembles two application
+# modules, so an unqualified find returns whichever the filesystem happened to walk first --
+# and traversal order is not a contract about which artifact is the product. Callers install
+# what this reports, so picking the watch-only APK would hand them the wrong app.
+APK_PATH=$(find "$GRADLE_PROJECT_DIR/app" -path "*/outputs/apk/debug/*.apk" 2>/dev/null | head -n 1 || true)
+if [ -z "$APK_PATH" ]; then
+ # A project whose module is not called "app" -- or a layout without one -- falls back to the
+ # old search, minus anything under a wear module, which is never the phone artifact.
+ APK_PATH=$(find "$GRADLE_PROJECT_DIR" -path "*/outputs/apk/debug/*.apk" \
+ -not -path "*/wear/*" | head -n 1 || true)
+fi
[ -n "$APK_PATH" ] || { ba_log "Gradle build completed but no APK was found" >&2; exit 1; }
ba_log "Successfully built Android APK at $APK_PATH"
diff --git a/scripts/hellocodenameone/common/src/main/resources/surfaces.json b/scripts/hellocodenameone/common/src/main/resources/surfaces.json
index 48792f09547..180bb06c54a 100644
--- a/scripts/hellocodenameone/common/src/main/resources/surfaces.json
+++ b/scripts/hellocodenameone/common/src/main/resources/surfaces.json
@@ -1,12 +1,12 @@
{
- "_comment": "Build-time widget kinds manifest for the cn1ss device suite. Referencing com.codename1.surfaces from the suite (Surfaces* tests) makes the iOS/Android builders require this manifest: widget kinds are compiled into the native widget gallery, so they cannot be registered at runtime only. The id mirrors the runtime Surfaces.registerWidgetKind call in SurfacesPublishTest. liveActivities keeps the ActivityKit lowering (CN1LiveActivityWidget.swift, NSSupportsLiveActivities, the Android ongoing-notification manager) compiled and exercised by every platform CI leg.",
+ "_comment": "Build-time widget kinds manifest for the cn1ss device suite. Referencing com.codename1.surfaces from the suite (Surfaces* tests) makes the iOS/Android builders require this manifest: widget kinds are compiled into the native widget gallery, so they cannot be registered at runtime only. The id mirrors the runtime Surfaces.registerWidgetKind call in SurfacesPublishTest. liveActivities keeps the ActivityKit lowering (CN1LiveActivityWidget.swift, NSSupportsLiveActivities, the Android ongoing-notification manager) compiled and exercised by every platform CI leg. The watch families are here for the same reason: the suite declares codename1.watchMain, so they make the build-ios-watch job compile the generated CN1WatchWidgets extension for watchOS on every PR -- which is the only automated check that the shared surfaces Swift stays portable to a platform without UIGraphicsImageRenderer or the system widget families. 'families' is the portable spelling of 'iosFamilies'.",
"liveActivities": true,
"kinds": [
{
"id": "cn1ss_status",
"name": "CN1SS Status",
"description": "Surfaces suite status widget",
- "iosFamilies": ["small", "medium"],
+ "families": ["small", "medium", "watchCircular", "watchRectangular"],
"androidMinWidthDp": 180,
"androidMinHeightDp": 60
}
diff --git a/scripts/run-watch-ui-tests.sh b/scripts/run-watch-ui-tests.sh
index 761479c3639..206ebedf177 100755
--- a/scripts/run-watch-ui-tests.sh
+++ b/scripts/run-watch-ui-tests.sh
@@ -127,6 +127,32 @@ BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/I
[ -z "$BUNDLE_ID" ] && { rw_log "Could not read CFBundleIdentifier from $APP_PATH"; exit 5; }
rw_log "Built $APP_PATH (bundle $BUNDLE_ID)"
+# --- The complication extension --------------------------------------------
+# The suite's surfaces.json declares watch families, so the watch app must carry a
+# CN1WatchWidgets.appex in its PlugIns folder. Checking it here is what proves the target was
+# created, built for watchOS and embedded at the right nesting -- none of which the screenshot
+# comparison can see, and none of which simctl can exercise (there is no API to place a
+# complication on a watch face). A Swift portability break in the shared surfaces sources fails
+# the xcodebuild above; this catches the wiring instead.
+APPEX="$APP_PATH/PlugIns/CN1WatchWidgets.appex"
+if [ ! -d "$APPEX" ]; then
+ rw_log "watch complication extension missing at $APPEX"
+ rw_log "PlugIns contains: $(ls "$APP_PATH/PlugIns" 2>/dev/null || echo '')"
+ exit 5
+fi
+APPEX_POINT="$(/usr/libexec/PlistBuddy -c 'Print :NSExtension:NSExtensionPointIdentifier' \
+ "$APPEX/Info.plist" 2>/dev/null || true)"
+if [ "$APPEX_POINT" != "com.apple.widgetkit-extension" ]; then
+ rw_log "complication extension declares '$APPEX_POINT', expected com.apple.widgetkit-extension"
+ exit 5
+fi
+# Without the app group the extension has no container to read the published timeline from, so
+# it would launch and render its placeholder forever.
+APPEX_GROUP="$(/usr/libexec/PlistBuddy -c 'Print :CN1SurfacesAppGroup' "$APPEX/Info.plist" \
+ 2>/dev/null || true)"
+[ -z "$APPEX_GROUP" ] && { rw_log "complication extension declares no CN1SurfacesAppGroup"; exit 5; }
+rw_log "Complication extension embedded: $APPEX (app group $APPEX_GROUP)"
+
# --- Screenshot capture: host WS sink + the streaming watch app -------------
JAVA_BIN="${JAVA17_BIN:-$(command -v java)}"
cn1ss_setup "$JAVA_BIN" "$CN1SS_HELPER_SOURCE_DIR"