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(); log("surfaces.json declares no widget kinds; only live activities are available"); } + // Resolved once, from the whole declared set, BEFORE any name is used: which kind + // keeps the plain folded name and which takes the positional form is a property of + // the set, not of an id on its own. + surfaceKindClassNames.clear(); + List declaredKindIds = new ArrayList(); + for (Object surfaceKindEntry : surfaceKinds) { + if (surfaceKindEntry instanceof Map) { + Object declaredId = ((Map) surfaceKindEntry).get("id"); + if (declaredId instanceof String && ((String) declaredId).length() > 0) { + declaredKindIds.add((String) declaredId); + } + } + } + surfaceKindClassNames.putAll(surfaceKindClassSuffixes(declaredKindIds)); + writeSurfaceKindClassMap(resDir); + for (Map.Entry named : surfaceKindClassNames.entrySet()) { + if (!named.getValue().equals(surfaceKindClassSuffix(named.getKey()))) { + // Never silently: this kind's provider is not found under the name its id + // suggests, and the developer is the only one who can rename the id. + log("WARNING: widget kinds '" + named.getKey() + "' and another declared kind " + + "produce the same class name '" + + surfaceKindClassSuffix(named.getKey()) + "'. This one is generated " + + "as CN1Widget_" + named.getValue() + " instead. Rename one of the " + + "ids so they differ by more than where the underscores are."); + } + } // the pre-baked layouts the generic renderer composes at runtime String[] surfaceLayouts = { @@ -3490,7 +3559,40 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { throw new BuildException("Invalid widget kind id '" + kindId + "' in surfaces.json; ids must match [a-z][a-z0-9_]*"); } - String providerClass = "CN1Widget_" + surfaceKindClassSuffix(kindId); + java.util.List kindFamilies = + com.codename1.util.SurfaceKindFamilies.read(surfaceKind); + // A name this framework does not know is a typo, and it used to be a silent one: + // isWatch tested for a "watch" prefix, so "watchCircle" suppressed the kind's + // phone widget and turned on watch codegen while every mapping downstream + // recognised only the real four -- leaving the kind with no surface on any + // platform and a build that went green. isWatch is now exact, which turns the + // typo into a plain phone family instead; say so rather than quietly rendering + // a home-screen widget the author did not ask for. + for (String declared : kindFamilies) { + if (!com.codename1.util.SurfaceKindFamilies.isKnown(declared)) { + throw new BuildException("Widget kind '" + kindId + + "' in surfaces.json declares the family '" + declared + + "', which is not one this framework knows. The watch families " + + "are watchCircular, watchRectangular, watchInline and " + + "watchCorner; the phone families are small, medium, large and " + + "lockscreen."); + } + } + boolean watchBearing = + com.codename1.util.SurfaceKindFamilies.hasWatchFamily(kindFamilies); + if (watchBearing) { + watchSurfaceKinds.add(new String[] {kindId, + surfaceKind.get("name") instanceof String + ? (String) surfaceKind.get("name") : kindId, + joinFamilies(kindFamilies)}); + } + if (!com.codename1.util.SurfaceKindFamilies.hasPhoneFamily(kindFamilies)) { + // A complication is not a home-screen widget, and rendering one as though it + // were puts a surface in front of the user that the manifest never asked for. + // iOS already refuses the same thing, so this is the two platforms agreeing. + continue; + } + String providerClass = "CN1Widget_" + surfaceKindClassName(kindId); String providerSource = "package com.codename1.impl.android;\n\n" + "/** Generated by the Codename One build from surfaces.json. */\n" + "public class " + providerClass @@ -3537,9 +3639,12 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } // the invisible trampoline that turns a widget/live-activity tap into a // Surfaces.dispatchAction call and brings the main activity forward + // Exported only for a Tile, and only when this module is the watch product; see + // anyWatchTile. A phone build keeps it private, which is what it has always been. + boolean tileTrampoline = "app".equals(watchModuleName(request)) && anyWatchTile(); surfaceReceivers.append(" \n"); @@ -3558,9 +3663,95 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { // runtime permission declared (permissionAdd dedups against user overrides) postNotificationsPermission = true; } + reportWatchSurfaces(request); + watchSurfacesManifestEntries = generateWatchSurfaces(request, srcDir, resDir); + if (watchSurfacesManifestEntries.length() > 0 && "app".equals(watchModuleName(request))) { + // Standalone: this module is the watch product, so its own dependency list and + // its own floor are the right places for both. + request.putArgument("gradleDependencies", + request.getArg("gradleDependencies", "") + "\n" + + watchSurfaceDependencies); + // Wear OS 3 is the floor for the complication data source and Tile APIs, and in a + // STANDALONE build the app module IS the watch product, so its floor has to rise. + // + // Only then. In a companion build the phone module is a phone app that happens to + // ship a watch beside it, and raising this would have made a phone APK supporting + // API 21-25 uninstallable on the devices it already served. generateWearModule + // raises 26 on the wear module alone, which is where the libraries actually land. + minSDK = maxInt("26", minSDK); + } + } + + // AFTER the surfaces manifest is parsed, because the decision below reads the kinds it + // produces. Run before it, watchSurfaceKinds was always empty -- so an app that publishes + // complications and never writes a line of com.codename1.wearable got no Data Layer glue, + // no dependency and an empty listener declaration, and the mirror it was meant to enable + // had no transport at either end. + // The mirror rides the Data Layer, and the app that publishes a complication need never + // have written a line of com.codename1.wearable -- so the class scan alone would leave the + // mirror with no transport in exactly the apps that want one. Skipped under the legacy + // Play services monolith, where adding the wearable artifact is a hard conflict. + if (!usesWearable && !watchSurfaceKinds.isEmpty() && watchModuleName(request) != null + && !legacyGplayServicesMode) { + log("[wearable] Enabling the Wearable Data Layer glue: watch-bearing surface kinds " + + "are declared, so a phone-side Surfaces.publish() of one is mirrored to the " + + "watch. This adds play-services-wearable to the app."); + usesWearable = true; + } else if (!usesWearable && !watchSurfaceKinds.isEmpty() + && watchModuleName(request) != null && legacyGplayServicesMode) { + // Said out loud rather than skipped in silence. The complication services still + // generate and a watch-side publish still works, but a phone-side one does not reach + // the watch, and nothing else in the build would tell the developer that -- they + // would be looking at a complication that never updates from the phone and no reason + // for it. Not a hard failure, because adding the wearable artifact under the legacy + // Play services monolith is a dependency conflict this build would lose. + log("[wearable] android.includeGPlayServices=true, so the Wearable Data Layer glue " + + "cannot be added -- the wearable artifact conflicts with the legacy Play " + + "services monolith. The complication and Tile services are still generated " + + "and the watch app's own Surfaces.publish() still feeds them, but a " + + "PHONE-side publish will NOT be mirrored to the watch. Drop " + + "android.includeGPlayServices to get the mirror."); + } + 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); + } } + // We need to choose the correct PlayServices class file for the version of play services // we are building for. File androidImpl = new File(srcDir, "com/codename1/impl/android"); @@ -4181,7 +4372,14 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { remoteControlService = ""; mediabuttonReceiver = ""; } + // The Wear manifest needs these too. A watch lifecycle that plays audio reaches the same + // AudioService through the same shared implementation, and remote controls need the same + // service and media-button receiver -- all of which the wear module compiles either way, + // so the declarations are the only thing missing. + watchMediaComponents = mediaService + "\n" + remoteControlService + "\n" + + mediabuttonReceiver + "\n"; String alarmRecevier = "\n"; + watchAlarmReceiver = alarmRecevier; String backgroundLocationReceiver = "\n"; if (!playServicesLocation) { backgroundLocationReceiver = ""; @@ -4189,10 +4387,20 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { String backgroundFetchService = "\n"+ "\n"; + // The Wear manifest needs these too: a watch lifecycle that declares background fetch is + // woken through the same handler and the same trampoline activity, and the wear module + // compiles them either way -- so without the declarations the fetch the watch asked for + // simply never arrives. + watchBackgroundFetchService = backgroundFetchService; + // Constraint-aware background work always registers the JobScheduler service // (harmless when unused). The foreground service entry is emitted only when the // app references com.codename1.background.ForegroundService. String backgroundWorkService = "\n"; + // The Wear manifest needs it too: the watch lifecycle can call + // Display.scheduleBackgroundWork, which schedules this component through JobScheduler, and + // a component the manifest does not declare cannot be scheduled. + watchBackgroundWorkService = backgroundWorkService; String foregroundServiceEntry = ""; if (usesForegroundService) { foregroundServicePermission = true; @@ -4200,6 +4408,14 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { foregroundServiceEntry = "\n"; } + // The Wear manifest needs these too. A watch lifecycle using background location, + // geofencing or ForegroundService reaches the same components through the same shared + // implementation, and the wear module compiles them either way -- so the declarations + // are the only thing missing, and a component the manifest does not declare cannot be + // started at all. + watchFeatureComponents = locationServices + backgroundLocationReceiver + + foregroundServiceEntry; + // Receive-shared-content: register the share receiver activity with SEND / // SEND_MULTIPLE intent filters for the mime types named by android.shareFilter // (comma separated). When the app references SharedContent but sets no explicit @@ -4229,6 +4445,13 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + dataLines + " \n" + "\n"; + // The watch too. Its manifest is selected outright rather than merged, so nothing + // else declares this -- yet the wear module compiles the SAME receiver and the same + // lifecycle that handles what it delivers. Without the declaration ACTION_SEND and + // ACTION_SEND_MULTIPLE cannot resolve to the watch app at all, so a companion whose + // watch half is meant to accept shared content silently never appears in the share + // sheet there. + watchFeatureComponents += shareReceiverActivity; } // Host card emulation service is generated only when the classpath @@ -4599,6 +4822,13 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { " \n"); } + // Held for the companion Wear manifest, which is generated independently and would + // otherwise never see these. A watchMain that reads media on Wear OS 4 needs the same + // READ_MEDIA_* declarations the phone half gets, or the runtime permission cannot be + // granted and every read fails on the watch alone -- and the same is true of external + // storage on API 26 to 29, where the watch would be refused an operation the phone + // artifact is permitted. + watchSharedPermissions = readMediaPermissions + externalStoragePermission; String xmlizedDisplayName = xmlize(request.getDisplayName()); String applicationAttr = request.getArg("android.xapplication_attr", ""); @@ -4651,6 +4881,12 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { " android:resource=\"@xml/file_paths\">\n" + " \n" + " "; + // Held for the companion Wear manifest, which is generated independently. The wear module + // compiles the same shared implementation and packages the same file_paths.xml, so a + // watch that shares or opens a local file on API 24+ needs the same provider -- and a + // watch that schedules a local notification needs the receiver that delivers it when the + // app is not running. + watchProviderTag = providerTag; if (!providerTag.isEmpty()) { File filePathsFile = new File(xmlDir, "file_paths.xml"); @@ -4706,15 +4942,19 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } } + // Held for the companion Wear manifest, which is generated independently. The wear + // module compiles the same generated CN1FirebaseMessagingService, resolves the same + // Firebase dependencies and now carries the same google-services.json -- everything but + // the declaration that lets Play services bind it, so a push arriving on the watch had + // nothing to deliver to. + watchPushManifestEntries = pushManifestEntries; + String launchMode = request.getArg("android.activity.launchMode", "singleTop"); String xActivity = request.getArg("android.xactivity", ""); if (!xActivity.contains("android:exported")) { xActivity += " android:exported=\"true\""; } - String activityTheme = "@style/CustomTheme"; - if (extendAppCompatActivity) { - activityTheme = "@@style/Theme.AppCompat.NoActionBar"; - } + String activityTheme = launcherTheme(); String manifestSource = "\n" + "\n" + " \n" @@ -4835,7 +5080,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { String localNotificationCode = ""; localNotificationCode = "" - + " if(i instanceof com.codename1.notifications.LocalNotificationCallback){\n" + + " if(((Object)i) instanceof com.codename1.notifications.LocalNotificationCallback){\n" + " Intent intent = getIntent();\n" + " if(intent != null && intent.getExtras() != null && intent.getExtras().containsKey(\"LocalNotificationID\")){\n" + " String id = intent.getExtras().getString(\"LocalNotificationID\");\n" @@ -4855,7 +5100,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " }\n" + " }\n" + " }\n" - + " ((com.codename1.notifications.LocalNotificationCallback)i).localNotificationReceived(id);\n" + + " ((com.codename1.notifications.LocalNotificationCallback)(Object)i).localNotificationReceived(id);\n" + " }\n" + " }\n" + " com.codename1.impl.android.AndroidImplementation.setCurrentApplicationInstance(i);\n" @@ -5340,13 +5585,13 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " }\n" + " if (i == null) {\n" + " i = new " + appLifecycleClass(request) + "();\n" - + " if(i instanceof PushCallback) {\n" - + " com.codename1.impl.CodenameOneImplementation.setPushCallback((PushCallback)i);\n" + + " if(((Object)i) instanceof PushCallback) {\n" + + " com.codename1.impl.CodenameOneImplementation.setPushCallback((PushCallback)(Object)i);\n" + " }\n"; stubSourceCode += - " if (i instanceof com.codename1.push.PushActionsProvider) {\n" - + " try{AndroidImplementation.installNotificationActionCategories((com.codename1.push.PushActionsProvider)i);}catch(java.io.IOException ex){ex.printStackTrace();}\n" + " if (((Object)i) instanceof com.codename1.push.PushActionsProvider) {\n" + + " try{AndroidImplementation.installNotificationActionCategories((com.codename1.push.PushActionsProvider)(Object)i);}catch(java.io.IOException ex){ex.printStackTrace();}\n" + " }\n"; } catch (Exception ex) { @@ -5934,6 +6179,10 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } catch (IOException ex) { throw new BuildException("Failed to write stub source file", ex); } + // Kept so the companion Wear module can derive its own stub from it. The stub is typed + // throughout to the lifecycle class it starts, so a subclass cannot re-root it -- the + // watch one is the same generated source with a different entry point substituted. + generatedStubSource = stubSourceCode; try { File projectPropertiesFile = new File(projectDir, "project.properties"); @@ -6587,6 +6836,10 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { throw new BuildException("Failed to write gradle properties to "+gradleFile, ex); } + generateWearModule(request, studioProjectDir, gradleProps, watchSurfacesManifestEntries, + basePermissions + permissions + xPermissions + watchSharedPermissions, + intVersion, wearableListenerService); + String rootGradleProps = "// Top-level build file where you can add configuration options common to all sub-projects/modules.\n" + "buildscript {\n" + " repositories {\n" + @@ -6824,6 +7077,22 @@ static String androidInferenceProguardRules(boolean inferenceIncluded) { : ""; } + /** + * {@link #xmlize} plus the quote escape an ATTRIBUTE value needs. + * + *

xmlize handles the three characters that matter in element content and leaves the double + * quote alone, which is right there and wrong inside {@code android:label="..."} -- a display + * name containing one closed the attribute early and the manifest stopped parsing, failing + * the build on a name the developer was entitled to choose. Single quotes go too, so the + * result is safe in either delimiter.

+ * + * @param s the value to place inside an XML attribute + * @return the escaped value + */ + static String xmlizeAttribute(String s) { + return xmlize(s).replace("\"", """).replace("'", "'"); + } + static String xmlize(String s) { s = s.replace("&", "&"); s = s.replace("<", "<"); @@ -6834,15 +7103,23 @@ static String xmlize(String s) { if (c > 127) { // we need to localize the string... StringBuilder b = new StringBuilder(); - for (int counter = 0; counter < charCount; counter++) { - c = s.charAt(counter); - if (c > 127) { + // By CODE POINT, not by char. A supplementary character -- an emoji in a display + // name is the ordinary way to get one -- is two chars in UTF-16, and escaping the + // halves separately emits a pair of surrogate code points such as + // ��. Those are not legal XML character references, so a manifest + // carrying one failed to parse at all rather than showing the wrong glyph. Every + // BMP character still escapes exactly as before, so nothing that was already + // valid changes. + for (int counter = 0; counter < charCount; ) { + int point = s.codePointAt(counter); + if (point > 127) { b.append("&#x"); - b.append(Integer.toHexString(c)); + b.append(Integer.toHexString(point)); b.append(";"); } else { - b.append(c); + b.append((char) point); } + counter += Character.charCount(point); } return b.toString(); } @@ -6853,9 +7130,17 @@ static String xmlize(String s) { /** * Maps a widget kind id to the simple name suffix of its generated provider class: * underscore-separated words become CamelCase ({@code delivery_status} -> - * {@code DeliveryStatus}). The identical logic lives in - * {@code com.codename1.impl.android.surfaces.AndroidSurfaceBridge#toClassSuffix} in the - * Android port; keep them in sync. + * {@code DeliveryStatus}). + * + *

This is the name shipped builds already use, and it has to keep being it. Android + * remembers a pinned widget by its provider {@code ComponentName}, so renaming the receiver + * of an existing kind does not merely regenerate a class -- the widget the user pinned then + * names a receiver that no longer exists, and the home screen drops it. That is the price of + * "just" making the fold injective, and users pay it silently on update.

+ * + *

So the fold stays as it shipped, and the ambiguity it does have is resolved elsewhere: + * {@link #surfaceKindClassSuffixes} hands the positional form only to a kind that would + * otherwise collide with one already holding the plain name.

*/ static String surfaceKindClassSuffix(String kindId) { StringBuilder sb = new StringBuilder(kindId.length()); @@ -6876,6 +7161,1197 @@ static String surfaceKindClassSuffix(String kindId) { return sb.toString(); } + /** + * The disambiguated form, used only when the plain fold is already taken. + * + *

Records where the underscores were, which is the only thing the fold discards -- so it + * separates exactly the ids the fold cannot, and changes nothing else. The positions and not + * a count: a count separates {@code status} from {@code status_} but not {@code a__b} from + * {@code a_b_}, which both discard two.

+ * + * @param kindId the declared kind id + * @return the folded name with the underscore positions appended + */ + static String surfaceKindClassSuffixDisambiguated(String kindId) { + StringBuilder positions = new StringBuilder(); + for (int i = 0; i < kindId.length(); i++) { + if (kindId.charAt(i) == '_') { + positions.append('_').append(i); + } + } + return surfaceKindClassSuffix(kindId) + positions; + } + + /** + * Names every declared kind's generated class, keeping the names shipped builds use. + * + *

The fold is not injective -- {@code status} and {@code status_} both read as + * {@code Status} -- so two kinds could be handed one class: the second overwrites the first + * and both manifest entries point at it, and one kind serves the other kind's data. Both ids + * are legal, so they have to be kept apart rather than one refused.

+ * + *

The first kind claiming a folded name keeps it, in declaration order; a later kind that + * would collide takes the positional form instead. Every project that builds today gets + * byte-identical names, because a collision would already have been a bug there.

+ * + * @param kindIds the declared kind ids, in declaration order + * @return kind id to class-name suffix, for every id given + */ + /** + * The names resolved for this build, from every declared kind id in declaration order. + * + *

Empty until the surfaces block has read surfaces.json. A kind absent from it -- which + * cannot happen for a declared one -- falls back to the plain fold, the same answer the + * runtime reaches without the generated map.

+ */ + private final Map surfaceKindClassNames = + new LinkedHashMap(); + + /** + * Writes the kind-to-class map the runtime reads. + * + *

Which kind holds the plain folded name is a property of the whole declared set, so a + * runtime holding one kind id cannot work it out. Probing for a class that exists is not the + * answer either -- {@code CN1Widget_Status} exists for {@code status}, so {@code status_} + * probing the plain name first finds the OTHER kind's provider and publishes into it.

+ * + *

So the build states it. This is data rather than a second copy of the algorithm: it is + * written from the very map that named the classes, so there is nothing for the two sides to + * disagree about. A build that writes no map -- an older APK -- leaves the runtime on the + * plain fold, which is exactly what that APK was built with.

+ * + * @param resDir the module's resource root + */ + private void writeSurfaceKindClassMap(File resDir) throws BuildException { + if (surfaceKindClassNames.isEmpty()) { + return; + } + StringBuilder items = new StringBuilder(); + for (Map.Entry named : surfaceKindClassNames.entrySet()) { + // "id=Suffix". A kind id is [a-z][a-z0-9_]* so it can never contain the separator. + items.append(" ").append(xmlize(named.getKey())).append('=') + .append(xmlize(named.getValue())).append("\n"); + } + File values = new File(resDir, "values"); + values.mkdirs(); + try { + createFile(new File(values, "cn1_surfaces_kinds.xml"), + ("\n" + + "\n" + + " \n" + + items + + " \n" + + "\n").getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to write the surface kind class map", ex); + } + } + + /** The generated class-name suffix for a kind, as resolved for this build. */ + private String surfaceKindClassName(String kindId) { + String resolved = surfaceKindClassNames.get(kindId); + return resolved != null ? resolved : surfaceKindClassSuffix(kindId); + } + + static Map surfaceKindClassSuffixes(List kindIds) { + Map out = new LinkedHashMap(); + Set taken = new HashSet(); + for (String kindId : kindIds) { + if (kindId == null || out.containsKey(kindId)) { + continue; + } + String plain = surfaceKindClassSuffix(kindId); + String chosen = plain; + if (!taken.add(plain)) { + // The positional form, which separates ids differing only in where the + // underscores are. It is NOT guaranteed free on its own: an id with no underscore + // has no positions to add, so its disambiguated form is the plain name it just + // lost -- declare "status_" before "status" and both wanted Status. So the answer + // is whatever is actually free, and the loop is what makes that true rather than + // hoped for. + chosen = surfaceKindClassSuffixDisambiguated(kindId); + for (int n = 2; !taken.add(chosen); n++) { + chosen = surfaceKindClassSuffixDisambiguated(kindId) + "_" + n; + } + } + out.put(kindId, chosen); + } + return out; + } + + /** + * Says what happens to the declared watch complication families, loudly and every time. + * + *

The failure this exists to prevent is silence. A developer declares a complication, + * hears nothing, and gets no surface: on a build with no watch product there is nothing to + * host it, and a watch-only kind no longer produces a home-screen widget either -- which is + * correct, and would otherwise look like the declaration was ignored.

+ * + * @param request the build being generated + */ + private void reportWatchSurfaces(BuildRequest request) { + if (watchSurfaceKinds.isEmpty()) { + return; + } + StringBuilder ids = new StringBuilder(); + for (String[] kind : watchSurfaceKinds) { + if (ids.length() > 0) { + ids.append(", "); + } + ids.append(kind[0]); + } + if (watchModuleName(request) == null) { + log("[surfaces] NOTE: these kinds declare watch complication families and will NOT " + + "appear on any device in this build: " + ids + ". They are hosted by a Wear " + + "OS product, and this build produces none -- declare codename1.watchMain " + + "(with codename1.watchStandalone for a watch-only APK), or declare a phone " + + "family alongside them."); + return; + } + log("[surfaces] Generating Wear OS complication data sources for: " + ids); + for (String[] kind : watchSurfaceKinds) { + String families = kind[2]; + if (families.contains("watchCorner")) { + log("[surfaces] Kind \"" + kind[0] + "\" declares watchCorner. Wear OS has no " + + "corner slot, so it renders as the circular family."); + } + if (declaresTile(families)) { + log("[surfaces] Kind \"" + kind[0] + "\" declares watchRectangular: generating a " + + "LONG_TEXT complication data source and a Tile."); + } + } + } + + /** + * Generates the Wear OS complication data sources and Tile services, and returns their + * manifest entries. + * + *

The generated classes carry nothing but their kind id: every decision lives in the + * injected {@code CN1ComplicationDataSource} / {@code CN1SurfaceTileService}, which ship as + * build-time resources because they compile against {@code androidx.wear} libraries the + * Android port must not depend on -- an app publishing no complication should not carry + * them.

+ * + *

Everything goes into the WATCH module, which is the phone module itself in a standalone + * build and a separate one in a companion build. One code path, one destination variable.

+ * + * @param request the build being generated + * @param srcDir the watch module's java source root + * @param resDir the watch module's resource root + * @return the manifest service entries, or an empty string when there is nothing to declare + */ + private String generateWatchSurfaces(BuildRequest request, File srcDir, File resDir) + throws BuildException { + String module = watchModuleName(request); + if (watchSurfaceKinds.isEmpty() || module == null) { + return ""; + } + // The WATCH module's own roots, which in a companion build are NOT the phone's. + // + // The wear module shares the phone's source directory, so anything written to srcDir is + // compiled by both -- and these import androidx.wear, whose dependencies belong to the + // wear module alone. A companion build therefore failed compiling the PHONE module + // against imports it has no libraries for. Writing them into the wear module's own root + // keeps them on the one side that can compile them; the shared source set still carries + // everything else. + File watchSrcDir = watchSourceRoot(module, srcDir); + File implDir = new File(watchSrcDir, "com/codename1/impl/android"); + implDir.mkdirs(); + File surfacesDir = new File(watchSrcDir, "com/codename1/impl/android/surfaces"); + surfacesDir.mkdirs(); + // The Tile service only when a Tile is actually declared. Gradle compiles every source in + // the tree whether or not a subclass names it, and its androidx.wear.tiles and + // protolayout dependencies are added only for a rectangular family -- so copying it + // unconditionally failed a complication-only build on unresolved imports. + for (String resource : watchSurfaceSources(watchSurfaceKinds)) { + InputStream in = getResourceAsStream( + "/com/codename1/builders/surfaces/wear/" + resource); + if (in == null) { + throw new BuildException("Missing Wear surfaces resource " + resource); + } + try { + copy(in, new FileOutputStream(new File(surfacesDir, resource))); + } catch (IOException ex) { + throw new BuildException("Failed to write Wear surfaces glue " + resource, ex); + } + } + StringBuilder entries = new StringBuilder(); + StringBuilder kindIds = new StringBuilder(); + for (String[] kind : watchSurfaceKinds) { + String kindId = kind[0]; + // RAW here: complicationServiceEntry and tileServiceEntry put it in an attribute and + // escape it themselves. Escaping first turned "A & B" into "A &amp; B", which the + // watch face then shows as "A & B". + String label = kind[1]; + String families = kind[2]; + String suffix = surfaceKindClassName(kindId); + writeWatchService(surfacesDir, implDir, "CN1Complication_" + suffix, + "CN1ComplicationDataSource", kindId); + entries.append(complicationServiceEntry(request, "CN1Complication_" + suffix, label, + complicationTypes(families))); + if (declaresTile(families)) { + writeWatchService(surfacesDir, implDir, "CN1Tile_" + suffix, + "CN1SurfaceTileService", kindId); + entries.append(tileServiceEntry("CN1Tile_" + suffix, label)); + } + if (kindIds.length() > 0) { + kindIds.append("\n "); + } + kindIds.append(kindId); + } + // Read by CN1WatchSurface.isWatchKind and by the mirror, so it has to land in the watch + // module's OWN res dir -- the same trap the wearable capability declaration documents + // just above, where an extra "app/" segment made the resource silently unpackaged. + // The PHONE's res dir, deliberately, even in a companion build. CN1SurfaceMirror reads + // this list on the PHONE to decide which kinds are worth sending, so putting it only in + // the wear module would leave the sender unable to see it and nothing would ever mirror. + // The same kind-to-class map the phone module gets. In a companion build this resDir is + // the WATCH module's own, and the complication and Tile services look their class names + // up exactly as the widget path does -- so the map has to be here too or the watch half + // falls back to the plain fold and misses a disambiguated kind. + writeSurfaceKindClassMap(resDir); + // The wear module shares the phone's res directory, so writing it here gives it to both. + File values = new File(resDir, "values"); + values.mkdirs(); + try { + createFile(new File(values, "cn1_surfaces_watch.xml"), + ("\n" + + "\n" + + " \n" + + " " + kindIds + "\n" + + " \n" + + "\n").getBytes("UTF-8")); + } catch (IOException ex) { + throw new BuildException("Failed to write the watch surface kind declaration", ex); + } + addWatchSurfaceDependencies(request); + return entries.toString(); + } + + /** Writes one generated service subclass, which carries only its kind id. */ + private void writeWatchService(File surfacesDir, File implDir, String className, + String baseClass, String kindId) throws BuildException { + String source = "package com.codename1.impl.android;\n\n" + + "/** Generated by the Codename One build from surfaces.json. */\n" + + "public class " + className + + " extends com.codename1.impl.android.surfaces." + baseClass + " {\n" + + " @Override\n" + + " protected String getKindId() {\n" + + " return \"" + kindId + "\";\n" + + " }\n" + + "}\n"; + try { + createFile(new File(implDir, className + ".java"), + source.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to generate " + className, ex); + } + } + + /** + * The manifest entry for a complication data source. + * + *

{@code UPDATE_PERIOD_SECONDS} defaults to 0 on purpose: the timeline model here is + * push-driven exactly like the widget path, so a system poll would spend watch battery + * asking a question the app has already answered.

+ */ + private String complicationServiceEntry(BuildRequest request, String className, String label, + String supportedTypes) { + return " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n"; + } + + private String tileServiceEntry(String className, String label) { + return " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n"; + } + + /** + * Adds the androidx.wear dependencies the generated services need. + * + *

Only when a complication is actually declared, and only the Tile half when a Tile is. + * concurrent-futures is not decoration: {@code TileService.onTileRequest} returns a + * {@code ListenableFuture} and the stub artifact tiles pulls in has no + * {@code Futures.immediateFuture}, so {@code CallbackToFutureAdapter} is the reliable + * Java-only route.

+ */ + /** + * The theme the app's launcher activity declares. + * + *

One resolution, because the companion Wear manifest is written independently of the + * phone's and needs the same answer: the generated stub extends {@code AppCompatActivity} + * when {@code android.extendAppCompatActivity} is set, and AppCompat refuses to start under + * a theme that is not a {@code Theme.AppCompat} descendant. A Wear launcher left on the + * platform default crashed on the first frame.

+ * + *

The AppCompat value used to be written as {@code @@style/...}. In an Android resource + * attribute a leading {@code @@} is the escape for a literal {@code @}, so that was the + * eight-character string "@style/Theme.AppCompat.NoActionBar" and not a reference to + * anything -- the theme was never applied. Corrected here rather than copied into a second + * manifest, since the branch only runs when a project has asked for AppCompat and the + * intent is not in doubt.

+ * + * @return the theme reference for the launcher activity + */ + private String launcherTheme() { + return extendAppCompatActivity + ? "@style/Theme.AppCompat.NoActionBar" : "@style/CustomTheme"; + } + + /** + * The Wear module's {@code }, or nothing when the project set no + * {@code android.xmanifest}. + * + *

The ATTRIBUTES only -- no {@code minSdkVersion} or {@code targetSdkVersion}. The wear + * module declares its own in build.gradle and the Gradle values win over the manifest, so + * repeating the phone's floors here would say something false about a module whose floor is + * deliberately higher.

+ * + *

Emitted only when the hint is set, so an unmodified project's Wear manifest is + * byte-identical to what it was.

+ * + * @param request the build being generated + * @return the element, or an empty string + */ + static String wearUsesSdk(BuildRequest request) { + String attributes = request.getArg("android.xmanifest", ""); + if (attributes == null || attributes.trim().length() == 0) { + return ""; + } + return " \n"; + } + + /** + * The Wear manifest's {@code } opening tag. + * + *

Written the way the phone manifest writes its own: a default is emitted only when + * {@code android.xapplication_attr} has not already said it. Emitting both produced the same + * attribute twice, and a duplicate attribute is not a merge conflict but an XML document that + * does not parse -- so a companion project that customised its label or icon failed before + * packaging, which is the whole build rather than the watch half of it.

+ * + * @param request the build being generated + * @return the opening tag, ending with the closing angle bracket and a newline + */ + + private String wearApplicationTag(BuildRequest request) { + String attrs = request.getArg("android.xapplication_attr", ""); + StringBuilder sb = new StringBuilder(" 0) { + sb.append(" ").append(attrs); + } + return sb.append(">\n").toString(); + } + + /** + * How far above the phone's the Wear artifact's version code sits by default. + * + *

Large enough to partition the space rather than merely satisfy the ordering rule. Play + * refuses a version code it has already seen for an applicationId, and the two artifacts + * share one -- so an offset of 1 makes the Wear artifact consume the code the next phone + * release needs. A hundred million is beyond any hand-maintained sequence and still leaves + * room under Play's ceiling for a project numbering in the millions.

+ */ + static final int DEFAULT_WATCH_VERSION_CODE_OFFSET = 100000000; + + /** Play's ceiling for a version code. */ + static final int MAX_PLAY_VERSION_CODE = 2100000000; + + /** + * Whether any declared kind earns a Tile, and so whether the tap trampoline has to be + * reachable from outside this app. + * + *

A complication's tap is a {@code PendingIntent} the app itself created, which the + * system can fire into a private activity. A Tile's is not: ProtoLayout's + * {@code LaunchAction} names a component and the TILE HOST starts it, from its own process, + * so a non-exported trampoline fails the permission check and the tap does nothing at all. + * Nothing in the build warns about it, because the manifest is valid.

+ * + *

Exporting an activity is not free -- any installed app can then start it with extras of + * its choosing, and this one dispatches an action id to the app's listeners -- so it is asked + * per build rather than granted once: a project with complications and no Tile keeps the + * trampoline private.

+ * + * @return true when at least one kind declares a Tile + */ + private boolean anyWatchTile() { + for (String[] kind : watchSurfaceKinds) { + if (declaresTile(kind[2])) { + return true; + } + } + return false; + } + + /** + * The androidx.wear dependency block for the Wear module. + * + *

Pure so it can be pinned directly. What has to stay true is a pairing rather than a + * list: the Tile half is optional, and every line it adds has to arrive together.

+ * + * @param compile the dependency keyword this build uses -- implementation, or compile on a + * legacy support-library build + * @param anyTile whether any declared kind earns a Tile + * @param complicationsVersion the watchface-complications-data-source version + * @param tilesVersion the tiles version + * @param protoLayoutVersion the protolayout version, used for both protolayout artifacts + * @param guavaVersion the Guava version floor; see the comment on the Guava line + * @return the dependency lines, each already indented for the block they are inserted into + */ + static String watchSurfaceDependencyBlock(String compile, boolean anyTile, + String complicationsVersion, String tilesVersion, String protoLayoutVersion, + String guavaVersion) { + StringBuilder deps = new StringBuilder(); + deps.append(" ").append(compile) + .append(" 'androidx.wear.watchface:watchface-complications-data-source:") + .append(complicationsVersion).append("'\n"); + if (anyTile) { + deps.append(" ").append(compile).append(" 'androidx.wear.tiles:tiles:") + .append(tilesVersion).append("'\n"); + deps.append(" ").append(compile) + .append(" 'androidx.wear.protolayout:protolayout:") + .append(protoLayoutVersion).append("'\n"); + deps.append(" ").append(compile) + .append(" 'androidx.wear.protolayout:protolayout-material:") + .append(protoLayoutVersion).append("'\n"); + // TileService.onTileRequest returns a ListenableFuture, and the stub artifact tiles + // pulls in has no Futures.immediateFuture, so CallbackToFutureAdapter is the reliable + // Java-only route. + deps.append(" ").append(compile) + .append(" 'androidx.concurrent:concurrent-futures:1.1.0'\n"); + // Guava, for one class. concurrent-futures and tiles both ask for + // com.google.guava:listenablefuture:1.0, a jar holding only ListenableFuture -- the + // type onTileRequest returns. Guava publishes the SAME coordinate at + // 9999.0-empty-to-avoid-conflict-with-guava containing no classes at all, so that a + // build carrying full Guava does not get the class twice; anything pulling the marker + // wins the version comparison and the real jar drops out. CameraX's graph does that + // to a Codename One app, and the import then fails in a generated Tile service the + // developer never wrote. + // + // So supply what the marker assumes is already there rather than fighting it. Forcing + // 1.0 back instead looks lighter and is wrong: an app whose graph ALREADY carries + // full Guava -- androidx.car.app brings 31.1-android -- then has ListenableFuture in + // two jars and fails checkDuplicateClasses instead, which is how this was found. The + // floor is deliberately low so a project already on a newer Guava keeps it; R8 takes + // the unused bulk back out of a release build. + deps.append(" ").append(compile).append(" 'com.google.guava:guava:") + .append(guavaVersion).append("'\n"); + } + return deps.toString(); + } + + private void addWatchSurfaceDependencies(BuildRequest request) throws BuildException { + // The same keyword the rest of the dependency block uses; AndroidX builds are + // "implementation" and the legacy ones "compile". + String compile = useAndroidX ? "implementation" : "compile"; + boolean anyTile = false; + for (String[] kind : watchSurfaceKinds) { + if (declaresTile(kind[2])) { + anyTile = true; + break; + } + } + String deps = watchSurfaceDependencyBlock(compile, anyTile, + request.getArg("android.wear.complicationsVersion", "1.2.1"), + request.getArg("android.wear.tilesVersion", "1.4.1"), + request.getArg("android.wear.protoLayoutVersion", "1.2.1"), + request.getArg("android.wear.guavaVersion", "31.1-android")); + // Held rather than pushed into the shared gradleDependencies hint, because in a + // COMPANION build that hint feeds the phone module too -- and these libraries declare + // minSdk 26. A phone app supporting API 24 then failed its manifest merge against a + // library it has no use for. Where they actually land is decided by the caller, which + // knows which module is the watch. + watchSurfaceDependencies = deps; + // These libraries are AndroidX-only, so a legacy support-library build cannot carry them. + // Said here, naming the setting, rather than failing later inside Gradle with a manifest + // merge error that names none of this. + if (!useAndroidX) { + throw new BuildException("Wear OS complications need AndroidX: the " + + "androidx.wear complication and Tile libraries have no support-library " + + "equivalent. Set android.useAndroidX=true (and android.useJetifier=true if " + + "you depend on older libraries), or remove the watch families from " + + "surfaces.json."); + } + } + + /** + * Generates the companion Wear OS module, so a companion build hands back a Wear artifact + * beside the phone one instead of only the phone app. + * + *

The module SHARES the app module's source, resource and asset directories rather than + * copying them. A copy would roughly double disk and dex time on a cloud builder for a tree + * that is identical apart from one class. What differs is declared here: its own generated + * stub rooted at {@code codename1.watchMain}, its own manifest, and the complication and Tile + * services.

+ * + *

Both modules declare the same namespace, which AGP permits and which is required + * rather than merely convenient: the shared sources sit in the app's own package and refer to + * {@code R} unqualified, so a second namespace would give the wear module an {@code R} class + * the shared code cannot see. The same {@code applicationId} is what makes Play treat the two + * artifacts as one app.

+ * + *

Nothing here runs for a project that declared no watch, or for a standalone build where + * the single APK already is the watch app.

+ * + * @param request the build being generated + * @param studioProjectDir the generated project root, which holds settings.gradle + * @param appGradle the app module's build.gradle, which this one is derived from + * @param watchServices the complication and Tile manifest entries + * @param sharedPermissions the permissions the phone manifest declares, base ones + * included -- the watch compiles the same Codename One sources, so a watchMain making an + * ordinary network request needs INTERNET as much as the phone does, and this manifest is + * selected outright rather than merged with the phone's + * @param intVersion the phone's version code, which the watch's must exceed + * @param wearableListenerService the Data Layer listener declaration, which the watch needs + * as much as the phone does -- it is the half that RECEIVES a mirrored complication + */ + private void generateWearModule(BuildRequest request, File studioProjectDir, String appGradle, + String watchServices, String sharedPermissions, int intVersion, + String wearableListenerService) throws BuildException { + if (!"wear".equals(watchModuleName(request))) { + return; + } + File wearDir = new File(studioProjectDir, "wear"); + File wearSrc = new File(wearDir, "src/main/java"); + File wearRes = new File(wearDir, "src/main/res"); + wearSrc.mkdirs(); + wearRes.mkdirs(); + + String watchMain = watchMainClass(request); + String phoneStub = request.getMainClass() + "Stub"; + String stubName = request.getMainClass() + "WatchStub"; + if (generatedStubSource == null) { + throw new BuildException("The Wear module needs the generated stub, which has not " + + "been produced yet"); + } + // Derived from the phone stub rather than subclassing it: the stub is typed throughout to + // the lifecycle class it starts, so re-rooting it means substituting that type. The + // phone stub itself stays in the app module and is compiled into both harmlessly, because + // each manifest names its own -- which is what lets the source set be shared at all. + String phoneMain = appLifecycleClass(request); + String watchSimpleName = watchMain.substring(watchMain.lastIndexOf('.') + 1); + String stub = generatedStubSource + .replace("class " + phoneStub, "class " + stubName) + .replace(phoneStub + "()", stubName + "()") + .replace(phoneStub + " stubInstance", stubName + " stubInstance") + .replace(phoneStub + " getInstance", stubName + " getInstance") + // The qualified-this the run() hand-off uses. Renaming the class without this + // leaves ".this" naming a class that is no longer an enclosing one, + // and javac says exactly that. + .replace(phoneStub + ".this", stubName + ".this") + .replace(phoneStub + ".headphones", stubName + ".headphones") + .replace("new " + phoneMain + "(", "new " + watchSimpleName + "(") + .replace(" " + phoneMain + " i;", " " + watchSimpleName + " i;") + .replace(" " + phoneMain + " getAppInstance", " " + watchSimpleName + + " getAppInstance"); + if (watchMain.indexOf('.') > 0) { + stub = stub.replace("import com.codename1.ui.*;", + "import com.codename1.ui.*;\nimport " + watchMain + ";"); + } + // Re-rooting the phone stub is a set of textual substitutions over generated code, and + // the failure mode when one is missing is not subtle but it IS remote: the watch module + // fails to compile, minutes later, naming a file the developer never wrote. Anything the + // list above did not catch still says the phone stub's name, so say so here instead -- + // at generation time, naming the construct, in the build that produced it. + int leftover = stub.indexOf(phoneStub); + if (leftover >= 0) { + int from = Math.max(0, leftover - 60); + int to = Math.min(stub.length(), leftover + 60); + throw new BuildException("The Wear stub still refers to the phone stub " + + phoneStub + ", so re-rooting it missed a construct and the wear module " + + "would not compile. Near: ..." + stub.substring(from, to).replace('\n', ' ') + + "... Add a substitution for it in generateWearModule."); + } + File stubDir = new File(wearSrc, request.getPackageName().replace('.', '/')); + stubDir.mkdirs(); + try { + createFile(new File(stubDir, stubName + ".java"), + stub.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to generate the Wear OS stub", ex); + } + writeWatchStubUtil(request, wearSrc, stubName); + + int wearVersion = wearVersionCode(request, intVersion); + log("[wearable] Wear module version code " + wearVersion + " (phone " + intVersion + ")"); + + String wearGradle = deriveWearGradle(appGradle, intVersion, wearVersion, + watchSurfaceDependencies); + try { + createFile(new File(wearDir, "build.gradle"), + wearGradle.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to write the Wear module build.gradle", ex); + } + copyMobileServiceConfig(studioProjectDir, wearDir); + + String wearManifest = "\n" + + "\n" + + " \n" + // The uses-sdk attributes the project set with android.xmanifest -- in practice + // tools:overrideLibrary, which is how a project accepts a dependency whose own + // manifest demands a higher minSdk than the app declares. deriveWearGradle keeps + // the phone module's dependency graph, so that same library manifest is merged + // into :wear as well, and without the override carried across the wear merge + // fails on exactly the conflict the phone build was told to allow. + + wearUsesSdk(request) + + sharedPermissions + // The package-visibility queries, for the same reason the permissions above are + // here: this manifest is selected outright rather than merged with the phone's, + // so nothing else supplies them. The watch module compiles the SAME sources, and + // on API 30+ an undeclared query means resolveActivity and queryIntentActivities + // return filtered results -- so code that finds a package on the phone silently + // finds nothing on the watch, which reads as a broken feature rather than a + // missing declaration. + + " " + xQueries + // android.xapplication_attr and android.xapplication carried across. This + // manifest is selected outright by the module's sourceSets rather than merged + // with the phone's, so a project that names a custom android:name Application -- + // the usual way to initialise a native SDK -- or declares application-level + // meta-data got the stock Application and no meta-data on the watch, while the + // watch module compiles the very sources that expect them. The two hints are the + // ones that describe the application ITSELF; phone-only components stay behind + // deliberately, and a project that needs a watch-only difference can still say so + // by keeping the phone-only parts out of these hints. + + wearApplicationTag(request) + + " " + request.getArg("android.xapplication", "") + "\n" + // Says the watch app needs its phone half, which is what a companion IS. A + // standalone build says the opposite, in the phone manifest. + + " \n" + // Same theme the phone launcher gets. The shared stub extends AppCompatActivity + // when the project asks for it, and AppCompat refuses to start under a theme that + // is not one of its own -- so a Wear launcher on the platform default crashed on + // the first frame. + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + // Whatever else the project registered on its launcher -- an app link, a custom + // URI scheme, a share target. The phone activity gets this same fragment, and + // the two halves are ONE app to the system: they share an applicationId, so an + // intent the phone resolves is an intent the watch has to be able to resolve + // too. Without it a link opened on the watch has nowhere to go, and the watch + // half of a companion cannot be reached by anything but its launcher icon. + + request.getArg("android.xintent_filter", "") + + watchIntentsActivityMetaData + + " \n" + // The Data Layer listener. This manifest is selected outright by the module's + // sourceSets rather than merged with the phone's, so anything the watch needs has + // to be declared here -- and the watch needs this one MORE than the phone does: + // it is the half that RECEIVES a mirrored complication. Without it Play services + // has nothing to bind in the watch APK, and every mirrored descriptor is dropped. + + wearableListenerService + // Push, when the project uses it. See watchPushManifestEntries. + + watchPushManifestEntries + // The FileProvider and the local-notification receiver, for the same reason: the + // wear module compiles the same sources and packages the same file_paths.xml, and + // a scheduled notification is delivered by a manifest-declared receiver or not at + // all. + + " " + watchProviderTag + "\n" + + " " + watchAlarmReceiver + + " " + watchBackgroundWorkService + + " " + watchBackgroundFetchService + + " " + watchIntentsManifestEntries + + " " + watchFeatureComponents + + " " + watchMediaComponents + // A complication or Tile tap still needs the trampoline, and a TILE tap needs it + // reachable from the tile host's process -- see anyWatchTile. + + " \n" + + watchServices + + "
\n" + + "
\n"; + try { + createFile(new File(wearDir, "src/main/AndroidManifest.xml"), + wearManifest.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to write the Wear module manifest", ex); + } + + // Only now, so a project without a watch produces a byte-identical settings.gradle. + File settings = new File(studioProjectDir, "settings.gradle"); + try { + String existing = new String(java.nio.file.Files.readAllBytes(settings.toPath()), + StandardCharsets.UTF_8); + if (existing.indexOf("':wear'") < 0) { + createFile(settings, (existing + "\ninclude ':wear'\n") + .getBytes(StandardCharsets.UTF_8)); + } + } catch (IOException ex) { + throw new BuildException("Failed to add the Wear module to settings.gradle", ex); + } + + // The Gradle tasks are invoked unqualified from the project root, so "assembleRelease" + // runs in every subproject that has it -- this module included. Nothing extra is needed + // to build it; what was needed was collecting its output, which the daemon now does. + log("[wearable] Generated the companion Wear OS module; the build produces a Wear " + + "artifact beside the phone one."); + } + + + /** + * The Wear artifact's version code, which must outrank the phone's. + * + *

On a watch Play picks among the APKs the device supports by version code, so the wear + * one has to be higher to be chosen. On a phone the required watch feature filters it out + * entirely, so the phone APK still wins there whatever this says.

+ * + * @param request the build being generated + * @param intVersion the phone's version code + * @return the wear module's version code + */ + static int wearVersionCode(BuildRequest request, int intVersion) throws BuildException { + String explicit = request.getArg("android.watchVersionCode", ""); + int resolved; + String setting; + if (explicit.length() > 0) { + // Refused rather than substituted. Falling back to intVersion + 1 hid the typo AND + // recreated the collision this method exists to prevent: the Wear artifact would + // consume the next phone release's code, and the developer would be looking at a + // hint that says something else entirely. + try { + resolved = Integer.parseInt(explicit.trim()); + } catch (NumberFormatException malformed) { + throw new BuildException("android.watchVersionCode is '" + explicit + + "', which is not a version code. It must be a whole number greater " + + "than the phone's " + intVersion + " and no more than " + + MAX_PLAY_VERSION_CODE + "."); + } + setting = "android.watchVersionCode=" + explicit; + } else { + String offset = request.getArg("android.watchVersionCodeOffset", + String.valueOf(DEFAULT_WATCH_VERSION_CODE_OFFSET)); + resolved = intVersion + parseIntSafe(offset, DEFAULT_WATCH_VERSION_CODE_OFFSET); + setting = "android.watchVersionCodeOffset=" + offset; + } + // Play never accepts a version code twice for one application, and the two artifacts share + // an applicationId. An offset of 1 satisfies the ordering rule and then collides with the + // NEXT release: ship phone 100 with Wear 101, and the release after it cannot upload phone + // 101 at all. A project on sequential codes hits that on its second release, which is + // where this would have been found. The default offset partitions the space instead, so a + // Wear code can only collide with a phone code the project will never reach. + if (resolved > MAX_PLAY_VERSION_CODE) { + throw new BuildException("The Wear version code " + resolved + + " exceeds the " + MAX_PLAY_VERSION_CODE + " Play allows. " + setting + + " is added to the phone's " + intVersion + "; a project whose own codes are " + + "already this large -- a date-derived code, usually -- needs a smaller " + + "android.watchVersionCodeOffset, chosen so it cannot collide with a code " + + "the project will use later."); + } + // The whole multi-APK arrangement rests on this ordering. A watch picks among the APKs it + // supports by version code, so a Wear artifact that does not outrank the phone one loses + // to a phone APK the watch also happens to support -- and the failure is not a build + // error but a watch quietly running the phone build, which nobody would trace back to a + // hint. Refuse it here, naming the setting, rather than shipping an arrangement that + // cannot work. + if (resolved <= intVersion) { + throw new BuildException("The Wear version code must be higher than the phone's. " + + setting + " resolves to " + resolved + ", and the phone build is " + + intVersion + ". Play picks among the APKs a device supports by version " + + "code, so a watch would install the phone build instead of the Wear one."); + } + return resolved; + } + + private static int parseIntSafe(String value, int fallback) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ex) { + return fallback; + } + } + + /** + * The source root the generated Wear services belong in. + * + *

In a companion build that is the wear module's own, NOT the phone's. The wear module + * shares the phone's source directory, so anything written to the phone's root is compiled by + * both -- and these import {@code androidx.wear}, whose dependencies belong to the wear module + * alone. A companion build therefore failed compiling the PHONE module against imports it has + * no libraries for.

+ * + * @param module the watch module name, from {@link #watchModuleName(BuildRequest)} + * @param appSrcDir the phone module's java source root + * @return where to generate the services + */ + static File watchSourceRoot(String module, File appSrcDir) { + if (!"wear".equals(module)) { + return appSrcDir; + } + // appSrcDir is /app/src/main/java, so four levels up is the project root. + File wearModule = new File(appSrcDir.getParentFile().getParentFile() + .getParentFile().getParentFile(), "wear"); + return new File(wearModule, "src/main/java"); + } + + /** + * Which injected Wear sources to copy for a set of kinds. + * + *

The Tile service only when a Tile is actually declared. Gradle compiles every source in + * the tree whether or not a generated subclass names it, and the tiles/protolayout + * dependencies are added only for a rectangular family -- so copying it unconditionally + * failed a complication-only build on unresolved imports.

+ * + * @param kinds the watch-bearing kinds, as {id, label, families} + * @return the resource names to copy + */ + static List watchSurfaceSources(List kinds) { + List out = new ArrayList(); + out.add("CN1ComplicationDataSource.java"); + for (String[] kind : kinds) { + if (declaresTile(kind[2])) { + out.add("CN1SurfaceTileService.java"); + break; + } + } + return out; + } + + /** + * Gives the Wear module the mobile-services config the phone module already has. + * + *

The Wear build.gradle is derived from the phone's, so an app using FCM or Firebase + * Analytics carries {@code apply plugin: 'com.google.gms.google-services'} into it, and that + * plugin fails the whole multi-module build when it cannot find its config file -- which is + * written only under {@code app}. The phone artifact goes down with the watch one, which is + * the worst shape this failure could take.

+ * + *

Copying rather than dropping the plugin, because the derived dependency block already + * carries the Firebase libraries: with the plugin gone they would be present and + * unconfigured, and FirebaseApp initialization fails at runtime on the watch instead of at + * build time on the desk. The two modules share an applicationId, so the same file is the + * right one.

+ * + * @param studioProjectDir the generated project root + * @param wearDir the Wear module directory + */ + private void copyMobileServiceConfig(File studioProjectDir, File wearDir) + throws BuildException { + String[] configs = {"google-services.json", "agconnect-services.json"}; + for (String config : configs) { + File from = new File(new File(studioProjectDir, "app"), config); + if (!from.exists()) { + continue; + } + try { + copy(new FileInputStream(from), new FileOutputStream(new File(wearDir, config))); + log("[wearable] Copied " + config + " into the Wear module; the derived " + + "build.gradle applies the same plugin the phone module does."); + } catch (IOException ex) { + throw new BuildException("Failed to copy " + config + + " into the Wear module, which the google-services plugin needs", ex); + } + } + } + + /** + * Derives the Wear module's build.gradle from the phone module's. + * + *

Textual substitution, which is cheap and total -- and every one of these has been wrong + * at least once in a way no compiler could see. A generated Gradle file only fails when + * Gradle evaluates it, which on CI is twenty minutes after the mistake, so this is separated + * from writing it and pinned by WearModuleGradleTest.

+ * + * @param appGradle the phone module's build.gradle + * @param intVersion the phone's version code + * @param wearVersion the watch's version code, which must outrank it + * @param wearDependencies the androidx.wear block, which belongs to this module alone + * @return the wear module's build.gradle + */ + /** + * Writes the wear module's own StubUtil, naming the WATCH stub. + * + *

StubUtil answers "which stub is this app's" and the push glue believes it: a push + * callback asks whether the app is running through it, and a notification tap targets the + * class it returns. The phone's copy is generated with {@code

Stub} substituted in, and + * that activity is not declared in the Wear manifest -- so a companion build with FCM or HMS + * sent both to the phone lifecycle from inside the watch app, and the tap resolved to + * nothing.

+ * + *

Written here rather than patched into the shared copy because both modules compile the + * same tree: the wear source set excludes the shared StubUtil (see deriveWearGradle) and this + * takes its place, which is also why the two cannot both be present.

+ * + * @param request the build being generated + * @param wearSrc the wear module's java source root + * @param stubName the simple name of the generated watch stub + */ + private void writeWatchStubUtil(BuildRequest request, File wearSrc, String stubName) + throws BuildException { + String watchStub = request.getPackageName() + "." + stubName; + String source = "package com.codename1.impl.android;\n\n" + + "/** Generated by the Codename One build for the Wear module. The phone's copy\n" + + " * of this class names the phone stub, which this manifest does not declare. */\n" + + "public class StubUtil {\n" + + " public static boolean appIsRunning() {\n" + + " return " + watchStub + ".isRunning();\n" + + " }\n\n" + + " public static Class getAppStubClass() {\n" + + " return " + watchStub + ".class;\n" + + " }\n\n" + // getMain too: every bundled CN1FirebaseMessagingService template calls it, so a + // replacement without it fails the wear build on a class the developer never + // wrote. Package-private and returning Object, exactly as the phone's copy is. + + " static Object getMain() {\n" + + " return " + watchStub + ".getAppInstance();\n" + + " }\n" + + "}\n"; + File dir = new File(wearSrc, "com/codename1/impl/android"); + dir.mkdirs(); + try { + createFile(new File(dir, "StubUtil.java"), source.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to generate the Wear module's StubUtil", ex); + } + } + + static String deriveWearGradle(String appGradle, int intVersion, int wearVersion, + String wearDependencies) { + String gradle = appGradle + // Libraries are shared from the app module rather than copied. + .replace("fileTree(dir: 'libs'", "fileTree(dir: '../app/libs'") + .replace("dirs 'libs'", "dirs '../app/libs'") + // The signing key lives in the app module and nowhere else, and Gradle resolves + // file("keyStore") relative to the project it appears in -- so a verbatim copy + // sent the wear module looking for a keystore beside itself. A release build then + // failed to CONFIGURE, taking the phone artifact down with it: this is not the + // watch half degrading, it is the whole multi-module build not starting. + .replace("storeFile file(\"keyStore\")", "storeFile file(\"../app/keyStore\")") + // Same trap as the keystore, one line further on. The generated proguard.cfg is + // written into the app module and nowhere else, and Gradle resolves a bare + // proguardFiles path against the project it appears in -- so the default + // release build had the Wear R8 task looking for a file beside itself and + // failing, which takes the phone artifact down with it. + .replace("'proguard.cfg'", "'../app/proguard.cfg'"); + // Share the app module's tree instead of duplicating it, and add this module's own + // generated sources on top. insertAfterFirst and not replace: an "android {" block can + // appear more than once -- a coverage harness appends a second one to add a build type -- + // and only the module's own block wants a source set. + gradle = insertAfterFirst(gradle, "android {\n", + " sourceSets.main {\n" + + " java.srcDirs = ['../app/src/main/java', 'src/main/java']\n" + // StubUtil comes from THIS module, not from the phone's tree. It is generated + // with the app stub's name substituted in, and the phone's copy names + //
Stub -- a class this manifest does not declare as an activity. Push + // callbacks ask StubUtil which stub to reach and notification taps target what it + // answers, so the shared copy sent both to the phone lifecycle from inside the + // watch app. Excluded here and replaced by the watch-specific one written beside + // the watch stub; two copies of one class would not compile. + // Scoped to the PHONE root by absolute path, not a bare pattern. An exclude + // applies to the whole source set, and both roots hold the same relative path -- + // so '**/StubUtil.java' removed the watch-specific replacement as well as the + // phone's, leaving the shared messaging service referencing a class that was no + // longer compiled at all. + + " java.exclude { \n" + + " it.file.absolutePath.replace('\\\\', '/')\n" + + " .endsWith('/app/src/main/java/com/codename1/impl/android/StubUtil.java')\n" + + " }\n" + + " res.srcDirs = ['../app/src/main/res', 'src/main/res']\n" + + " assets.srcDirs = ['../app/src/main/assets']\n" + // ../app/src/main/JAVA, matching what the phone module declares. The generated + // in-app billing interface for a pre-v8 port is written next to the Java it + // serves, which is why the phone gradle names src/main/java as an AIDL root -- + // and pointing this at a src/main/aidl that no build creates left the wear module + // compiling the billing sources with no IInAppBillingService to compile against. + + " aidl.srcDirs = ['../app/src/main/java']\n" + + " manifest.srcFile 'src/main/AndroidManifest.xml'\n" + + " }\n"); + // The androidx.wear libraries, HERE and not in the shared dependency hint. They declare + // minSdk 26, and this is the only module raised to it -- putting them in the hint failed + // the phone module's manifest merge against libraries it never uses. + // + // "\ndependencies {" and not "dependencies {": the buildscript block is indented and + // comes FIRST, and a plain replace put an implementation() call inside buildscript's + // dependency handler, where the method does not exist and the whole :wear project failed + // to evaluate. insertAfterFirst for the same reason one level up -- the generated file + // also carries an androidTest dependency block, which has no use for these. + gradle = insertAfterFirst(gradle, "\ndependencies {\n", wearDependencies); + // Appended, and not substituted into the generated declarations. android.xgradle_default_config + // lets a project add its OWN minSdkVersion and versionCode inside defaultConfig, and those + // come after the builder's -- so rewriting the builder's left the project's value + // effective and this module quietly kept the phone's version code, or a floor below the + // one the Wear libraries need. A trailing block is evaluated last, whatever the file + // above it says, which is the only form that cannot be overridden by a hint. + // + // The floor rises only for the libraries that demand it. Wear OS 3 is where the + // complication and Tile APIs start, so a module carrying them cannot support less -- but + // a companion watch app that only uses the lifecycle or the Data Layer has always run on + // the Wear OS 2 baseline, and raising it for every watchMain build took API 23 to 25 + // watches away from projects that declared no surface at all. + StringBuilder wins = new StringBuilder(); + wins.append("\n// Last word on the two values the Wear artifact cannot get wrong. See\n") + .append("// deriveWearGradle: a defaultConfig fragment from android.xgradle_default_config\n") + .append("// appears after the generated declarations and would otherwise win.\n") + .append("android {\n") + .append(" defaultConfig {\n") + .append(" versionCode ").append(wearVersion).append("\n"); + if (wearDependencies != null && wearDependencies.length() > 0) { + wins.append(" minSdkVersion 26\n"); + } + wins.append(" }\n") + .append("}\n"); + return gradle + wins; + } + + /** + * Inserts text directly after the FIRST occurrence of an anchor, or returns the input when + * the anchor is absent. + * + *

{@code String.replace} rewrites every occurrence, which in a generated build.gradle is + * almost never what is wanted: the anchors here name block openings that legitimately repeat. + * Two separate CI failures came from that, so the intent is spelled out rather than encoded + * in a longer anchor string.

+ * + * @param text the text to insert into + * @param anchor the opening to insert after + * @param insertion the text to insert + * @return the text with the insertion applied at most once + */ + private static String insertAfterFirst(String text, String anchor, String insertion) { + int at = text.indexOf(anchor); + if (at < 0) { + return text; + } + int after = at + anchor.length(); + return text.substring(0, after) + insertion + text.substring(after); + } + + /** Joins declared families for the codegen tables, normalized to the portable spelling. */ + static String joinFamilies(List families) { + StringBuilder sb = new StringBuilder(); + for (String family : families) { + if (sb.length() > 0) { + sb.append(","); + } + sb.append(com.codename1.util.SurfaceKindFamilies.normalize(family)); + } + return sb.toString(); + } + + /** + * The Wear complication types a kind's declared families map onto, as the comma-separated + * SUPPORTED_TYPES value the manifest meta-data carries. + * + *

A watch face asks a data source for one specific type and gets nothing if the source + * does not offer it, so this is what decides whether a complication can be placed in a given + * slot at all. The mapping is the one {@code WidgetSize} documents: circular is a gauge or a + * glyph, inline is one short string, rectangular is the roomy one. SHORT_TEXT is offered + * everywhere because every family can degrade to a number or a word, and a slot that would + * otherwise refuse the source entirely is better filled than empty.

+ * + * @param familiesCsv the kind's declared families, comma separated + * @return the SUPPORTED_TYPES value, never empty when any watch family was declared + */ + /// + /// Advertised from the DECLARED FAMILY, which is all this build can see. Whether a given + /// layout will contain a progress node or an image is a property of what the app publishes at + /// runtime, and it can differ between one publish and the next -- so narrowing the advertised + /// set here would be guessing about a document that does not exist yet, and guessing low + /// makes the kind unselectable in a slot it will later be able to fill. When a face asks for + /// a type the current layout cannot produce, the data source answers with no data, which is + /// the defined way to say so; the slot then falls back to another type or shows its empty + /// state, and the next publish can change the answer. + static String complicationTypes(String familiesCsv) { + LinkedHashSet types = new LinkedHashSet(); + for (String family : familiesCsv.split(",")) { + String f = family.trim(); + if ("watchCircular".equals(f) || "watchCorner".equals(f)) { + // Wear OS has no corner slot; a corner complication is round, so it renders as + // the circular family here exactly as WidgetSize says it does. + types.add("RANGED_VALUE"); + types.add("MONOCHROMATIC_IMAGE"); + types.add("SHORT_TEXT"); + } else if ("watchRectangular".equals(f)) { + types.add("LONG_TEXT"); + types.add("SHORT_TEXT"); + } else if ("watchInline".equals(f)) { + types.add("SHORT_TEXT"); + } + } + StringBuilder sb = new StringBuilder(); + for (String type : types) { + if (sb.length() > 0) { + sb.append(","); + } + sb.append(type); + } + return sb.toString(); + } + + /** + * Whether a kind also earns a Tile: the rectangular family is the only one roomy enough for + * a layout rather than a readout. + * + * @param familiesCsv the kind's declared families, comma separated + * @return true if a TileService should be generated + */ + static boolean declaresTile(String familiesCsv) { + for (String family : familiesCsv.split(",")) { + if ("watchRectangular".equals(family.trim())) { + return true; + } + } + return false; + } + /** * Formats a numeric surfaces.json value (the JSON parser produces Doubles) as a dp integer * string, falling back to the supplied default when absent or malformed. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index f44e664c65e..74cec70f4b2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -298,6 +298,13 @@ private static boolean healthCapabilityRequested(BuildRequest request, String al // ios.surfaces.extension=false the whole iOS lowering is skipped (no define flip, no // extension, no Swift glue): the surfaces API compiles but answers unsupported at runtime. private boolean surfacesExtensionEnabled; + /// True when a watch target exists and at least one kind declares a complication family, so + /// the watch app gets a CN1WatchWidgets extension of its own. + /// + /// Deliberately INDEPENDENT of surfacesExtensionEnabled. A manifest whose every kind is a + /// complication produces no iOS extension and no phone app-group entitlement, and must still + /// produce a watch one -- that is the whole case the surfaces watch families exist for. + private boolean surfacesWatchEnabled; // Resolved app group: surfaces.json appGroup > ios.surfaces.appGroup hint > group.. private String surfacesAppGroup; private boolean surfacesLiveActivities; @@ -2992,7 +2999,12 @@ public void usesClassMethod(String cls, String method) { // the shared CodenameOne_GLViewController.h so it is visible to every surfaces // translation unit (IOSNative.m and CodenameOne_GLAppDelegate.m), mirroring // CN1_USE_CARPLAY. Skipped when ios.surfaces.extension=false. - if (surfacesExtensionEnabled) { + // The watch half counts too, and independently: a manifest whose every kind is a + // complication produces no iOS extension, and the WATCH slice still needs these + // natives. Without the define its own Surfaces.publish() is a no-op AND + // cn1_watch_apply_mirrored_surface -- which the WatchConnectivity delegate calls -- is + // compiled out, so the watch slice fails to link rather than merely doing nothing. + if (surfacesExtensionEnabled || surfacesWatchEnabled) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_WIDGETS", "#define CN1_USE_WIDGETS"); } @@ -3001,7 +3013,11 @@ public void usesClassMethod(String cls, String method) { // lives in the shared CodenameOne_GLViewController.h so it reaches every wearable // translation unit, and unlike the widgets define it deliberately survives on the watch // slice: both halves of a pair run the same symmetric code. - if (usesWearable) { + // surfacesWatchEnabled counts too. Mirroring a phone-side publish to the watch + // rides WCSession, and the app that publishes need never have written a line of + // com.codename1.wearable -- so the scan alone would leave the mirror with no + // transport in exactly the apps that want one. + if (usesWearable || surfacesWatchEnabled) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_WATCHCONNECTIVITY", "#define CN1_USE_WATCHCONNECTIVITY"); } @@ -4111,7 +4127,7 @@ public void usesClassMethod(String cls, String method) { // link WatchConnectivity.framework in lockstep with the scan. It exists on both iOS and // watchOS, which is why it is a plain link rather than one of the watch slice's // weak-linked frameworks. - if (usesWearable) { + if (usesWearable || surfacesWatchEnabled) { String wearableLib = "WatchConnectivity.framework"; if (addLibs == null || addLibs.length() == 0) { addLibs = wearableLib; @@ -4157,9 +4173,16 @@ public void usesClassMethod(String cls, String method) { // cloud builder's entitlement generator consumes; local Xcode builds supply the app // entitlements externally via $(APP_CODE_SIGN_ENTITLEMENTS), matching the wallet // extension flow. - if (surfacesExtensionEnabled) { + // surfacesWatchEnabled counts too. Without the entitlement the group container does + // not resolve on the phone, so areWidgetsSupported() answers false and + // Surfaces.publish() returns before the bridge is reached at all -- taking the watch + // mirror with it. A manifest of nothing but complications would then be unable to + // update the very complications it declares, which is the one case this feature is + // for. The group is genuinely part of the plumbing on both bundles, not an unused + // capability. + if (surfacesExtensionEnabled || surfacesWatchEnabled) { String appGroups = request.getArg("ios.app_groups", ""); - if (!appGroups.contains(surfacesAppGroup)) { + if (!declaresAppGroup(appGroups, surfacesAppGroup)) { request.putArgument("ios.app_groups", appGroups.length() == 0 ? surfacesAppGroup : appGroups + "," + surfacesAppGroup); } @@ -5036,6 +5059,7 @@ public void usesClassMethod(String cls, String method) { + " file_name = f.path || f.name || f.display_name\n" + " file_name && file_name.downcase.end_with?('.swift') && !file_name.start_with?('" + SURFACES_EXTENSION_NAME + "/') && !file_name.start_with?('" + + SURFACES_WATCH_EXTENSION_NAME + "/') && !file_name.start_with?('" + MatterExtensionBuilder.EXTENSION_NAME + "/') && !file_name.include?('/" + WatchNativeBuilder.WATCH_SRC_DIR + "/')\n" + " end\n" @@ -5364,6 +5388,11 @@ public void usesClassMethod(String cls, String method) { if (watchNativeBuilder.isEnabled()) { File appSrcDir = new File(tmpFile, "dist/" + request.getMainClass() + "-src"); + // Before the plist and the Xcode script, both of which describe it. Generated + // here rather than alongside the iOS extension because the watch APP target + // does not exist yet when the schemes ruby runs -- and the complication + // extension is embedded in that target, not the phone's. + writeWatchWidgetExtension(request, new File(tmpFile, "dist"), appSrcDir); watchNativeBuilder.writeWatchInfoPlist(request, appSrcDir); watchNativeBuilder.writeWatchEntry(request, appSrcDir); watchNativeBuilder.writeStubHeaders(appSrcDir); @@ -5987,15 +6016,8 @@ private void appendMatterExtensionTarget(StringBuilder sb, BuildRequest request, // The host's own versions, through the helpers the watch builder uses // for the same rule: an embedded extension whose marketing or build // version differs from its containing app fails archive validation. - String injectedShort = WatchNativeBuilder.injectedPlistString(request, - "CFBundleShortVersionString"); - String extShort = injectedShort != null ? injectedShort - : WatchNativeBuilder.shortVersion(request); - String injectedBundle = WatchNativeBuilder.injectedPlistString(request, - "CFBundleVersion"); - String extBundle = injectedBundle != null ? injectedBundle - : request.getArg("ios.bundleVersion", - WatchNativeBuilder.shortVersion(request)); + String extShort = embeddedExtensionShortVersion(request); + String extBundle = embeddedExtensionBundleVersion(request); // The hint is an override, not the only way in: an app whose // setCommissionToThisApp(true) the scanner saw needs no hint, and one // that reaches the API through reflection has no other way to say so. @@ -6154,6 +6176,9 @@ private void appendWalletExtensionRuby(StringBuilder sb, BuildRequest request, S sb.append("end\n"); } + /** Xcode target / folder name of the generated watchOS complication extension. */ + static final String SURFACES_WATCH_EXTENSION_NAME = "CN1WatchWidgets"; + /** Xcode target / folder name of the generated WidgetKit extension. */ static final String SURFACES_EXTENSION_NAME = "CN1Widgets"; @@ -6547,13 +6572,26 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui if (kindMap.get("preview") instanceof String) { kind.setPreviewName((String) kindMap.get("preview")); } - if (kindMap.get("iosFamilies") instanceof List) { - List families = new ArrayList(); - for (Object family : (List) kindMap.get("iosFamilies")) { - if (family instanceof String) { - families.add((String) family); - } - } + // "families" is the portable spelling and "iosFamilies" the legacy one; the + // shared reader picks between them so Android resolves a kind's families the + // same way rather than parsing a key with "ios" in its name. + List families = com.codename1.util.SurfaceKindFamilies.read(kindMap); + // Refused here as well as on Android, and for a worse failure than the one there. + // A name this framework does not know is not a watch family, so the kind reads as + // an iPhone surface -- and familiesSwift cannot map it either, so it falls back to + // all three home-screen sizes. A typo therefore SHIPS three widgets the manifest + // never asked for, rather than shipping none. + for (String declared : families) { + if (!com.codename1.util.SurfaceKindFamilies.isKnown(declared)) { + throw new BuildException("Widget kind '" + kind.getId() + + "' in surfaces.json declares the family '" + declared + + "', which is not one this framework knows. The watch families " + + "are watchCircular, watchRectangular, watchInline and " + + "watchCorner; the phone families are small, medium, large and " + + "lockscreen."); + } + } + if (!families.isEmpty()) { kind.setIosFamilies(families); } surfacesKinds.add(kind); @@ -6576,6 +6614,17 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui break; } } + // Whether a complication reaches a device is a separate question from whether an iOS + // widget does, and it is answered separately: a watch-only manifest produces no iOS + // extension and must still produce a watch one. + boolean anyWatchSurface = false; + for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { + if (IOSWidgetExtensionBuilder.hasWatchFamily(kind)) { + anyWatchSurface = true; + break; + } + } + surfacesWatchEnabled = anyWatchSurface && watchTargetEnabled(request); if (!anyIosSurface) { // Said HERE, and in full. Turning the flag off is what stops widgetExtensionBuilder // from ever being created, and the watch-only notice at the extension-generation site @@ -6589,20 +6638,41 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui } names.append(kind.getId()); } - log("[surfaces] NOTE: these kinds declare only watch complication families and will " - + "NOT appear on any device in this build: " + names + ". The watchOS widget " - + "extension target and the Wear OS complication data source are not generated " - + "yet, so nothing emits them. Declare a phone family alongside them if you " - + "need a surface today."); - log("[surfaces] No iOS extension is generated and the iOS surface lowering is skipped " - + "entirely -- no app group, no widget support compiled into the app."); + if (surfacesWatchEnabled) { + log("[surfaces] These kinds declare only watch complication families and are " + + "hosted by the watch app's " + SURFACES_WATCH_EXTENSION_NAME + + " extension: " + names + ". No iOS extension is generated, because " + + "there is no iPhone surface to put them on."); + } else if (anyWatchSurface) { + log("[surfaces] NOTE: these kinds declare only watch complication families and " + + "will NOT appear on any device in this build: " + names + ". They are " + + "hosted by the watch app, and this project declares no " + + "codename1.watchMain -- add one, or declare a phone family alongside " + + "them."); + } else { + log("[surfaces] NOTE: these kinds declare no family this build can host and will " + + "NOT appear on any device: " + names + "."); + } surfacesExtensionEnabled = false; - // Nothing further to prepare, and in particular no xcodeproj gem to require: that - // check exists for wiring an extension into the project, and there is no extension. - return; + if (!surfacesWatchEnabled) { + log("[surfaces] No iOS extension is generated and the iOS surface lowering is " + + "skipped entirely -- no app group, no widget support compiled into the " + + "app."); + // Nothing further to prepare, and in particular no xcodeproj gem to require: that + // check exists for wiring an extension into the project, and there is no + // extension. + return; + } + // Deliberately falling through with surfacesExtensionEnabled false. The watch + // extension still has to be wired into the project, which needs the xcodeproj gem + // checked below and an app group validated -- and the group is now something this + // build genuinely uses, even though the PHONE gets neither an entitlement for it nor + // an extension to use it. + log("[surfaces] The watch app publishes surfaces; the phone app does not."); } - // Only now, with an iOS surface confirmed, is the app group something this build actually - // uses -- and only now is rejecting a malformed one the right answer. + // Only now, with a surface confirmed on one platform or the other, is the app group + // something this build actually uses -- and only now is rejecting a malformed one the + // right answer. if (!surfacesAppGroup.startsWith("group.")) { throw new BuildException("The surfaces app group must start with 'group.' (Apple " + "requirement); found '" + surfacesAppGroup + "' (from surfaces.json " @@ -6616,6 +6686,107 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui + (surfacesLiveActivities ? ", live activities" : "")); } + /** + * Generates the watchOS complication extension under dist/ and hands it to + * {@link WatchNativeBuilder}, which embeds it in the watch app target. + * + *

Deliberately NOT part of {@link #appendWidgetExtensionTargets}. That path runs inside + * the schemes ruby, at a point where the watch app target does not exist yet -- and this + * extension is embedded in the watch app, not the phone app. So it is generated here, + * immediately before the watch builder runs its own xcodeproj script, and wired by that + * script instead.

+ * + *

The app-side Swift glue goes into the same {@code -src} folder the phone's + * does. The two are the same files, and the watch script adds them to the watch target by + * name; the schemes script's sweep of that folder into the PHONE target is what already + * handles the case where the watch shares the phone's translation.

+ * + * @param request the build + * @param distDir the generated project's dist folder + * @param appSrcDir the {@code -src} folder + */ + /** + * The marketing version an embedded bundle must declare to match this app. + * + *

Not simply the project version: {@code ios.plistInject} REPLACES the phone's default + * version injection where it sets the key, so a project that overrides the version there + * ships an app whose version is not {@code shortVersion(request)} at all. An embedded bundle + * whose versions differ from its container is rejected by App Store validation, which is the + * one failure that shows up only at submission.

+ * + * @param request the build being generated + * @return the CFBundleShortVersionString the app itself will declare + */ + private static String embeddedExtensionShortVersion(BuildRequest request) { + String injected = WatchNativeBuilder.injectedPlistString(request, + "CFBundleShortVersionString"); + return injected != null ? injected : WatchNativeBuilder.shortVersion(request); + } + + /** + * The build version an embedded bundle must declare to match this app. + * + *

The fallback is {@code shortVersion(request)} and deliberately NOT + * {@link #embeddedExtensionShortVersion}: the two keys are independent, the app's CFBundleVersion is + * {@code ios.bundleVersion} defaulting to the build version, and it does not follow an + * injected marketing version. Deriving one from the other produces the very mismatch this + * exists to prevent.

+ * + * @param request the build being generated + * @return the CFBundleVersion the app itself will declare + */ + private static String embeddedExtensionBundleVersion(BuildRequest request) { + String injected = WatchNativeBuilder.injectedPlistString(request, "CFBundleVersion"); + return injected != null ? injected + : request.getArg("ios.bundleVersion", WatchNativeBuilder.shortVersion(request)); + } + + private void writeWatchWidgetExtension(BuildRequest request, File distDir, File appSrcDir) + throws IOException { + if (!surfacesWatchEnabled) { + return; + } + IOSWidgetExtensionBuilder watchBuilder = new IOSWidgetExtensionBuilder() + .setVersions(embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request)) + .setWatchTarget(true) + .setExtensionName(SURFACES_WATCH_EXTENSION_NAME) + // The extension is nested in the watch app, so its bundle id extends the WATCH + // bundle id rather than the phone's. + .setHostBundleId(request.getPackageName() + ".watchkitapp") + .setAppGroupId(surfacesAppGroup) + // The WATCH APP's target, not the extension's own floor. WidgetKit goes back to + // watchOS 9 and the extension can build there, but it is embedded in the watch + // app -- a watch that cannot install the app cannot show its complication, so + // defaulting to the lower number advertised support that does not exist. The + // extension's floor stays where it is for a project that lowers both. + .setDeploymentTarget(request.getArg("watchNative.surfaces.deploymentTarget", + WatchNativeBuilder.MIN_DEPLOYMENT_TARGET)); + for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { + watchBuilder.addKind(kind); + } + if (!watchBuilder.hasWatchSurface()) { + // Cannot happen -- surfacesWatchEnabled was decided from the same predicate -- but + // generating an empty WidgetBundle would break the watch build rather than degrade, + // so the check is worth its two lines. + return; + } + File extensionDir = new File(distDir, SURFACES_WATCH_EXTENSION_NAME); + IOSWalletExtensionBuilder.writeFileMap(watchBuilder.buildFileMap(), extensionDir); + IOSWalletExtensionBuilder.writeFileMap(watchBuilder.buildAppTargetFileMap(), appSrcDir); + watchNativeBuilder.setWidgetExtension(extensionDir, surfacesAppGroup, + watchBuilder.getDeploymentTarget()); + int complications = 0; + for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { + if (IOSWidgetExtensionBuilder.hasWatchFamily(kind)) { + complications++; + } + } + log("Adding watchOS complication extension target " + SURFACES_WATCH_EXTENSION_NAME + + " (" + complications + " complication kind(s), watchOS " + + watchBuilder.getDeploymentTarget() + ")"); + } + /** * Generates the CN1Widgets WidgetKit extension folder under dist/, drops the app-side * Swift glue into <MainClass>-src (the schemes ruby sweeps *.swift there into the @@ -6624,6 +6795,8 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui */ private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request, File distDir) throws IOException { IOSWidgetExtensionBuilder widgetBuilder = new IOSWidgetExtensionBuilder() + .setVersions(embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request)) .setExtensionName(SURFACES_EXTENSION_NAME) .setHostBundleId(request.getPackageName()) .setAppGroupId(surfacesAppGroup) @@ -6632,11 +6805,10 @@ private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { widgetBuilder.addKind(kind); } - // Named out loud, every time, whether or not the extension is generated. A watch-only kind - // is silently dropped from the iOS bundle and NOTHING else emits it -- there is no watchOS - // widget extension target and no Wear complication data source yet -- so a developer who - // declares one and says nothing about it gets no surface on any platform and no clue why. - // The limitation is documented in the Wearables guide; this is the build-time half of it. + // Named out loud, every time, whether or not the extension is generated. A watch-only + // kind is silently dropped from the iOS bundle, so a developer who declares one and hears + // nothing has to work out for themselves where it went -- and, when there is no watch + // target, that it went nowhere at all. StringBuilder watchOnly = new StringBuilder(); for (IOSWidgetExtensionBuilder.Kind watchKind : surfacesKinds) { if (IOSWidgetExtensionBuilder.isWatchOnly(watchKind)) { @@ -6647,17 +6819,23 @@ private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request } } if (watchOnly.length() > 0) { - log("[surfaces] NOTE: these kinds declare only watch complication families and will " - + "NOT appear on any device in this build: " + watchOnly + ". The watchOS " - + "widget extension target and the Wear OS complication data source are not " - + "generated yet. Declare a phone family alongside them if you need a surface " - + "today."); + if (surfacesWatchEnabled) { + log("[surfaces] These kinds declare only watch complication families and are " + + "hosted by " + SURFACES_WATCH_EXTENSION_NAME + " rather than by the iOS " + + "extension: " + watchOnly + "."); + } else { + log("[surfaces] NOTE: these kinds declare only watch complication families and " + + "will NOT appear on any device in this build: " + watchOnly + ". They " + + "are hosted by the watch app, and this project declares no " + + "codename1.watchMain -- add one, or declare a phone family alongside " + + "them."); + } } if (!widgetBuilder.hasIosSurface()) { - // Every declared kind is a watch complication and there is no live activity, so the iOS - // extension would host nothing -- and a WidgetBundle with an empty body does not compile. - // Declaring only complications is legitimate; it simply produces no iOS surface until the - // watchOS extension target exists, so skip the extension instead of failing the build. + // Every declared kind is a watch complication and there is no live activity, so the + // iOS extension would host nothing -- and a WidgetBundle with an empty body does not + // compile. Declaring only complications is legitimate: those kinds are hosted by the + // watch extension instead, so skip this one rather than failing the build. log("Skipping the WidgetKit extension target: surfaces.json declares only watch " + "complication families, which the iOS extension cannot host"); return; @@ -7199,14 +7377,34 @@ public boolean accept(File file, String string) { // External surfaces: the Java bridge (IOSSurfaceBridge via IOSNative.m) resolves the // shared App Group container through this key; the CN1Widgets extension carries its own // copy in its generated Info.plist. See surfaces.json / the ios.surfaces.* build hints. - if (surfacesExtensionEnabled) { + if (surfacesExtensionEnabled || surfacesWatchEnabled) { if (!inject.contains("CN1SurfacesAppGroup")) { inject += "\nCN1SurfacesAppGroup" + surfacesAppGroup + ""; } + // Which kinds are worth mirroring to the watch, decided here rather than at runtime. + // The phone cannot write into the watch's App Group container -- the same identifier + // resolves to a directory of its own there -- so a phone-side publish only reaches a + // complication if the descriptor travels over WCSession, and that is budgeted. Naming + // the kinds means a publish of a phone-only kind costs one dictionary lookup instead. + if (surfacesWatchEnabled && !inject.contains("CN1SurfacesWatchKinds")) { + StringBuilder watchKinds = new StringBuilder(); + for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { + if (IOSWidgetExtensionBuilder.hasWatchFamily(kind)) { + if (watchKinds.length() > 0) { + watchKinds.append(","); + } + watchKinds.append(kind.getId()); + } + } + inject += "\nCN1SurfacesWatchKinds" + watchKinds + ""; + } // The extension's deployment target: the runtime gates areWidgetsSupported() on it // (the extension cannot run or appear in the widget gallery below this version, so // WidgetKit's own iOS 14 floor is not the right check). - if (!inject.contains("CN1SurfacesMinOS")) { + // The PHONE's floor, always -- the watch bundle carries its own, lower one, written + // by WatchNativeBuilder. Both are compared against the OS actually running, so one + // shared value would be wrong on one of the two. + if (surfacesExtensionEnabled && !inject.contains("CN1SurfacesMinOS")) { inject += "\nCN1SurfacesMinOS" + request.getArg("ios.surfaces.deploymentTarget", "16.1") + ""; } @@ -7219,14 +7417,31 @@ public boolean accept(File file, String string) { inject += "\nNSSupportsLiveActivitiesFrequentUpdates"; } } - // Widget taps deep link back through cn1surface:// (handled by the app delegate and + // Widget taps deep link back through :// (handled by the app delegate and // never stored in AppArg). Register the scheme by appending to ios.urlSchemes so it // rides the existing CFBundleURLTypes injection below, whichever branch runs. - if (!inject.contains("cn1surface")) { - String urlSchemes = request.getArg("ios.urlSchemes", request.getArg("ios.urlScheme", "")); - if (!urlSchemes.contains("cn1surface")) { - request.putArgument("ios.urlSchemes", urlSchemes + "cn1surface"); - } + // + // BOTH the app's own cn1surface. -- which is what the widget generates + // now, and the only one the watch registers -- and the bare cn1surface, which this + // app has always registered and which something may still hold a link built with. + // Keeping the bare one on the phone costs nothing; dropping it could break a link + // that works today. + String ownScheme = IOSWidgetExtensionBuilder.surfaceScheme(request.getPackageName()); + String urlSchemes = request.getArg("ios.urlSchemes", + request.getArg("ios.urlScheme", "")); + String added = ""; + if (!inject.contains(ownScheme) && !urlSchemes.contains(ownScheme)) { + added += "" + ownScheme + ""; + } + // Matched as a whole element, because the qualified scheme CONTAINS the bare one as + // a prefix -- a substring test would read the qualified registration as the bare one + // already being present. + if (!inject.contains("cn1surface") + && !urlSchemes.contains("cn1surface")) { + added += "cn1surface"; + } + if (added.length() > 0) { + request.putArgument("ios.urlSchemes", urlSchemes + added); } } @@ -8061,4 +8276,34 @@ static void collectOptionalFrameworks(java.util.Set set, String arg) { } } + /** + * Whether a declared app-group list already contains a group, compared as a whole token. + * + *

The same trap the profile check documents, one layer up: a project already declaring + * {@code group.com.example.shared} contains the string {@code group.com.example}, so a + * substring test read the surfaces group as present and left the entitlement out. The + * container then fails to resolve, {@code areWidgetsSupported()} answers false, and + * {@code Surfaces.publish()} returns before the bridge -- taking the watch mirror with it, in + * the watch-only configuration this entitlement was widened for.

+ * + *

Split on both separators because the two builders spell the list differently -- comma + * here, space on the build server -- and a value pasted from one into the other should not + * change the answer.

+ * + * @param declared the existing ios.app_groups value + * @param group the group being added + * @return true when the group is already declared + */ + static boolean declaresAppGroup(String declared, String group) { + if (declared == null || group == null || group.length() == 0) { + return false; + } + for (String token : declared.split("[,\\s]+")) { + if (group.equals(token.trim())) { + return true; + } + } + return false; + } + } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java index e7e548e9e51..1053f2011f2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -24,9 +24,15 @@ import org.apache.tools.ant.BuildException; +import com.codename1.util.IOSWidgetExtensionBuilder; + import java.io.File; +import java.io.FileInputStream; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Properties; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -57,10 +63,16 @@ class WatchNativeBuilder { private final IPhoneBuilder owner; - // watchOS floor: single-target WKApplication apps, WidgetKit complications, - // and the SwiftUI onChange(of:) two-parameter API the generated - // CN1WatchRootView uses. - private static final String MIN_DEPLOYMENT_TARGET = "10.0"; + /** + * watchOS floor for the watch APP: single-target WKApplication apps and the SwiftUI + * onChange(of:) two-parameter API the generated CN1WatchRootView uses. + * + *

Package-visible because the complication extension has to agree with it. WidgetKit + * itself goes back to watchOS 9 and the extension can build there, but it is embedded in + * this app -- so a watch that cannot install the app cannot show its complication either, + * and an extension advertising 9 while the host requires 10 is advertising nothing.

+ */ + static final String MIN_DEPLOYMENT_TARGET = "10.0"; // Derived build state. private boolean enabled; @@ -79,6 +91,17 @@ class WatchNativeBuilder { private boolean distinctWatchMain; // Explicit opt-in/out for HealthKit on the watch bundle. private String healthHint; + // The generated CN1WatchWidgets folder under dist/, or null when the app declares no watch + // complication. Set by IPhoneBuilder before applyXcodeSettings runs, because the extension + // is embedded in the WATCH app target and that target does not exist until this builder + // creates it -- which is why this cannot ride the ordinary app-extension path. + private File watchWidgetExtensionDir; + // The App Group the watch app and its extension share. Same identifier as the phone's; the + // container behind it is watch-local, which is what makes the watch publish for itself. + private String surfacesAppGroup; + // The extension's watchOS deployment target, so the plist can advertise the same floor the + // natives compare against. + private String surfacesMinOS; // watchNative.health.workoutProcessing, kept so the entitlement // decision can read it too -- a workout session is HealthKit. private String workoutProcessingHint; @@ -190,6 +213,30 @@ boolean isEnabled() { return enabled; } + /** + * Declares the generated watch widget extension so the Xcode script embeds it in the watch + * app. + * + * @param extensionDir the CN1WatchWidgets folder under dist/, or null for no complication + * @param appGroup the App Group shared by the watch app and its extension + * @param minOS the extension's watchOS deployment target + */ + void setWidgetExtension(File extensionDir, String appGroup, String minOS) { + this.watchWidgetExtensionDir = extensionDir; + this.surfacesAppGroup = appGroup; + this.surfacesMinOS = minOS; + } + + /** The App Group the watch bundle needs entitled, or null when it publishes no surfaces. */ + String getSurfacesAppGroup() { + return surfacesAppGroup; + } + + /** The extension's watchOS floor, advertised to the natives through the watch plist. */ + String getSurfacesMinOS() { + return surfacesMinOS; + } + /** * Resolve the watch build from the project's entry points. The watch app is * built whenever the project declares a watch lifecycle class @@ -583,11 +630,33 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append("@main\n") .append("struct CN1WatchApp: App {\n") .append(" @WKApplicationDelegateAdaptor var delegate: CN1WatchAppDelegate\n") - .append(" var body: some Scene {\n") - .append(" WindowGroup { CN1WatchRootView() }\n") - .append(" }\n") + .append(" var body: some Scene {\n"); + if (watchWidgetExtensionDir != null) { + // A complication tap launches the watch app with the widgetURL rather than handing it + // to a delegate -- there is no UIApplicationDelegate here at all -- so the scene is + // the only place it can be caught. Without this the tap opened the app and the action + // went nowhere. Surfaces.dispatchAction queues until the handler registers, which is + // what makes the cold-start case (the usual one for a complication) work. + sw.append(" WindowGroup {\n") + .append(" CN1WatchRootView()\n") + .append(" .onOpenURL { url in\n") + .append(" cn1_watch_surface_url(url.absoluteString)\n") + .append(" }\n") + .append(" }\n"); + } else { + sw.append(" WindowGroup { CN1WatchRootView() }\n"); + } + sw.append(" }\n") .append("}\n\n") .append("final class CN1WatchAppDelegate: NSObject, WKApplicationDelegate {\n") + // BEFORE anything SwiftUI does, and deliberately not from initVM. A mirrored + // complication update wakes a terminated watch app in the background, where the root + // view is not guaranteed to appear -- so CN1WatchHost.startWithWidth() may never run, + // initVM with it, and the WCSession activation that lives there never happens. That is + // precisely the launch the mirror causes, so putting the activation anywhere the VM + // gates would leave it unreachable in its own use case. This delegate callback runs on + // every launch either way. + .append(" func applicationDidFinishLaunching() { cn1_watch_bootstrap_didFinishLaunching() }\n") .append(" func applicationDidBecomeActive() { CN1WatchHost.shared().applicationDidBecomeActive() }\n") .append(" func applicationWillResignActive() { CN1WatchHost.shared().applicationWillResignActive() }\n") // The active/resign pair only starts and stops the paint pump. These two carry the CN1 @@ -713,7 +782,19 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append("extern void cn1_watch_runtime_pointerReleased(int x, int y);\n") .append("extern void cn1_watch_runtime_didEnterBackground(void);\n") .append("extern void cn1_watch_runtime_willEnterForeground(void);\n\n") - .append("// App-specific entry: register natives + set the main class, init\n") + // Always defined, whatever this build carries, so the Swift delegate can call it + // unconditionally. Its body is what changes. + .append("// Called from the app delegate on EVERY launch, including a background wake\n") + .append("// that never shows the root view -- see the comment on the call site.\n"); + if (watchWidgetExtensionDir != null) { + bs.append("extern void cn1_watch_activate_connectivity(void);\n") + .append("void cn1_watch_bootstrap_didFinishLaunching(void) {\n") + .append(" cn1_watch_activate_connectivity();\n") + .append("}\n\n"); + } else { + bs.append("void cn1_watch_bootstrap_didFinishLaunching(void) { }\n\n"); + } + bs.append("// App-specific entry: register natives + set the main class, init\n") .append("// Display (starts the EDT) and block this thread inside initVM.\n") .append("extern void ").append(mainStub) .append("_main___java_lang_String_1ARRAY(struct ThreadLocalData* threadStateData, JAVA_OBJECT arg);\n") @@ -739,8 +820,22 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { bs.toString().getBytes(StandardCharsets.UTF_8)); // 3) Bridging header. + StringBuilder bridging = new StringBuilder("#import \"CN1WatchHost.h\"\n"); + // Always declared, because the generated delegate always calls it and the bootstrap + // always defines it. What differs is whether its body does anything. + bridging.append("\n// Launch hook: implemented in the generated CN1WatchBootstrap.m,\n") + .append("// called from CN1WatchAppDelegate.applicationDidFinishLaunching.\n") + .append("void cn1_watch_bootstrap_didFinishLaunching(void);\n"); + if (watchWidgetExtensionDir != null) { + // The complication tap path. A plain C function rather than an Objective-C class, so + // Swift only sees it if it is declared in the bridging header -- and the SwiftUI + // scene's onOpenURL is the only place a watch app can catch that URL at all. + bridging.append("\n// Complication tap: implemented in IOSNative.m, called from the\n") + .append("// generated CN1WatchApp scene's onOpenURL.\n") + .append("void cn1_watch_surface_url(const char *url);\n"); + } owner.createFile(new File(appSrcDir, mainClass + "-Watch-Bridging-Header.h"), - "#import \"CN1WatchHost.h\"\n".getBytes(StandardCharsets.UTF_8)); + bridging.toString().getBytes(StandardCharsets.UTF_8)); } /** @@ -1368,6 +1463,42 @@ void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOExceptio : request.getArg("ios.bundleVersion", shortVersion(request))); // Modern single-target watch app marker. sb.append(" WKApplication\n \n"); + // Surfaces on the watch. Same keys, same meaning and the same reader as the phone's: + // the natives resolve the App Group container through the first and compare the running + // OS against the second. The floor differs, though -- the watch extension targets + // watchOS 10 where the phone's targets iOS 16.1 -- and the natives compare against the + // OS actually running, so leaving this to the iOS default would have every watch report + // no widget support. + if (surfacesAppGroup != null && surfacesAppGroup.length() > 0) { + plistString(sb, IOSWidgetExtensionBuilder.APP_GROUP_PLIST_KEY, surfacesAppGroup); + plistString(sb, "CN1SurfacesMinOS", surfacesMinOS == null + ? IOSWidgetExtensionBuilder.WATCH_MIN_DEPLOYMENT_TARGET : surfacesMinOS); + // The complication tap's own scheme. A widget supplies a :// widgetURL and + // the generated CN1WatchApp scene waits for it in onOpenURL -- but the watch is a + // separate bundle and inherits none of the phone's URL types, so without this + // declaration watchOS has nothing to route the URL to and the tap dispatches nothing. + // + // The scheme is this app's own, cn1surface., and the bare cn1surface is + // deliberately NOT registered here. A URL scheme is a global claim, and the watch is + // where that mattered most: a complication tap is routed by the scheme and nothing + // else, so two Codename One apps on one watch both claiming the bare name meant a tap + // could open the other app. This registration is new, so nothing holds a watch link + // built with the old name and there is nothing to keep compatible. + // From the WATCH bundle id, which is what the watch extension was built with + // (setHostBundleId passes .watchkitapp) and therefore what its widgetURL + // carries. Composing this from the phone's package instead would register a scheme + // nothing generates -- the plist would look right, the tap would route nowhere, and + // the only symptom would be a complication that does nothing when touched. + String scheme = IOSWidgetExtensionBuilder.surfaceScheme(bundleId); + sb.append(" CFBundleURLTypes\n \n \n") + .append(" CFBundleURLName\n") + .append(" ").append(escapeXml(request.getPackageName())) + .append(".cn1surface\n") + .append(" CFBundleURLSchemes\n") + .append(" \n ") + .append(escapeXml(scheme)).append("\n") + .append(" \n \n \n"); + } if (isStandalone()) { // A standalone bundle must SAY it is watch-only, not merely omit the companion key. // Without WKWatchOnly the bundle is neither tied to a containing iOS app nor declared @@ -1717,13 +1848,23 @@ boolean watchUsesHealth(boolean appUsesHealth) { */ private void writeWatchEntitlements(BuildRequest request, File appSrcDir, boolean usesHealth) throws IOException { - if (!usesHealth) { + if (!watchNeedsEntitlements(usesHealth)) { return; } owner.createFile(new File(appSrcDir, request.getMainClass() + "-Watch.entitlements"), - watchEntitlementsPlist(request, workoutProcessingHint) - .getBytes(StandardCharsets.UTF_8)); + watchEntitlementsPlist(request, workoutProcessingHint, usesHealth, + surfacesAppGroup).getBytes(StandardCharsets.UTF_8)); + } + + /// Whether the watch bundle needs an entitlements file at all. + /// + /// It started as "does it use HealthKit", which was the only capability the watch did not + /// inherit from the phone. Publishing complications adds a second: the watch app and its + /// widget extension reach each other through an App Group, and the watch bundle is signed + /// with this file and nothing else. + private boolean watchNeedsEntitlements(boolean usesHealth) { + return usesHealth || (surfacesAppGroup != null && surfacesAppGroup.length() > 0); } /// Whether a HealthKit capability is asked for, in either spelling. @@ -1752,12 +1893,43 @@ private static boolean healthCapability(BuildRequest request, /// build time. static String watchEntitlementsPlist(BuildRequest request, String workoutProcessingHint) { + return watchEntitlementsPlist(request, workoutProcessingHint, true, null); + } + + /// As above, but saying which capabilities the watch bundle actually needs. + /// + /// Both are opt-in and neither implies the other. HealthKit was the only capability the + /// watch did not inherit from the phone until complications arrived; publishing them adds + /// an App Group, and a watch app may want either, both or -- for the two-argument overload's + /// historical callers -- just the first. + /// + /// Granting one that is not used is not harmless: entitlement validation refuses a signature + /// carrying a capability the provisioning profile does not have, which is the same reason + /// background-delivery and recalibrate-estimates below are conditional rather than always on. + /// + /// The App Group id is the same string the phone uses. What differs is the container behind + /// it: on the watch it resolves to a watch-local directory the phone cannot see, which is + /// why the watch publishes its own timelines rather than reading the phone's. + /// + /// @param request the build + /// @param workoutProcessingHint the workout hint, which implies HealthKit + /// @param usesHealth whether to grant the HealthKit capability + /// @param appGroup the App Group to entitle, or null when the watch publishes no surfaces + /// @return the entitlements plist text + static String watchEntitlementsPlist(BuildRequest request, + String workoutProcessingHint, boolean usesHealth, String appGroup) { StringBuilder sb = new StringBuilder(); sb.append("\n") .append("\n") - .append("\n\n") - .append(" com.apple.developer.healthkit\n") + .append("\n\n"); + if (appGroup != null && appGroup.length() > 0) { + sb.append(" com.apple.security.application-groups\n") + .append(" \n ").append(escapeXml(appGroup)) + .append("\n \n"); + } + if (usesHealth) { + sb.append(" com.apple.developer.healthkit\n") .append(" \n"); // Background delivery only, not workout processing. A workout // keeps running through WKBackgroundModes=workout-processing in @@ -1782,10 +1954,12 @@ static String watchEntitlementsPlist(BuildRequest request, sb.append(" com.apple.developer.healthkit") .append(".recalibrate-estimates\n \n"); } + } sb.append("\n\n"); return sb.toString(); } + /** * The CODE_SIGN_ENTITLEMENTS setting for the watch target, or an empty * string when the watch does not use HealthKit. @@ -1801,7 +1975,7 @@ String watchEntitlementsSetting(BuildRequest request, // description supplied through ios.plistInject produced a bundle that declared HealthKit // and was signed without the entitlement, so authorization failed on device. boolean phoneUsesHealth = owner.phoneUsesHealthData(request); - if (!watchUsesHealth(phoneUsesHealth)) { + if (!watchNeedsEntitlements(watchUsesHealth(phoneUsesHealth))) { return ""; } return " bs['CODE_SIGN_ENTITLEMENTS'] = '" @@ -2034,8 +2208,21 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // translated sources. .append("watch_src = '").append(IPhoneBuilder.escapeRubyStr(mainClass)).append("-src'\n") .append("entry_existing = watch_target.source_build_phase.files.to_a.map { |bf| bf.file_ref && bf.file_ref.path ? File.basename(bf.file_ref.path) : nil }\n") - .append("%w[CN1WatchApp.swift CN1WatchBootstrap.m].each do |name|\n") + .append("%w[CN1WatchApp.swift CN1WatchBootstrap.m") + // The app-side surfaces glue, when the app publishes complications. IOSNative + // reaches CN1SurfaceBridge through NSClassFromString and CN1SurfaceConfig + // supplies the App Group constant, so without both on the watch target every + // surfaces native finds no bridge and answers unsupported. + // + // It only needs saying for the watch's OWN translation: the shared-translation + // branch above copies the app target's compile sources, which the schemes script + // has already swept these into. The de-dupe by basename keeps that case correct. + .append(watchWidgetExtensionDir == null + ? "" : " CN1SurfaceBridge.swift CN1SurfaceConfig.swift") + .append("].each do |name|\n") .append(" next if entry_existing.include?(name)\n") + .append(" path = File.join(File.dirname(project_file), watch_src, name)\n") + .append(" next unless File.exist?(path)\n") .append(" ref = xcproj.main_group.new_reference(watch_src + '/' + name)\n") .append(" watch_target.source_build_phase.add_file_reference(ref)\n") .append("end\n") @@ -2128,6 +2315,8 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, } s.append("end\n"); + appendWidgetExtension(s, tmpFile, resolvedTeamId); + // The generated phone Stub (translated ) defines the C // `int main()` (the iOS entry). The watch app is SwiftUI-rooted // (CN1WatchApp.swift @main), so both would define `_main` -> duplicate @@ -3237,4 +3426,111 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, } return s.toString(); } + + /** + * Creates the watchOS widget-extension target and embeds it in the WATCH app. + * + *

A complication is a WidgetKit widget, so this is the same shape as the iOS extension + * {@code IPhoneBuilder.appendWidgetExtensionRuby} builds -- an app extension in the host's + * {@code PlugIns/} folder -- with the watch app as the host instead of the phone app.

+ * + *

Both distributions fall out of that choice. Because the extension lives inside + * the watch app rather than beside it, the companion case needs nothing extra: the iOS + * app's "Embed Watch Content" phase copies the finished watch app with the .appex already + * in it, and the platform filter that keeps the watch tree out of the Mac Catalyst slice + * covers the extension for free. A standalone build removes that phase entirely and the + * extension simply rides in the product. There is no branch here for either.

+ * + *

The target type is {@code :app_extension}, not {@code :watch2_extension}. The latter + * is the legacy paired WatchKit app extension -- the same trap as {@code :application} vs + * {@code :watch2_app} above -- while a WidgetKit extension is a plain app extension on + * every platform Apple ships it on.

+ */ + private void appendWidgetExtension(StringBuilder s, File tmpFile, String resolvedTeamId) { + if (watchWidgetExtensionDir == null || !watchWidgetExtensionDir.isDirectory()) { + return; + } + String extensionName = watchWidgetExtensionDir.getName(); + File distDir = new File(tmpFile, "dist"); + Map buildSettings = new LinkedHashMap(); + buildSettings.put("PRODUCT_NAME", "$(TARGET_NAME)"); + // The watch app is one bundle deeper than a phone app, and its PlugIns one deeper + // still, so the runpath needs the extra level the phone extension does not. + buildSettings.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks " + + "@executable_path/../../Frameworks @executable_path/../../../../Frameworks"); + buildSettings.put("CLANG_ENABLE_MODULES", "YES"); + File props = new File(watchWidgetExtensionDir, "buildSettings.properties"); + if (props.exists()) { + Properties loaded = new Properties(); + FileInputStream in = null; + try { + in = new FileInputStream(props); + loaded.load(in); + } catch (IOException ex) { + throw new BuildException("Failed to read " + props, ex); + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignore) { + // Nothing useful to do; the properties are already loaded or the read + // failed above. + } + } + } + for (Object key : loaded.keySet()) { + if (key instanceof String) { + buildSettings.put((String) key, loaded.getProperty((String) key)); + } + } + // Loaded, so it must not also be added to the Xcode group as a resource. + props.delete(); + } + if (resolvedTeamId != null && !resolvedTeamId.isEmpty()) { + buildSettings.put("DEVELOPMENT_TEAM", resolvedTeamId); + } + String target = buildSettings.get("WATCHOS_DEPLOYMENT_TARGET"); + if (target == null || target.length() == 0) { + target = IOSWidgetExtensionBuilder.WATCH_MIN_DEPLOYMENT_TARGET; + } + + // Guarded so re-running the script (the build re-executes the schemes ruby after + // dependency integration) does not duplicate the target. + s.append("\nif xcproj.targets.find{|e| e.name=='") + .append(IPhoneBuilder.escapeRubyStr(extensionName)).append("'}.nil?\n"); + s.append(" ext_target = xcproj.new_target(:app_extension, '") + .append(IPhoneBuilder.escapeRubyStr(extensionName)).append("', :watchos, '") + .append(IPhoneBuilder.escapeRubyStr(target)).append("')\n"); + s.append(" ext_group = xcproj.new_group('") + .append(IPhoneBuilder.escapeRubyStr(extensionName)).append("')\n"); + IPhoneBuilder.appendFilesToXcodeProjGroup(s, watchWidgetExtensionDir, + "ext_group", "ext_target", distDir); + s.append(" watch_target.add_dependency(ext_target)\n"); + s.append(" ext_ref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('") + .append(IPhoneBuilder.escapeRubyStr(extensionName)) + .append(".appex', \"BUILT_PRODUCTS_DIR\")\n"); + // "Embed Foundation Extensions" is what Xcode calls this phase on watchOS; the + // destination is the same PlugIns folder (spec 13) the phone extension uses. + s.append(" ext_embed = watch_target.copy_files_build_phases" + + ".find{|p| p.name=='Embed Foundation Extensions'} || " + + "watch_target.new_copy_files_build_phase('Embed Foundation Extensions')\n"); + s.append(" ext_embed.build_action_mask = \"2147483647\"\n"); + s.append(" ext_embed.dst_subfolder_spec = \"13\"\n"); + s.append(" ext_embed.run_only_for_deployment_postprocessing = \"0\"\n"); + s.append(" ext_embed.add_file_reference(ext_ref)\n"); + s.append(" ext_target.build_configurations.each{|e|\n"); + for (Map.Entry e : buildSettings.entrySet()) { + // SINGLE-quoted on both sides. escapeRubyStr escapes a backslash and a single quote, + // which is exactly a single-quoted Ruby literal's alphabet -- and a double-quoted one + // needs more than that: a provisioning profile named Acme "Watch" closed the literal + // and made the generated project script fail to parse, and a name containing #{ would + // have been interpolated. Single quotes need neither escape. + s.append(" e.build_settings['").append(IPhoneBuilder.escapeRubyStr(e.getKey())) + .append("'] = '").append(IPhoneBuilder.escapeRubyStr(e.getValue())) + .append("'\n"); + } + s.append(" }\n"); + s.append("end\n"); + } + } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index df9c6698b1e..9c6c01a59f6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -1729,7 +1729,27 @@ private void createAntProject() throws IOException, LibraryPropertiesException, resultDir.mkdir(); unzip.setDest(resultDir); unzip.execute(); - for (File child : resultDir.listFiles()) { + File[] resultFiles = resultDir.listFiles(); + // Every returned base, by extension, collected BEFORE anything is classified: see + // roleSuffixFor, which needs to know whether a suffixed entry names an artifact that + // is also here. + java.util.Map> basesByExtension = + new java.util.HashMap>(); + for (File child : resultFiles) { + String name = child.getName(); + int dot = name.lastIndexOf("."); + if (dot < 0) { + continue; + } + String ext = name.substring(dot); + java.util.Set bases = basesByExtension.get(ext); + if (bases == null) { + bases = new java.util.HashSet(); + basesByExtension.put(ext, bases); + } + bases.add(name.substring(0, dot)); + } + for (File child : resultFiles) { String name = child.getName(); int dotpos = name.lastIndexOf("."); if (dotpos < 0) { @@ -1737,9 +1757,20 @@ private void createAntProject() throws IOException, LibraryPropertiesException, } String extension = name.substring(dotpos); String base = name.substring(0, dotpos); - File copyTo = new File(project.getBuild().getDirectory() + File.separator + project.getBuild().getFinalName() + extension); + // The role suffix has to survive into the copied name. Every entry used to land on + // target/, keyed on the extension alone, so a build that + // returns two artifacts of the same kind -- a phone APK and its companion Wear APK + // -- collapsed both onto one path and the last one written won. That is silent and + // it corrupts the primary artifact, not merely the secondary one. + String roleSuffix = roleSuffixFor(base, extension, basesByExtension); + File copyTo = new File(project.getBuild().getDirectory() + File.separator + project.getBuild().getFinalName() + roleSuffix + extension); FileUtils.copyFile(child, copyTo); - if (".war".equals(extension)) { + if (roleSuffix.length() > 0) { + // Attached with a classifier so the companion artifact is installed and + // deployed beside the primary one rather than being an orphan in target/. + projectHelper.attachArtifact(project, extension.substring(1), + roleSuffix.substring(1), copyTo); + } else if (".war".equals(extension)) { projectHelper.attachArtifact(project, "war", copyTo); } else if (".zip".equals(extension) && "javascript".equals(buildTarget)) { projectHelper.attachArtifact(project, "zip", "webapp", copyTo); @@ -2461,6 +2492,76 @@ protected void afterBuild() { } + /** + * Role suffixes a returned artifact may carry, longest first so a future + * "-wear-debug" cannot be shadowed by "-wear". + * + *

Kept deliberately closed. Anything not on this list is the primary artifact and + * keeps the plain {@code } name it has always had, so adding a + * role here is the only way to change where a file lands.

+ */ + // Longest first: "-wear-debug" ends with neither "-wear" nor anything else here, but a + // future suffix that is a tail of another would match the shorter one if it came first. + /** + * The role a result entry plays, given what else came back. + * + *

A role suffix is a claim about a SET, not about a name, and the question it answers is + * "is there something here that this one is the companion TO". An app called + * {@code fitness-wear} returns one APK whose base ends in {@code -wear} and it is the primary + * artifact; the same app with a companion returns {@code fitness-wear} and + * {@code fitness-wear-wear}, where the second is not. Neither reading the name alone nor + * asking whether any unsuffixed entry exists separates those two cases -- in the second, no + * entry is unsuffixed at all.

+ * + *

What does separate them is the artifact the suffix points at: strip it, and a companion + * names something else in the set while a primary names nothing.

+ * + * @param base the entry's name with its extension removed + * @param extension the entry's extension, including the dot + * @param basesByExtension every returned base, keyed by extension + * @return the role suffix including its leading dash, or an empty string + */ + static String roleSuffixFor(String base, String extension, + java.util.Map> basesByExtension) { + String suffix = roleSuffixOf(base); + if (suffix.length() == 0 || basesByExtension == null) { + return ""; + } + java.util.Set siblings = basesByExtension.get(extension); + if (siblings == null) { + return ""; + } + return siblings.contains(base.substring(0, base.length() - suffix.length())) + ? suffix : ""; + } + + private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-wear-debug", "-wear"}; + + /** + * The role suffix carried by a result entry's base name, or an empty string when it is + * the primary artifact. + * + *

A build may hand back more than one artifact of the same kind -- an Android + * companion build returns the phone APK and the Wear APK beside it -- and the two + * cannot share a destination path. The builder names the secondary one with a role + * suffix; this recovers it so the copy keeps it and the artifact can be attached + * under a matching classifier.

+ * + * @param base the result file's name with its extension already removed + * @return the matching role suffix including its leading dash, or an empty string + */ + static String roleSuffixOf(String base) { + if (base == null) { + return ""; + } + for (String suffix : ARTIFACT_ROLE_SUFFIXES) { + if (base.endsWith(suffix)) { + return suffix; + } + } + return ""; + } + private static class LibraryPropertiesException extends Exception { private String libName; LibraryPropertiesException(String libName, String message) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java index b06d8c55eda..742f3b9c3a2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java @@ -32,10 +32,16 @@ import java.util.Map; /** - * Generates the {@code CN1Widgets} WidgetKit app-extension target that the Codename One - * iOS build wires into the generated Xcode project when the app references - * {@code com.codename1.surfaces} (see the {@code surfaces.json} project manifest and the - * {@code ios.surfaces.*} build hints). + * Generates a WidgetKit app-extension target that the Codename One iOS build wires into the + * generated Xcode project when the app references {@code com.codename1.surfaces} (see the + * {@code surfaces.json} project manifest and the {@code ios.surfaces.*} build hints). + * + *

There are two flavours, selected with {@link #setWatchTarget(boolean)}. The default + * {@code CN1Widgets} extension is embedded in the phone app and hosts home and lock-screen + * widgets; the {@code CN1WatchWidgets} flavour is embedded in the watch app and hosts + * complications. They are separate targets in one project and share every Swift source that + * can be shared, differing in which WidgetKit families they may name -- see + * {@link #mapFamily(String, boolean)} for why the two sets are not interchangeable.

* *

The extension is fully generic: the static Swift renderer sources shipped as plugin * resources under {@code com/codename1/builders/surfaces/ios/} render whatever timeline @@ -50,10 +56,11 @@ * requires {@code init()}, so a parameterized struct cannot serve every kind; each * generated struct hardcodes its kind metadata and delegates to the shared * {@code cn1MakeWidgetConfiguration} factory in CN1DescriptorWidget.swift. When - * live activities are enabled the bundle also lists {@code CN1LiveActivityWidget()} - * unconditionally - the struct itself guards every ActivityKit reference with - * {@code #if canImport(ActivityKit)}, which keeps the composition simple and - * compiles cleanly on SDKs/platforms without ActivityKit. + * live activities are enabled the iOS bundle also lists + * {@code CN1LiveActivityWidget()} unconditionally - the struct itself guards every + * ActivityKit reference with {@code #if canImport(ActivityKit)}, which keeps the + * composition simple and compiles cleanly on SDKs without ActivityKit. The watch + * flavour omits it entirely, watchOS having no ActivityKit at all. * * *

{@link #buildAppTargetFileMap()} returns the glue compiled into the MAIN APP target @@ -62,9 +69,10 @@ * matches app and extension by the {@code ActivityAttributes} type, so both modules need * the identical struct).

* - *

The extension's deployment target defaults to 16.1 (ActivityKit's floor); the host - * app's own deployment target is unaffected - the extension simply never runs on older - * iOS versions.

+ *

The iOS extension's deployment target defaults to 16.1 (ActivityKit's floor); the host + * app's own deployment target is unaffected - the extension simply never runs on older iOS + * versions. The watch flavour defaults to {@link #WATCH_MIN_DEPLOYMENT_TARGET} and refuses + * anything below it.

*/ public class IOSWidgetExtensionBuilder { @@ -74,7 +82,7 @@ public class IOSWidgetExtensionBuilder { /** Classpath folder holding the static Swift renderer sources. */ private static final String RESOURCE_ROOT = "/com/codename1/builders/surfaces/ios/"; - /** Static Swift sources copied verbatim into the extension target. */ + /** Static Swift sources copied verbatim into the iOS extension target. */ private static final String[] EXTENSION_SOURCES = { "CN1SurfaceModel.swift", "CN1SurfaceRenderer.swift", @@ -83,6 +91,34 @@ public class IOSWidgetExtensionBuilder { "CN1SurfaceAttributes.swift", }; + /** + * The same sources minus the two ActivityKit ones, for the watchOS extension target. + * + *

watchOS has no ActivityKit at all. Both files are already + * {@code #if canImport(ActivityKit)} guarded, so shipping them would compile to nothing + * rather than fail -- but a target carrying the attributes of a capability the platform + * does not have is a claim, and the next person to read the target would believe it.

+ */ + private static final String[] WATCH_EXTENSION_SOURCES = { + "CN1SurfaceModel.swift", + "CN1SurfaceRenderer.swift", + "CN1WidgetProvider.swift", + "CN1DescriptorWidget.swift", + }; + + /** + * Lowest watchOS the generated extension can target: WidgetKit's own floor. + * + *

This used to say 10.0, on the grounds that {@code containerBackground(for:)} is watchOS + * 10 and every generated widget applies it. That is true of the API and not of the code: + * {@code CN1DescriptorWidget} applies it inside {@code if #available(iOS 17.0, watchOS 10.0, + * *)}, and an availability check compiles below the version it names -- that is what it is + * for. Typechecking the whole extension against the watchOS 9 SDK confirms it, and holding + * the floor at 10.0 excluded every watch still on 9 from a complication that would have + * worked on it, losing only the background.

+ */ + public static final String WATCH_MIN_DEPLOYMENT_TARGET = "9.0"; + /** * One widget kind declared in surfaces.json. Ids must match * {@code [a-z][a-z0-9_]*} - they become Swift struct names and WidgetKit kind ids. @@ -131,6 +167,7 @@ public Kind setPreviewName(String previewName) { private String appGroupId; private String deploymentTarget = "16.1"; private boolean liveActivitiesEnabled; + private boolean watchTarget; private final List kinds = new ArrayList(); /** Bare-bones constructor. Configure with the fluent setters. */ @@ -140,6 +177,40 @@ public IOSWidgetExtensionBuilder() {} * Sets the extension target name (Xcode target, .appex bundle and bundle-id suffix). * Must be an ASCII identifier. Defaults to {@code CN1Widgets}. */ + /** + * The extension's marketing version. Defaults to the historical constant so a caller that + * says nothing keeps its current output. + */ + private String shortVersion = "1.0"; + + /** The extension's build version; see {@link #shortVersion}. */ + private String bundleVersion = "1"; + + /** + * Sets the versions this extension declares. + * + *

Apple validates an embedded bundle's versions against the app that contains it, and an + * extension pinned to 1.0/1 inside an app at any other version is rejected at submission -- + * a failure that appears only when the archive is uploaded, long after every build has gone + * green. The container's resolved values are the ones to pass; they are not simply the + * project version, because {@code ios.plistInject} and {@code ios.bundleVersion} both get a + * say in what the app itself ends up declaring.

+ * + * @param shortVersionValue the containing app's CFBundleShortVersionString + * @param bundleVersionValue the containing app's CFBundleVersion + * @return this builder + */ + public IOSWidgetExtensionBuilder setVersions(String shortVersionValue, + String bundleVersionValue) { + if (shortVersionValue != null && shortVersionValue.length() > 0) { + this.shortVersion = shortVersionValue; + } + if (bundleVersionValue != null && bundleVersionValue.length() > 0) { + this.bundleVersion = bundleVersionValue; + } + return this; + } + public IOSWidgetExtensionBuilder setExtensionName(String name) { this.extensionName = name; return this; @@ -175,6 +246,27 @@ public IOSWidgetExtensionBuilder setLiveActivitiesEnabled(boolean enabled) { return this; } + /** + * Builds the watchOS flavour of the extension rather than the iOS one. + * + *

The two are separate targets in the same project, embedded in different apps: the iOS + * extension rides in the phone app and hosts home and lock-screen widgets, the watch one + * rides in the watch app and hosts complications. They share every Swift source that can + * be shared, and differ in which families they may name -- see + * {@link #mapFamily(String, boolean)}.

+ * + * @param watch true to generate the watch flavour + * @return this builder + */ + public IOSWidgetExtensionBuilder setWatchTarget(boolean watch) { + this.watchTarget = watch; + if (watch && "16.1".equals(deploymentTarget)) { + // The iOS default is meaningless on the watch and would be rejected below. + this.deploymentTarget = WATCH_MIN_DEPLOYMENT_TARGET; + } + return this; + } + /** Declares one widget kind (from surfaces.json). */ public IOSWidgetExtensionBuilder addKind(Kind kind) { kinds.add(kind); @@ -186,6 +278,7 @@ public IOSWidgetExtensionBuilder addKind(Kind kind) { public String getAppGroupId() { return appGroupId; } public String getDeploymentTarget() { return deploymentTarget; } public boolean isLiveActivitiesEnabled() { return liveActivitiesEnabled; } + public boolean isWatchTarget() { return watchTarget; } public List getKinds() { return kinds; } /** @@ -194,7 +287,7 @@ public IOSWidgetExtensionBuilder addKind(Kind kind) { */ public Map buildFileMap() throws IOException { validate(); - if (!hasIosSurface()) { + if (!hasSurface()) { // Every declared kind is a watch complication and there is no live activity, so nothing // would reach the bundle body -- and a WidgetBundle whose body holds no Widget expression // does not compile. Callers check hasIosSurface() and skip the extension; reaching here @@ -204,18 +297,22 @@ public Map buildFileMap() throws IOException { // Deliberately here rather than in validate(): the APP-target glue is still wanted when // the app publishes surfaces that only a watch can show, so buildAppTargetFileMap() must // not be blocked by this. - throw new IllegalStateException("the iOS widget extension would be empty: every kind " - + "declares only watch complication families. Check hasIosSurface() before " + throw new IllegalStateException((watchTarget + ? "the watchOS widget extension would be empty: no kind declares a watch " + + "complication family. Check hasWatchSurface() before " + : "the iOS widget extension would be empty: every kind declares only watch " + + "complication families. Check hasIosSurface() before ") + "generating the extension"); } LinkedHashMap map = new LinkedHashMap(); map.put("Info.plist", utf8(buildInfoPlist())); map.put(extensionName + ".entitlements", utf8(buildEntitlements())); map.put("buildSettings.properties", utf8(buildBuildSettings())); - for (String source : EXTENSION_SOURCES) { + for (String source : (watchTarget ? WATCH_EXTENSION_SOURCES : EXTENSION_SOURCES)) { map.put(source, utf8(loadResource(source))); } - if (liveActivitiesEnabled) { + // Live activities are an iOS capability; watchOS has no ActivityKit. + if (liveActivitiesEnabled && !watchTarget) { map.put("CN1LiveActivityWidget.swift", utf8(loadResource("CN1LiveActivityWidget.swift"))); } map.put("CN1SurfaceConfig.swift", utf8(buildConfigSwift())); @@ -262,15 +359,23 @@ private void validate() { // produces a perfectly legal bundle -- ten complications plus one iOS widget is one widget. int emitted = 0; for (Kind kind : kinds) { - if (!isWatchOnly(kind)) { + if (watchTarget ? hasWatchFamily(kind) : !isWatchOnly(kind)) { emitted++; } } - if (emitted > (liveActivitiesEnabled ? 9 : 10)) { - throw new IllegalStateException("surfaces.json declares more than " - + (liveActivitiesEnabled ? 9 : 10) + " widget kinds with an iOS surface; a " + // The live activity occupies a slot in the iOS bundle only; the watch has none. + int limit = (liveActivitiesEnabled && !watchTarget) ? 9 : 10; + if (emitted > limit) { + throw new IllegalStateException("surfaces.json declares more than " + limit + + " widget kinds with " + (watchTarget ? "a watch" : "an iOS") + " surface; a " + "single WidgetBundle supports at most 10 widgets"); } + if (watchTarget && compareVersions(deploymentTarget, WATCH_MIN_DEPLOYMENT_TARGET) < 0) { + throw new IllegalStateException("the watch widget extension cannot target watchOS " + + deploymentTarget + ": WidgetKit's accessory families arrive in watchOS " + + WATCH_MIN_DEPLOYMENT_TARGET + ", so there is no complication to build " + + "below it"); + } for (Kind kind : kinds) { if (kind.getId() == null || !isKindId(kind.getId())) { throw new IllegalStateException("widget kind ids must match [a-z][a-z0-9_]*: " @@ -337,8 +442,8 @@ private String buildInfoPlist() { plistKeyString(sb, "CFBundleInfoDictionaryVersion", "6.0"); plistKeyString(sb, "CFBundleName", "$(PRODUCT_NAME)"); plistKeyString(sb, "CFBundlePackageType", "$(PRODUCT_BUNDLE_PACKAGE_TYPE)"); - plistKeyString(sb, "CFBundleShortVersionString", "1.0"); - plistKeyString(sb, "CFBundleVersion", "1"); + plistKeyString(sb, "CFBundleShortVersionString", shortVersion); + plistKeyString(sb, "CFBundleVersion", bundleVersion); plistKeyString(sb, APP_GROUP_PLIST_KEY, appGroupId); // No NSExtensionPrincipalClass: the @main CN1WidgetBundle is the entry point. // (NSSupportsLiveActivities belongs in the HOST APP's Info.plist, injected by @@ -375,9 +480,28 @@ private String buildBuildSettings() { sb.append("# Auto-generated by Codename One IOSWidgetExtensionBuilder.\n"); sb.append("# Picked up by com.codename1.builders.IPhoneBuilder when the CN1Widgets\n"); sb.append("# extension folder is wired into the generated Xcode project.\n"); - sb.append("IPHONEOS_DEPLOYMENT_TARGET=").append(deploymentTarget).append("\n"); + if (watchTarget) { + sb.append("WATCHOS_DEPLOYMENT_TARGET=").append(deploymentTarget).append("\n"); + sb.append("SDKROOT=watchos\n"); + sb.append("SUPPORTED_PLATFORMS=watchos watchsimulator\n"); + // 4 is the watch device family. Without it the extension builds for the phone + // families and is rejected when the watch app tries to embed it. + sb.append("TARGETED_DEVICE_FAMILY=4\n"); + // The '=' inside the KEY is escaped, because this file is read back with + // Properties.load and that treats the first unescaped '=' as the separator: the key + // parsed as "ARCHS[sdk" with value "watchos*]=arm64_32", so the conditional setting + // Xcode needs was never applied and the extension took whatever architectures the + // containing project supplies -- phone ones, for a watch target. + sb.append("ARCHS[sdk\\=watchos*]=arm64_32\n"); + } else { + sb.append("IPHONEOS_DEPLOYMENT_TARGET=").append(deploymentTarget).append("\n"); + } sb.append("SWIFT_VERSION=5.0\n"); - sb.append("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=YES\n"); + // The watch app embeds the Swift runtime once, for itself and everything nested inside + // it. An extension that embeds its own copy is dead weight in the bundle and can fail + // validation, so the iOS answer here is the wrong one for a nested watch extension. + sb.append("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=") + .append(watchTarget ? "NO" : "YES").append("\n"); sb.append("SKIP_INSTALL=YES\n"); sb.append("PRODUCT_BUNDLE_IDENTIFIER=").append(hostBundleId).append(".") .append(extensionName).append("\n"); @@ -395,9 +519,54 @@ private String buildConfigSwift() { sb.append("import Foundation\n"); sb.append("\n"); sb.append("let cn1SurfacesAppGroup = \"").append(escapeSwift(appGroupId)).append("\"\n"); + sb.append("let cn1SurfaceScheme = \"").append(escapeSwift(surfaceScheme())).append("\"\n"); return sb.toString(); } + /** + * The app's own deep-link scheme for surface taps. + * + *

A URL scheme is a GLOBAL registration. Every Codename One app used to claim the bare + * {@code cn1surface}, so two of them installed together were two claims on one name -- and on + * the watch, where the complication's widgetURL is routed by nothing else, the tap could open + * whichever bundle the system decided owned it. Any other app can also claim a known scheme + * and hand us whatever src and id it likes.

+ * + *

Qualifying it with the host bundle id makes the claim as unique as the bundle id itself, + * which is the strongest uniqueness Apple offers. Dots are legal in a scheme (RFC 3986 allows + * ALPHA, DIGIT, "+", "-" and "."), and reverse-DNS schemes are ordinary on Apple platforms. + * This does not make the payload trusted -- a scheme never can -- but it stops a tap landing + * in the wrong app, which is the part that broke without anyone being hostile.

+ * + * @return the scheme this build registers and generates + */ + public String surfaceScheme() { + return surfaceScheme(hostBundleId); + } + + /** + * The scheme for a host bundle id, so the builders can register what this generates. + * + * @param hostBundleId the bundle id the surfaces belong to + * @return the scheme + */ + public static String surfaceScheme(String hostBundleId) { + if (hostBundleId == null || hostBundleId.length() == 0) { + return "cn1surface"; + } + StringBuilder out = new StringBuilder("cn1surface."); + for (int i = 0; i < hostBundleId.length(); i++) { + char c = hostBundleId.charAt(i); + // The scheme grammar, applied rather than assumed: a bundle id is normally already + // within it, and anything outside becomes '-' so the result stays a legal scheme + // instead of a plist Xcode rejects. + boolean legal = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.'; + out.append(legal ? c : '-'); + } + return out.toString(); + } + private String buildBundleSwift() { StringBuilder sb = new StringBuilder(2048); sb.append("// Auto-generated by Codename One from surfaces.json. The @main entry point of\n"); @@ -411,22 +580,22 @@ private String buildBundleSwift() { sb.append("struct CN1WidgetBundle: WidgetBundle {\n"); sb.append(" var body: some Widget {\n"); for (Kind kind : kinds) { - if (isWatchOnly(kind)) { + if (!hostsKind(kind)) { continue; } sb.append(" ").append(structName(kind)).append("()\n"); } - if (liveActivitiesEnabled) { + if (liveActivitiesEnabled && !watchTarget) { sb.append(" CN1LiveActivityWidget()\n"); } sb.append(" }\n"); sb.append("}\n"); for (Kind kind : kinds) { - if (isWatchOnly(kind)) { - // Nothing to host it: the generated extension target is the iOS one, so a kind that - // declares only complication families has no surface here. Emitting it anyway would - // fall through to the default home-screen sizes and ship an iPhone widget the - // manifest never asked for. + if (!hostsKind(kind)) { + // Nothing here to host it. In the iOS target that is a kind declaring only + // complication families -- emitting it would fall through to the default + // home-screen sizes and ship an iPhone widget the manifest never asked for. In + // the watch target it is a kind declaring no complication family at all. continue; } sb.append("\n"); @@ -436,13 +605,20 @@ private String buildBundleSwift() { sb.append(" kind: \"").append(escapeSwift(kind.getId())).append("\",\n"); sb.append(" displayName: \"").append(escapeSwift(kind.getName())).append("\",\n"); sb.append(" description: \"").append(escapeSwift(kind.getDescription())).append("\",\n"); - // .accessoryCorner exists only on watchOS, so the corner family is emitted behind a - // platform guard rather than in the shared list -- naming the symbol on iOS would not - // compile even in code that never runs. - String shared = familiesSwift(kind, false); - String watchOnly = watchOnlyFamiliesSwift(kind, false); + // .accessoryCorner exists only on watchOS. In the watch target that is simply one + // more family in the list; in the iOS target it is emitted behind a platform guard, + // because naming the symbol on iOS would not compile even in code that never runs. + String shared = familiesSwift(kind, watchTarget); + String watchOnly = watchOnlyFamiliesSwift(kind, watchTarget); if (watchOnly.length() == 0) { sb.append(" families: [").append(shared).append("])\n"); + } else if (watchTarget) { + // Watch-only target: no guard, the corner family just joins the list. + sb.append(" families: [").append(shared); + if (shared.length() > 0) { + sb.append(", "); + } + sb.append(watchOnly).append("])\n"); } else { sb.append("#if os(watchOS)\n"); sb.append(" families: [").append(shared); @@ -464,6 +640,12 @@ private static String structName(Kind kind) { return "CN1Widget_" + kind.getId(); } + /// Whether this flavour of the extension has a surface for the kind: a watch target hosts + /// the kinds declaring a complication family, an iOS target hosts everything else. + private boolean hostsKind(Kind kind) { + return watchTarget ? hasWatchFamily(kind) : !isWatchOnly(kind); + } + private static String familiesSwift(Kind kind, boolean watchTarget) { List families = kind.getIosFamilies(); StringBuilder sb = new StringBuilder(); @@ -479,17 +661,24 @@ private static String familiesSwift(Kind kind, boolean watchTarget) { } } if (sb.length() == 0) { + if (watchTarget) { + // The home-screen default is meaningless here and unnameable besides. A kind + // with no usable watch family is skipped by the caller, which hasWatchFamily() + // has already decided, so this is the empty-list case rather than a fallback. + return ""; + } // No (usable) family declaration: all three home-screen sizes. return ".systemSmall, .systemMedium, .systemLarge"; } return sb.toString(); } - /// The families that exist only on watchOS, emitted behind an os(watchOS) guard. + /// The families that exist only on watchOS. /// - /// Like the other watch families this is confined to a watch target: the corner complication has - /// no iOS surface, so emitting it -- and the platform guard that carries it -- into the iOS - /// extension would advertise something the manifest never asked for. + /// Confined to a watch target: the corner complication has no iOS surface, so emitting it -- + /// and the platform guard that carried it -- into the iOS extension would advertise something + /// the manifest never asked for. Inside the watch target no guard is needed at all, because + /// the target's SUPPORTED_PLATFORMS is watchOS alone. private static String watchOnlyFamiliesSwift(Kind kind, boolean watchTarget) { if (!watchTarget) { return ""; @@ -507,30 +696,11 @@ private static String watchOnlyFamiliesSwift(Kind kind, boolean watchTarget) { /// The portable family name for a declaration, resolving the WidgetKit spellings. /// - /// Normalised in ONE place because four decisions read these names -- the Swift family list, - /// the watch-only classification, whether a kind has any watch family at all, and the corner - /// complication's platform guard -- and three of them tested `startsWith("watch")`. So - /// `accessoryCircular` was accepted by none of them: the kind looked like an iOS surface and - /// fell through to the systemSmall/Medium/Large default, turning a complication into three - /// home-screen widgets rather than withholding it. - /// - /// ONLY accessoryCorner. The other accessory spellings are not watch families: - /// `.accessoryCircular`, `.accessoryInline` and `.accessoryRectangular` are the iPhone - /// LOCK-SCREEN families as well as watch ones, and CN1DescriptorWidget.swift already renders - /// all three on iOS -- they sit under `if #available(iOS 16.0, watchOS 9.0)`, while only - /// `.accessoryCorner` is inside `#if os(watchOS)`. Folding them into the watch names withheld a - /// lock-screen widget the manifest had asked for, which is the mirror of the bug this method - /// was added to fix. - /// - /// So the two namings are NOT interchangeable for these three, and accessoryRectangular already - /// said so: it maps to the portable `lockscreen`, not to watchRectangular. The portable - /// `watch*` names mean "complication only"; the WidgetKit spellings mean the WidgetKit family, - /// which on iOS is the lock screen. + /// Kept as a method here because this class and its tests read it by this name; the rule + /// itself, and the long account of what getting it wrong costs, lives in + /// [SurfaceKindFamilies#normalize(String)] so the Android builder applies exactly the same one. static String normalizeFamily(String family) { - if ("accessoryCorner".equals(family)) { - return "watchCorner"; - } - return family; + return SurfaceKindFamilies.normalize(family); } private static String mapFamily(String rawFamily, boolean watchTarget) { @@ -538,6 +708,29 @@ private static String mapFamily(String rawFamily, boolean watchTarget) { // WidgetKit-style spellings are accepted, so manifests written against either // naming in the docs resolve to the same families. String family = normalizeFamily(rawFamily); + // The phone families have no watch surface, and the three system ones cannot even be + // NAMED there: WidgetFamily.systemSmall and friends are @available(watchOS, unavailable), + // so emitting one into the watch bundle fails the build rather than producing a widget + // nobody sees. lockscreen joins them because an iPhone lock screen is not a watch face. + if (watchTarget) { + if ("small".equals(family) || "systemSmall".equals(family) + || "medium".equals(family) || "systemMedium".equals(family) + || "large".equals(family) || "systemLarge".equals(family) + || "lockscreen".equals(family)) { + return null; + } + // The accessory spellings too, and for the reason hasWatchFamily already encodes: + // they are NOT watch families here. A kind declaring only accessoryCircular produces + // no watch extension at all, so letting one INTO a watch extension that some other + // family opened is the system contradicting itself -- a kind asking for a lock-screen + // circular and a rectangular complication got a circular complication it never asked + // for. 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 says they want it there. + if ("accessoryCircular".equals(family) || "accessoryInline".equals(family) + || "accessoryRectangular".equals(family)) { + return null; + } + } if ("small".equals(family) || "systemSmall".equals(family)) { return ".systemSmall"; } @@ -589,7 +782,7 @@ private static String mapFamily(String rawFamily, boolean watchTarget) { } /// True when the kind declares at least one watch complication family, which is what decides - /// whether the watch flavour of the extension is worth generating at all. + /// whether the watch flavour of the extension hosts it. /// /// @param kind the kind to inspect /// @return true if the kind offers a complication @@ -617,30 +810,72 @@ public boolean hasIosSurface() { return false; } - public static boolean isWatchOnly(Kind kind) { - List families = kind.getIosFamilies(); - if (families == null || families.isEmpty()) { - return false; + /** + * Whether THIS flavour of the extension would host anything, so the caller can skip + * generating a target that has nothing to show. + * + * @return true if there is something for this extension to host + */ + public boolean hasSurface() { + return watchTarget ? hasWatchSurface() : hasIosSurface(); + } + + /** + * Whether the watch widget extension would host anything: at least one kind declaring a + * watch complication family. + * + *

Live activities never count -- watchOS has no ActivityKit -- so unlike + * {@link #hasIosSurface()} this is decided by the kinds alone.

+ * + * @return true if there is a complication to show + */ + public boolean hasWatchSurface() { + for (Kind kind : kinds) { + if (hasWatchFamily(kind)) { + return true; + } } - for (String family : families) { - if (family != null && !normalizeFamily(family).startsWith("watch")) { - return false; + return false; + } + + /** + * Compares two dotted version strings numerically, so "10.0" orders above "9.0" as it + * would not under string comparison. + * + * @param a left version + * @param b right version + * @return negative, zero or positive as a orders below, with or above b + */ + private static int compareVersions(String a, String b) { + String[] left = (a == null ? "" : a).split("\\."); + String[] right = (b == null ? "" : b).split("\\."); + for (int i = 0; i < Math.max(left.length, right.length); i++) { + int l = parsePart(left, i); + int r = parsePart(right, i); + if (l != r) { + return l < r ? -1 : 1; } } - return true; + return 0; } - public static boolean hasWatchFamily(Kind kind) { - List families = kind.getIosFamilies(); - if (families == null) { - return false; + private static int parsePart(String[] parts, int index) { + if (index >= parts.length) { + return 0; } - for (String family : families) { - if (family != null && normalizeFamily(family).startsWith("watch")) { - return true; - } + try { + return Integer.parseInt(parts[index].trim()); + } catch (NumberFormatException ex) { + return 0; } - return false; + } + + public static boolean isWatchOnly(Kind kind) { + return SurfaceKindFamilies.isWatchOnly(kind.getIosFamilies()); + } + + public static boolean hasWatchFamily(Kind kind) { + return SurfaceKindFamilies.hasWatchFamily(kind.getIosFamilies()); } private static void plistKeyString(StringBuilder sb, String key, String value) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java new file mode 100644 index 00000000000..76bc9a38f89 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java @@ -0,0 +1,269 @@ +/* + * 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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * The size families a {@code surfaces.json} widget kind declares, and the one place that + * decides which of them are watch complications. + * + *

Shared by both device builders. The iOS builder needs the classification to decide what + * the WidgetKit extensions may host; the Android builder needs it to tell a home-screen + * widget kind from a Wear complication kind. Neither may own a private copy: the rule is + * subtle enough that three separate call sites once implemented it as + * {@code startsWith("watch")} and all three got {@code accessoryCircular} wrong, turning a + * complication into three home-screen widgets. See {@link #normalize(String)}.

+ * + *

Manifest key. A kind declares its families under the portable {@code families} + * key. The older {@code iosFamilies} spelling is still honoured and means the same thing -- + * it predates there being a second platform that cared -- and is consulted only when + * {@code families} is absent.

+ */ +public final class SurfaceKindFamilies { + + private SurfaceKindFamilies() { + } + + /** + * The families a kind's JSON object declares. + * + *

{@code families} wins outright when PRESENT -- well-formed or not. {@code iosFamilies} + * is the legacy spelling and is read only in its absence. They are not merged, because a manifest + * carrying both is far more likely to be mid-migration than to mean the union, and + * silently unioning would resurrect a family the author had just removed.

+ * + * @param kindJson one entry of the manifest's {@code kinds} array + * @return the declared family names, never null; entries that are not strings are skipped + */ + public static List read(Map kindJson) { + if (kindJson == null) { + return Collections.emptyList(); + } + // containsKey, not a null check. "families": null is PRESENT -- a JSON author writes it + // to mean "no families here", and reading the legacy list instead resurrects exactly what + // they were removing. The contract is about the key being there, so this asks that. + if (kindJson.containsKey("families")) { + Object portable = kindJson.get("families"); + // PRESENT is what decides, not well-formed. Falling through to iosFamilies when the + // portable value could not be read meant a manifest mid-migration -- the one case + // carrying both keys -- silently built the legacy list the author had just replaced, + // shipping a surface they had removed. Present and unusable is an authoring mistake, + // and it is said rather than absorbed. + // An explicit null is an authoring mistake, not a way to say "no families". + // + // There is no empty answer that means that: an EMPTY declaration deliberately means + // "take the home-screen default", which is what a kind with no families key gets and + // what hasPhoneFamily documents. Returning empty here therefore produced the three + // default iOS sizes and an Android provider -- the opposite of what a null plainly + // intends -- so the honest thing is to say the value cannot be read, exactly as any + // other unusable one is. A kind that should offer nothing is a kind that should not + // be declared. + List read = asFamilyList(portable); + if (read == null) { + throw new IllegalArgumentException("The \"families\" value of surface kind \"" + + kindJson.get("id") + "\" must be a list of family names (or a single " + + "name), but was: " + portable); + } + return read; + } + // The legacy key keeps its old tolerance. Nothing in the wild carries "families" yet, so + // tightening that one breaks nothing; manifests carrying iosFamilies predate this check + // and refusing one now would fail a build that has always worked. + List legacy = asFamilyList(kindJson.get("iosFamilies")); + return legacy == null ? Collections.emptyList() : legacy; + } + + /** + * One declaration's family names, or null when the value is not a family declaration at all. + * + *

A bare string counts as a single family: it is the obvious shorthand and the obvious way + * to mistype the key, and reading it the way the author plainly meant beats refusing it.

+ * + * @param declared the raw manifest value + * @return the names, or null when the value cannot be read as a declaration + */ + private static List asFamilyList(Object declared) { + if (declared instanceof String) { + List single = new ArrayList(1); + if (((String) declared).length() > 0) { + single.add((String) declared); + } + return single; + } + if (!(declared instanceof List)) { + return null; + } + List out = new ArrayList(); + for (Object family : (List) declared) { + if (family instanceof String) { + out.add((String) family); + } + } + return out; + } + + /** + * The portable family name for a declaration, resolving the WidgetKit spellings. + * + *

Normalised in ONE place because several decisions read these names -- the Swift + * family list, the watch-only classification, whether a kind has any watch family at + * all, the corner complication's platform guard, and now the Wear codegen -- and three + * of them once tested {@code startsWith("watch")} directly. So {@code accessoryCircular} + * was accepted by none of them: the kind looked like an iOS surface and fell through to + * the systemSmall/Medium/Large default, turning a complication into three home-screen + * widgets rather than withholding it.

+ * + *

ONLY {@code accessoryCorner} maps across. The other accessory spellings are not + * watch families: {@code accessoryCircular}, {@code accessoryInline} and + * {@code accessoryRectangular} are the iPhone LOCK-SCREEN families as well as watch + * ones, and CN1DescriptorWidget.swift renders all three on iOS. Folding them into the + * watch names withheld a lock-screen widget the manifest had asked for, which is the + * mirror of the bug this method was added to fix.

+ * + *

So the two namings are NOT interchangeable for those three, and + * {@code accessoryRectangular} already said so: it maps to the portable + * {@code lockscreen}, not to {@code watchRectangular}. The portable {@code watch*} names + * mean "complication only"; the WidgetKit spellings mean the WidgetKit family, which on + * iOS is the lock screen.

+ * + * @param family a declared family name in either spelling + * @return the portable name + */ + public static String normalize(String family) { + if ("accessoryCorner".equals(family)) { + return "watchCorner"; + } + return family; + } + + /** + * Every family name that is not a watch one, in both spellings. + * + *

The WidgetKit spellings sit beside the portable ones because {@code normalize} folds only + * {@code accessoryCorner} across -- the other accessory names are the iPhone LOCK-SCREEN + * families as well as watch ones, and the iOS renderer draws all three.

+ */ + private static final java.util.Set PHONE_FAMILIES = + java.util.Collections.unmodifiableSet(new java.util.LinkedHashSet( + java.util.Arrays.asList("small", "systemSmall", "medium", "systemMedium", + "large", "systemLarge", "lockscreen", "accessoryRectangular", + "accessoryCircular", "accessoryInline"))); + + /** The complete set of watch families, which is what {@link #isWatch} answers about. */ + private static final java.util.Set WATCH_FAMILIES = + java.util.Collections.unmodifiableSet(new java.util.LinkedHashSet( + java.util.Arrays.asList("watchCircular", "watchRectangular", + "watchInline", "watchCorner"))); + + /** + * Whether one declared family is a watch complication. + * + *

The four names exactly, not anything beginning with "watch". A prefix test made a + * mistyped {@code watchCircle} a watch family here while every mapping downstream -- the + * layout picker, the complication types, the Tile decision -- recognises only the real four, + * so the kind lost its phone widget, gained watch codegen, and produced no usable surface + * anywhere. The build succeeded, which is the worst part.

+ * + * @param family a declared family name in either spelling + * @return true for the four {@code watch*} families + */ + public static boolean isWatch(String family) { + return family != null && WATCH_FAMILIES.contains(normalize(family)); + } + + /** + * Whether a declared family name is one this framework knows at all. + * + *

Separate from {@link #isWatch} because the answer "not a watch family" is given both to + * a phone family and to a typo, and only the caller writing the diagnostics can tell the + * reader which one it has.

+ * + * @param family a declared family name in either spelling + * @return true when the name maps onto a real surface family + */ + public static boolean isKnown(String family) { + if (family == null) { + return false; + } + String normalized = normalize(family); + return WATCH_FAMILIES.contains(normalized) || PHONE_FAMILIES.contains(normalized); + } + + /** + * Whether any declared family is a watch complication. True for a kind that offers both + * a phone widget and a complication, unlike {@link #isWatchOnly(List)}. + * + * @param families the declared families + * @return true if at least one is a watch family + */ + public static boolean hasWatchFamily(List families) { + if (families == null) { + return false; + } + for (String family : families) { + if (isWatch(family)) { + return true; + } + } + return false; + } + + /** + * Whether a kind declares complication families and nothing else, so a phone surface has + * nothing to offer it. + * + *

An empty declaration is not watch-only: it means the kind took the default, which + * is the three home-screen sizes.

+ * + * @param families the declared families + * @return true if every declared family is a watch family + */ + public static boolean isWatchOnly(List families) { + if (families == null || families.isEmpty()) { + return false; + } + for (String family : families) { + if (family != null && !isWatch(family)) { + return false; + } + } + return true; + } + + /** + * Whether a kind reaches a phone surface -- a home or lock screen. + * + *

An empty declaration counts, because a kind that names no family takes the + * home-screen default rather than opting out.

+ * + * @param families the declared families + * @return true if the kind has a phone surface + */ + public static boolean hasPhoneFamily(List families) { + return !isWatchOnly(families); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift index 08b46f96bf0..a9769e3abad 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift @@ -71,6 +71,12 @@ struct CN1WidgetEntryView: View { /// lock-screen layout otherwise -- an app that only publishes "lockscreen" still gets a /// complication, and one that publishes both gets the layout it designed for each surface. func cn1LayoutForFamily(_ layouts: [String: Any], family: WidgetFamily) -> [String: Any]? { + // The four system families are @available(watchOS, unavailable) -- not merely absent at + // runtime, but unnameable -- so the whole switch has to go behind the platform guard + // rather than relying on `default`. +#if os(watchOS) + var keys: [String] = [] +#else var keys: [String] switch family { case .systemSmall: @@ -82,6 +88,7 @@ func cn1LayoutForFamily(_ layouts: [String: Any], family: WidgetFamily) -> [Stri default: keys = [] } +#endif if #available(iOS 16.0, watchOS 9.0, *) { switch family { case .accessoryCircular: @@ -117,12 +124,19 @@ func cn1LayoutForFamily(_ layouts: [String: Any], family: WidgetFamily) -> [Stri /// Per-node Link actions only work on medium and larger home-screen families; everywhere /// else the tap target is the whole widget via widgetURL. func cn1FamilyAllowsLinks(_ family: WidgetFamily) -> Bool { + // Same reason as above: the system families cannot be named on watchOS. The answer is + // false there in any case -- every watch family is an accessory family, and none of them + // supports a per-node Link. +#if os(watchOS) + return false +#else switch family { case .systemMedium, .systemLarge, .systemExtraLarge: return true default: return false } +#endif } extension View { @@ -130,7 +144,10 @@ extension View { /// placeholder). The surfaces node tree draws its own backgrounds, so declare a /// clear one; earlier versions pass through unchanged. @ViewBuilder func cn1WidgetContainerBackground() -> some View { - if #available(iOS 17.0, *) { + // watchOS 10.0 is spelled out rather than left to `*`: the watch extension's floor is + // exactly 10.0, so an unqualified `*` would compile today and silently stop guarding + // if that floor were ever lowered. + if #available(iOS 17.0, watchOS 10.0, *) { self.containerBackground(for: .widget) { Color.clear } diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift index 9d175d24cc8..a7d151d72d3 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift @@ -170,7 +170,14 @@ func cn1Color(_ spec: Any?) -> Color? { case "secondaryLabel": return Color.secondary case "background": + // systemBackground is API_UNAVAILABLE(watchos). A watch face composites over + // black and has no light appearance, so black is the answer the role means + // there rather than a stand-in for one. +#if os(watchOS) + return Color.black +#else return Color(UIColor.systemBackground) +#endif case "accent": return Color.accentColor default: @@ -181,9 +188,16 @@ func cn1Color(_ spec: Any?) -> Color? { return nil } let dark = cn1Int(dict["d"]) ?? light + // colorWithDynamicProvider: is API_UNAVAILABLE(watchos), and there is nothing to resolve + // there anyway: watchOS has no light appearance, so the dark half of the pair is the + // right colour rather than a degraded one. +#if os(watchOS) + return Color(cn1UIColor(argb: dark)) +#else return Color(UIColor { trait in trait.userInterfaceStyle == .dark ? cn1UIColor(argb: dark) : cn1UIColor(argb: light) }) +#endif } // MARK: - Vector node parsing @@ -258,14 +272,19 @@ func cn1Alignment(_ value: Any?) -> Alignment? { // MARK: - Actions -/// Builds the canonical surfaces deep link cn1surface://a?src=..&id=..&p= +/// Builds the canonical surfaces deep link ://a?src=..&id=..&p= /// handled by the Codename One app delegate. +/// +/// The scheme is this app's own (cn1surface.) rather than the bare cn1surface every +/// Codename One app used to claim: a URL scheme is a global registration, and on the watch -- +/// where a complication tap is routed by nothing else -- two installed apps claiming one name +/// meant the tap could open the wrong bundle. func cn1ActionURL(source: String, action: [String: Any]) -> URL? { guard let actionId = action["id"] as? String else { return nil } var components = URLComponents() - components.scheme = "cn1surface" + components.scheme = cn1SurfaceScheme components.host = "a" var items = [URLQueryItem(name: "src", value: source), URLQueryItem(name: "id", value: actionId)] if let params = action["p"], JSONSerialization.isValidJSONObject(params), diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift index 5234a97f1ef..5bc29e4f959 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift @@ -38,6 +38,10 @@ import Foundation import SwiftUI import UIKit +#if os(watchOS) +// The downsampling path below; ImageIO is present in the watchOS SDK. +import ImageIO +#endif /// Everything a render pass needs besides the node itself. /// @@ -262,17 +266,40 @@ private func cn1RenderDynamicText(_ node: [String: Any], _ ctx: CN1RenderContext // MARK: - Images +// A complication is a few dozen points on its longest side and the watch extension's memory +// cap is far below the phone's, so the watch ceiling is a quarter of the phone's rather than +// the same number. +#if os(watchOS) +private let cn1MaxImageDimension: CGFloat = 256 +#else private let cn1MaxImageDimension: CGFloat = 1024 +#endif /// Widget extensions run under a tight (~30MB) memory cap: decode from the app group -/// container and downsample anything larger than 1024px on its longest side. +/// container and downsample anything larger than the platform ceiling on its longest side. @available(iOS 14.0, *) private func cn1LoadImage(_ dir: URL?, name: String) -> UIImage? { guard let dir = dir, !name.isEmpty else { return nil } - let path = dir.appendingPathComponent(name + ".png").path - guard let image = UIImage(contentsOfFile: path) else { + let url = dir.appendingPathComponent(name + ".png") +#if os(watchOS) + // UIGraphicsImageRenderer is API_UNAVAILABLE(watchos). ImageIO is in the watchOS SDK and + // is the better tool here anyway: it downsamples during decode, so the full-size bitmap + // is never resident -- which matters more on the watch than on the phone. + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: cn1MaxImageDimension + ] + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return UIImage(contentsOfFile: url.path) + } + return UIImage(cgImage: thumb) +#else + guard let image = UIImage(contentsOfFile: url.path) else { return nil } let largest = max(image.size.width, image.size.height) @@ -285,6 +312,7 @@ private func cn1LoadImage(_ dir: URL?, name: String) -> UIImage? { return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: target)) } +#endif } @available(iOS 14.0, *) diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java new file mode 100644 index 00000000000..d541cb6ebbe --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java @@ -0,0 +1,902 @@ +/* + * 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.app.PendingIntent; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.util.Log; + +import androidx.wear.watchface.complications.data.ComplicationData; +import androidx.wear.watchface.complications.data.CountDownTimeReference; +import androidx.wear.watchface.complications.data.CountUpTimeReference; +import androidx.wear.watchface.complications.data.ComplicationText; +import androidx.wear.watchface.complications.data.ComplicationType; +import androidx.wear.watchface.complications.data.LongTextComplicationData; +import androidx.wear.watchface.complications.data.MonochromaticImage; +import androidx.wear.watchface.complications.data.MonochromaticImageComplicationData; +import androidx.wear.watchface.complications.data.NoDataComplicationData; +import androidx.wear.watchface.complications.data.PlainComplicationText; +import androidx.wear.watchface.complications.data.RangedValueComplicationData; +import androidx.wear.watchface.complications.data.ShortTextComplicationData; +import androidx.wear.watchface.complications.data.TimeDifferenceComplicationText; +import androidx.wear.watchface.complications.data.TimeDifferenceStyle; +import androidx.wear.watchface.complications.datasource.ComplicationDataSourceService; +import androidx.wear.watchface.complications.datasource.ComplicationDataTimeline; +import androidx.wear.watchface.complications.datasource.ComplicationRequest; +import androidx.wear.watchface.complications.datasource.TimeInterval; +import androidx.wear.watchface.complications.datasource.TimelineEntry; + +import org.json.JSONObject; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Serves one Codename One surface kind to a Wear OS watch face. + * + *

The build generates a tiny subclass per watch-bearing kind, carrying only its id; every + * decision lives here. Ships as a build-time resource rather than in the Android port because it + * compiles against {@code androidx.wear.watchface.complications}, which the port cannot depend + * on -- an app that publishes no complication must not carry the library.

+ * + *

A complication is not a small widget. The face asks for one specific + * {@link ComplicationType} and composes the answer into its own design: there is no layout to + * honour, and padding, background, alignment, weight and colour are the face's business. So the + * published node tree is flattened by {@link CN1WatchSurface} and mined for content -- the first + * text, the first progress value, the first image -- rather than rendered. Everything that + * cannot survive that is dropped, and said out loud once per render under the tag + * {@code CN1Surfaces} so a developer who wonders where their layout went can find out with + * {@code adb logcat}.

+ * + *

Nothing here throws. A data source that crashes takes the watch face down with it, so a + * malformed descriptor leaves the face showing whatever it had.

+ */ +public abstract class CN1ComplicationDataSource extends ComplicationDataSourceService { + + private static final String TAG = "CN1Surfaces"; + + /** Wear's own guidance for a short-text slot; longer strings are truncated by the face. */ + private static final int SHORT_TEXT_MAX = 7; + + /** The widget kind this data source serves. Supplied by the generated subclass. */ + protected abstract String getKindId(); + + @Override + public void onComplicationRequest(ComplicationRequest request, + ComplicationRequestListener listener) { + ComplicationType type = request.getComplicationType(); + ComplicationDataTimeline timeline = null; + ComplicationData data = null; + try { + timeline = buildTimeline(type); + if (timeline == null) { + data = build(type, null); + } + } catch (Throwable t) { + Log.w(TAG, "Could not build complication data for kind " + getKindId(), t); + } + try { + if (timeline != null) { + // The whole timeline, not the entry showing now. This service is asked once and + // UPDATE_PERIOD_SECONDS is deliberately 0, because polling a push-driven surface + // costs watch battery for nothing -- so an answer of one value stayed on the face + // for ever and the entries the app published for the hours ahead never appeared. + // + // setValidTimeRange does NOT solve that: it says when a value may be DISPLAYED and + // schedules no further request, so on its own it replaced a stale complication + // with an empty one. Handing the system the entries lets it swap them itself, at + // the stated moments, without waking this process at all -- which is the same + // bargain WidgetKit makes on the other platform, and what the published document + // was shaped for. + listener.onComplicationDataTimeline(timeline); + return; + } + listener.onComplicationData(data == null ? noData() : data); + } catch (Throwable t) { + Log.w(TAG, "Could not deliver complication data for kind " + getKindId(), t); + } + } + + /** + * Every published entry as a complication timeline, or null when there is nothing to serve. + * + * @param type the type the face asked for + * @return the timeline, or null + */ + private ComplicationDataTimeline buildTimeline(ComplicationType type) { + List readings = + CN1WatchSurface.readTimeline(this, getKindId(), familyFor(type)); + if (readings.isEmpty()) { + return null; + } + // A current entry this type cannot render does NOT end the search, for the same reason a + // later one does not: the entries after it may be renderable, and giving up here threw + // them away permanently. A complication is asked once and handed the whole timeline, and + // UPDATE_PERIOD_SECONDS is 0 by design -- so nothing would ever ask again, and a + // RANGED_VALUE slot whose progress node only appears in the next entry stayed empty for + // good. No-data covers the stretch before the first renderable entry, which is exactly + // what the default in a ComplicationDataTimeline is for. + long asOfNow = System.currentTimeMillis(); + ComplicationData current = build(type, readings.get(0), asOfNow); + List entries = new ArrayList(); + for (int i = 1; i < readings.size(); i++) { + CN1WatchSurface.Reading reading = readings.get(i); + // As of the moment the entry TAKES OVER, not as of this request. A future entry is + // rendered now and shown in an hour, so anything time-dependent in it -- an interval + // progress, a relative value that has crossed by then -- is wrong if it is resolved + // against the clock at build time, and stays wrong: no later request recomputes it. + ComplicationData entry = build(type, reading, Math.max(reading.getStart(), asOfNow)); + if (entry == null) { + // No-data for its interval, not a skipped entry. Skipping it leaves no entry + // covering that stretch, and what shows then is the timeline's DEFAULT -- which + // is the current reading. So a published timeline that moved to an entry with + // nothing this type can show kept displaying the old value as though it were + // still current, which is worse than showing nothing. + entry = noData(); + } + long end = reading.getNextFlipDate(); + // The base entry stops at this reading's FIRST crossing, where the entry addCrossings + // adds takes over. Running it to the reading's own end instead left two entries + // covering the same stretch, and a host handed overlapping intervals may reject the + // timeline outright or go on selecting the countdown it already had. + long firstCrossing = firstCrossingOf(reading); + long baseEnd = firstCrossing > 0 ? firstCrossing : end; + entries.add(new TimelineEntry( + new TimeInterval(Instant.ofEpochMilli(reading.getStart()), + baseEnd > reading.getStart() ? Instant.ofEpochMilli(baseEnd) + : Instant.MAX), + entry)); + // Crossings for THIS reading too, not only for the active one. A future entry with a + // relative target inside its own window would otherwise take over as a countdown and + // stay one past its target -- the same freeze the active entry's crossing exists to + // prevent, deferred by an hour. + addCrossings(type, reading, entries); + } + // Exhausted NOW -- the entry being shown is the last one -- and the app asked to be woken + // when that happened. Asked from the ACTIVE reading and not the final one: with future + // entries still to come the final one also has no flip date, so reading it here made the + // request hours early and never again at the moment it was for. + // + // Asked NOW, whenever the timeline reloads at its end -- not only when it is already + // exhausted. This is the only moment the provider gets: it is handed the whole timeline + // once, the system swaps entries itself, and UPDATE_PERIOD_SECONDS is 0 by design, so + // nothing calls back when the last entry finally takes over. Waiting for exhaustion meant + // the request was never made for the timeline that needed it most -- one published WITH + // future entries -- and the final value then stood for ever. + // + // Asking early is safe because the request is throttled (tryClaimBackgroundFetch) and is + // a no-op for an app that declares no background-fetch listener, which is the same + // treatment the widget path gives it. The cost of asking early is one fetch; the cost of + // not asking is a complication frozen on its last entry. + if (current == null && entries.isEmpty()) { + // Nothing this type can render, now or later. Answering with a timeline of nothing + // but no-data would replace whatever the face is showing with a blank slot, so the + // caller's own fallback is the better answer. + return null; + } + CN1WatchSurface.Reading active = readings.get(0); + addCrossings(type, active, entries); + if (active.isReloadAtEnd()) { + // AT the end, not now. Asking immediately spends the one throttled fetch hours early + // and can republish over entries the user has not seen yet; asking only when the + // timeline is already exhausted never happens, because nothing calls this provider + // when the last entry takes over. An alarm is the only thing that survives both -- + // and it survives the process too, which a posted Runnable does not. + long end = timelineEnd(readings); + if (end <= 0) { + // Already exhausted, or ending without a stated moment. Now is the only answer. + CN1WidgetProvider.requestAppRefresh(this, getKindId()); + } else { + CN1WidgetProvider.scheduleAppRefresh(this, getKindId(), end); + } + } + return new ComplicationDataTimeline(current == null ? noData() : current, entries); + } + + @Override + public ComplicationData getPreviewData(ComplicationType type) { + try { + ComplicationData data = build(type, null); + if (data != null) { + return data; + } + } catch (Throwable t) { + Log.w(TAG, "Could not build preview data for kind " + getKindId(), t); + } + // The gallery shows this before the app has ever published, so a placeholder that names + // the kind beats an empty slot the user cannot identify. No validity: a placeholder does + // not go stale, and expiring it would ask the system to come back for the same answer. + // + // Of the REQUESTED type, which is the part that matters. Wear takes preview data as an + // answer about the type it asked about, and a ShortText handed to a slot advertising + // LONG_TEXT or RANGED_VALUE is rejected or drawn empty -- so the kind would be missing + // from exactly the pickers where its layout is roomiest. + return placeholder(type); + } + + /** + * A named but empty stand-in for a kind that has published nothing yet. + * + * @param type the type the picker asked about + * @return placeholder data of that type, or null when there is no sensible one + */ + private ComplicationData placeholder(ComplicationType type) { + String label = getKindId(); + if (ComplicationType.LONG_TEXT.equals(type)) { + return new LongTextComplicationData.Builder(plain(label), plain(label)).build(); + } + if (ComplicationType.RANGED_VALUE.equals(type)) { + // Empty rather than arbitrary: an invented fraction in a picker is a claim about a + // value the app has never published. + return new RangedValueComplicationData.Builder(0f, 0f, 1f, plain(label)) + .setText(plain(shorten(label))) + .build(); + } + if (ComplicationType.MONOCHROMATIC_IMAGE.equals(type)) { + // No icon exists before a publish, and this type is nothing but its icon, so there is + // no honest placeholder to give. Null lets the picker fall back to another type. + return null; + } + // The FULL label. shortText shortens what it shows and keeps what it is given as the + // content description, so shortening first handed the picker and TalkBack the same seven + // characters the slot already displays -- the published path was fixed for exactly this + // and the placeholder kept doing it. + return shortText(null, label, null, null, null); + } + + /** + * The portable family whose layout best serves a requested complication type. + * + *

The mapping is the one {@code WidgetSize} documents, read backwards: a face asking for + * long text wants the roomy layout, one asking for a ranged value or a glyph wants the round + * one, and short text is the readout every family can produce.

+ */ + /// + /// A PREFERENCE, not a requirement, and that is why a kind declaring only watchCircular is + /// still right to advertise SHORT_TEXT. The name returned here is the first thing + /// CN1WatchSurface.pickLayout tries; when the published document has no layout under it the + /// picker falls back through the kind's other watch layouts, and only families the kind + /// actually declared are in the document at all -- so the fallback lands on one of its own, + /// never on unrelated content. Selecting here among the declared families would move that + /// decision to a place that cannot see the document. + private static String familyFor(ComplicationType type) { + if (ComplicationType.LONG_TEXT.equals(type)) { + return "watchRectangular"; + } + if (ComplicationType.SHORT_TEXT.equals(type)) { + return "watchInline"; + } + return "watchCircular"; + } + + private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading given) { + return build(type, given, System.currentTimeMillis()); + } + + /// As {@link #build(ComplicationType, CN1WatchSurface.Reading)}, but resolving anything that + /// depends on "now" against a stated moment. Used to build the entry that takes over when a + /// relative countdown reaches its target -- that entry has to be composed as the moment AFTER + /// the target, not as now. + private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading given, + long asOf) { + CN1WatchSurface.Reading reading = given != null ? given + : CN1WatchSurface.read(this, getKindId(), familyFor(type)); + if (reading == null) { + return null; + } + List nodes = CN1WatchSurface.flatten(reading.getLayout()); + List texts = CN1WatchSurface.texts(nodes, reading.getState()); + PendingIntent tap = tapIntent(reading.getLayout()); + reportDroppedContent(nodes, texts); + // The node the primary text came from, so a dynamic one can be handed over as a value the + // FACE ticks rather than a string frozen at this request. With the update period at 0 + // there is no later request to refresh it, so a countdown really did stop. + JSONObject primaryNode = texts.isEmpty() ? null + : textNodeAt(nodes, reading.getState(), 0); + boolean titleTicks = primaryNode != null + && tickingText(primaryNode, reading.getState(), asOf) != null; + ComplicationText primary = primaryNode == null ? null + : textFor(primaryNode, reading.getState(), texts.get(0), asOf); + + if (ComplicationType.LONG_TEXT.equals(type)) { + String title = texts.isEmpty() ? getKindId() : texts.get(0); + String body = texts.size() > 1 ? join(texts, 1) : ""; + ComplicationText titleText = primary != null ? primary : plain(title); + // The BODY can tick as well, and in a rectangular layout it is the likelier place for + // a countdown -- a static label first, the moving value beneath it. Only when the body + // is exactly one node, though: a join of several is a string, and there is nothing to + // hand a face that would advance part of it. + // Whether the body is a value the FACE advances, tracked rather than inferred: a + // ticking text and a plain one are both ComplicationText, and the description below + // has to know which it has. + JSONObject bodyNode = texts.size() == 2 + ? textNodeAt(nodes, reading.getState(), 1) : null; + boolean bodyTicks = bodyNode != null + && tickingText(bodyNode, reading.getState(), asOf) != null; + ComplicationText bodyText = bodyNode != null + ? textFor(bodyNode, reading.getState(), texts.get(1), asOf) + : plain(body); + // A body that JOINS several nodes cannot tick, and this is a real limitation rather + // than an oversight: a ticking value is an object the face advances, not a string, so + // there is nothing to hand it that would advance one part of a joined sentence. The + // androidx builder offers no verified way to surround a time difference with text -- + // nothing in the shipped API states one -- and guessing at a placeholder convention + // would risk rendering the wrong thing rather than a stale one. + // + // A relative value still moves, because its crossing rebuilds the whole string; a + // timer does not. So the developer is told once, with the fix: put the moving value + // in its own second node and the rest in the title. + if (texts.size() > 2 && hasDynamicNode(nodes)) { + Log.i(TAG, "Complication \"" + getKindId() + "\" joins " + (texts.size() - 1) + + " text nodes into its long-text body, so a countdown or timer among " + + "them is shown frozen: a joined string is not a value the watch face " + + "can advance. Put the moving value in the second text node on its own " + + "to have the face tick it."); + } + // The whole value, not just the title. TalkBack reads this instead of the layout, so + // omitting the body announced the label of a complication without the thing it says + // -- the order status, the message, the number the user actually wanted. + String spoken = body.length() == 0 ? title : title + ", " + body; + // Described by whichever part TICKS, for the reason the short-text branch gives: a + // plain description of a moving value is announced wrong. The body is preferred when + // both could, being the value rather than the label. + // Whichever part actually moves describes the whole, because a plain description of + // a moving value is announced wrong and nothing comes back to fix it. The body wins + // when both move, being the value rather than the label; the title is used whenever + // it is the only thing moving, with or without a body beside it. + ComplicationText spokenText = plain(spoken); + if (body.length() > 0 && bodyTicks) { + spokenText = bodyText; + } else if (titleTicks) { + spokenText = titleText; + } + LongTextComplicationData.Builder builder = + new LongTextComplicationData.Builder( + body.length() == 0 ? titleText : bodyText, spokenText) + .setTitle(body.length() == 0 ? null : titleText) + .setTapAction(tap); + return builder.build(); + } + if (ComplicationType.RANGED_VALUE.equals(type)) { + JSONObject prog = CN1WatchSurface.firstOfType(nodes, "prog"); + float value = CN1WatchSurface.progressValue(prog, reading.getState(), asOf); + if (value < 0) { + // The face asked for a gauge and the layout has none. Answering with no data + // lets it fall back to another type rather than showing an empty ring. + return null; + } + // Described by the ticking text when the value ticks, for the reason the short-text + // branch gives: a description resolved at request time is announced long after the + // face has moved on, and there is no later request to correct it. + ComplicationText rangedDescription = titleTicks && primary != null ? primary + : plain(texts.isEmpty() ? getKindId() : texts.get(0)); + RangedValueComplicationData.Builder builder = + new RangedValueComplicationData.Builder(value, 0f, 1f, rangedDescription); + if (!texts.isEmpty()) { + builder.setText(primary != null ? primary : plain(shorten(texts.get(0)))); + } + builder.setTapAction(tap); + return builder.build(); + } + if (ComplicationType.MONOCHROMATIC_IMAGE.equals(type)) { + Icon icon = monochromeIcon(nodes, reading.getState()); + if (icon == null) { + return null; + } + // Described by the ticking text when there is one, the same rule the other three + // types follow: this slot shows only an icon, so the description IS the value to a + // screen reader, and a request-time string is announced long after the face has + // advanced with nothing to correct it. + MonochromaticImageComplicationData.Builder builder = + new MonochromaticImageComplicationData.Builder( + new MonochromaticImage.Builder(icon).build(), + titleTicks && primary != null ? primary + : plain(texts.isEmpty() ? getKindId() : texts.get(0))) + .setTapAction(tap); + return builder.build(); + } + if (ComplicationType.SHORT_TEXT.equals(type)) { + if (texts.isEmpty()) { + return null; + } + // UNTRUNCATED. shortText shortens what it displays and keeps what it is given as the + // content description, so shortening first handed a screen reader the same seven + // characters the slot already shows -- losing exactly the text the description exists + // to supply. + // The second node can tick too -- it is displayed as the complication's title, and a + // countdown put there froze exactly as the primary one used to. + JSONObject titleNode = texts.size() > 1 + ? textNodeAt(nodes, reading.getState(), 1) : null; + ComplicationText tickingTitle = titleNode == null ? null + : tickingText(titleNode, reading.getState(), asOf); + return shortText(titleTicks ? primary : null, texts.get(0), + texts.size() > 1 ? texts.get(1) : null, tickingTitle, tap); + } + return null; + } + + /** + * Adds an entry for each moment this reading's relative text changes sides. + * + *

A relative value counting down to a target has to become "ago" when it reaches it -- + * that is what formatRelative does everywhere else -- and the direction is fixed by the + * reference type handed to TimeDifferenceComplicationText, so one text cannot span both. + * Nothing would rebuild it either: the provider is asked once and sets no update period. The + * timeline is the mechanism, and each crossing needs its OWN entry.

+ * + *

Every ticking node, not merely the earliest: a long-text layout can carry two relative + * values with different targets, and keeping only the first left the second counting down + * past its own. Each entry is composed as the moment after its crossing, so every node is on + * the side it should be by then.

+ * + *

Bounded to the reading's own window. A crossing at or after the next flip belongs to the + * entry that replaces this one, which computes its own.

+ * + * @param type the complication type being built + * @param reading the entry whose crossings are wanted + * @param entries the timeline being assembled + */ + /// When the published timeline runs out, or 0 when it already has. + /// + /// The final reading's START, not its flip date. readTimeline computes each reading's flip + /// from the entries after it, so the last one's is ALWAYS zero -- reading it made this method + /// answer zero for every timeline and the scheduling branch unreachable, which is the whole + /// mechanism defeated by one wrong field. + /// + /// The start is also the right moment on its own terms: reload-at-end means "fetch when the + /// timeline is exhausted", and it is exhausted when the last entry takes over, since there is + /// nothing behind it. + private static long timelineEnd(List readings) { + if (readings.size() < 2) { + // One reading is the last one, and it is already current. + return 0; + } + return readings.get(readings.size() - 1).getStart(); + } + + private long firstCrossingOf(CN1WatchSurface.Reading reading) { + java.util.List ordered = crossingsOf(reading); + return ordered.isEmpty() ? 0 : ordered.get(0).longValue(); + } + + /// Every moment this reading's text changes sides, in order. One computation, used both to + /// end the base entry and to build the entries that follow it, so the two cannot disagree + /// about where one stops and the next begins. + /// + /// Bounded at BOTH ends by the reading's own window. A crossing at or after the next flip + /// belongs to the entry that replaces this one, and one before the reading begins has already + /// happened by the time it takes over -- the entry is built as of its own start, so it is + /// already on the right side. + /// + /// EVERY displayed node contributes, not the first two: a long-text body joins every value + /// from index one, so a relative node third or later is shown as well, and without a crossing + /// it stays on its pre-target wording for good. + private java.util.List crossingsOf(CN1WatchSurface.Reading reading) { + java.util.TreeSet crossings = new java.util.TreeSet(); + if (reading == null) { + return new ArrayList(); + } + long windowEnd = reading.getNextFlipDate(); + long windowStart = reading.getStart(); + List nodes = CN1WatchSurface.flatten(reading.getLayout()); + JSONObject state = reading.getState(); + for (JSONObject node : textNodes(nodes, state)) { + long at = relativeCrossingOf(node, reading); + if (at > 0 && at > windowStart && (windowEnd <= 0 || at < windowEnd)) { + crossings.add(Long.valueOf(at)); + } + } + addIntervalSamples(nodes, windowStart, windowEnd, crossings); + return new ArrayList(crossings); + } + + /// How many times an interval-based gauge is stepped across the part of it we can see. + /// Enough that a bar visibly advances, few enough that a timeline stays small. + private static final int INTERVAL_SAMPLES = 12; + + /// The shortest gap worth an entry. Below this the face would redraw more often than the + /// value meaningfully changes. + private static final long MIN_SAMPLE_GAP_MILLIS = 60L * 1000L; + + /** + * Adds moments at which an interval-based progress value should be re-rendered. + * + *

A prog node carrying start and end derives its fraction from the clock, and + * progressValue snapshots it -- so a RANGED_VALUE complication showed one fraction for the + * whole reading while the same node on iOS advances. Crossings do not help: they exist for + * text changing sides, and a gauge has no side to change.

+ * + *

The timeline is the only mechanism available, the provider having no update period, so + * the visible part of the interval is stepped. Bounded on both counts: at most + * {@link #INTERVAL_SAMPLES} entries, and none closer together than + * {@link #MIN_SAMPLE_GAP_MILLIS}, so a five-minute interval does not produce a timeline of + * hundreds and a week-long one still moves.

+ * + * @param nodes the flattened layout + * @param windowStart the reading's own start + * @param windowEnd the reading's flip date, or 0 when it has none + * @param into the moments collected so far + */ + private static void addIntervalSamples(List nodes, long windowStart, + long windowEnd, java.util.TreeSet into) { + for (JSONObject node : nodes) { + if (!"prog".equals(node.optString("t", "")) + || !node.has("start") || !node.has("end")) { + continue; + } + // Never before NOW. The active reading's own start is in the past, so sampling from + // there emitted entries whose intervals already cover the present -- and one of them + // then overrides the default, which is the only value built for the current moment. + // A week-long interval sampled twelve times would show a gauge fourteen hours stale + // the instant it appeared. A future reading is unaffected: now is before its start, + // so the clamp does nothing there. + long from = Math.max(Math.max(node.optLong("start"), windowStart), + System.currentTimeMillis()); + long to = node.optLong("end"); + if (windowEnd > 0) { + to = Math.min(to, windowEnd); + } + if (to <= from) { + // The interval is over, or outside this reading. A finished gauge does not move. + continue; + } + long step = Math.max((to - from) / INTERVAL_SAMPLES, MIN_SAMPLE_GAP_MILLIS); + for (long at = from + step; at < to; at += step) { + into.add(Long.valueOf(at)); + } + // And the END itself, when the interval finishes inside this reading. The loop stops + // short of it, so a twelve-step gauge climbed to about eleven twelfths and stayed + // there: the last entry ran on indefinitely holding a partial value, and the one + // moment the bar is actually full was never shown. + if (to == node.optLong("end") && (windowEnd <= 0 || to < windowEnd)) { + into.add(Long.valueOf(to)); + } + } + } + + private void addCrossings(ComplicationType type, CN1WatchSurface.Reading reading, + List entries) { + if (reading == null) { + return; + } + long windowEnd = reading.getNextFlipDate(); + java.util.List ordered = crossingsOf(reading); + for (int i = 0; i < ordered.size(); i++) { + long at = ordered.get(i).longValue(); + ComplicationData after = build(type, reading, at + 1); + if (after == null) { + // No-data, not a skip. The base entry has already been ended at the first of + // these moments, so skipping leaves that stretch uncovered and the timeline falls + // back to its default -- the OLDER reading, resurfacing after it stopped being + // current. This is the same reason the main loop substitutes no-data. + after = noData(); + } + // Until the NEXT crossing, so two nodes changing sides at different moments each get + // their own stretch rather than the first one's entry covering both. + long until = i + 1 < ordered.size() ? ordered.get(i + 1).longValue() : windowEnd; + entries.add(new TimelineEntry( + new TimeInterval(Instant.ofEpochMilli(at), + until > at ? Instant.ofEpochMilli(until) : Instant.MAX), + after)); + } + } + + /// One node's crossing moment, or 0 when it has none. + private long relativeCrossingOf(JSONObject node, CN1WatchSurface.Reading reading) { + if (node == null || !"dyn".equals(node.optString("t", "")) + || !"relative".equals(node.optString("style", "timerDown"))) { + return 0; + } + long date = CN1WatchSurface.dynamicDate(node, reading.getState()); + return date > System.currentTimeMillis() ? date : 0; + } + + /** + * The nodes that actually produced the strings in {@code texts}, in the same order. + * + *

Indexing matters here and the two lists have to be built by the SAME rule. + * {@code CN1WatchSurface.texts} drops a node whose value resolves to nothing -- a missing + * ${placeholder} is the ordinary way -- so a plain scan for text-bearing nodes returns a + * longer list, and texts.get(0) then belonged to a different node than the first one found. + * A timer displayed after an empty label was handed over as the label's static text and + * frozen, which is the failure the ticking work exists to prevent, reached by an off-by-one.

+ * + * @param nodes the flattened layout + * @param state the entry state, which decides what resolves to nothing + * @return the contributing nodes, positionally matching texts() + */ + private static List textNodes(List nodes, JSONObject state) { + List out = new ArrayList(); + for (JSONObject node : nodes) { + String type = node.optString("t", ""); + if (!"text".equals(type) && !"dyn".equals(type)) { + continue; + } + // One node at a time through the same call texts() makes, so the two cannot disagree + // about what "resolves to nothing" means. + List one = new ArrayList(1); + one.add(node); + if (!CN1WatchSurface.texts(one, state).isEmpty()) { + out.add(node); + } + } + return out; + } + + /// The nth contributing text node, or null. + private static JSONObject textNodeAt(List nodes, JSONObject state, int index) { + List contributing = textNodes(nodes, state); + return index < contributing.size() ? contributing.get(index) : null; + } + + + /// A node's complication text: the face-ticked form where the node is dynamic and the style + /// is one that moves, a plain string otherwise. + private ComplicationText textFor(JSONObject node, JSONObject state, String resolved, + long asOf) { + if (node != null && "dyn".equals(node.optString("t", ""))) { + ComplicationText ticking = tickingText(node, state, asOf); + if (ticking != null) { + return ticking; + } + } + return plain(resolved); + } + + /** + * A dynamic node as a value the watch face advances itself, or null when it cannot be one. + * + *

Only the three time-RELATIVE styles qualify. {@code time} and {@code date} format the + * node's OWN timestamp -- a published moment, not the current one -- so handing them to a + * clock text would replace the value with whatever time it is now, which is a different + * number and a wrong one. They stay plain, and correctly so: nothing about them moves.

+ * + *

The reward for the three that do qualify is that the face redraws them from its own + * clock with no wake-up, which is the only way a countdown ticks at all here: the generated + * provider sets no update period, so there is no second request in which to re-render it.

+ * + * @param node a {@code dyn} node + * @param state the entry state, which may supply the date by key + * @return the ticking text, or null to fall back to a plain string + */ + private ComplicationText tickingText(JSONObject node, JSONObject state, long asOf) { + long date = CN1WatchSurface.dynamicDate(node, state); + if (date <= 0 || Build.VERSION.SDK_INT < 26) { + return null; + } + String style = node.optString("style", "timerDown"); + try { + java.time.Instant at = java.time.Instant.ofEpochMilli(date); + if ("relative".equals(style)) { + // "in 3m" / "3m ago" -- a single unit, which is what a relative date reads as. + return date > asOf + ? new TimeDifferenceComplicationText.Builder( + TimeDifferenceStyle.SHORT_SINGLE_UNIT, + new CountDownTimeReference(at)).build() + : new TimeDifferenceComplicationText.Builder( + TimeDifferenceStyle.SHORT_SINGLE_UNIT, + new CountUpTimeReference(at)).build(); + } + if ("timerUp".equals(style)) { + return new TimeDifferenceComplicationText.Builder(TimeDifferenceStyle.STOPWATCH, + new CountUpTimeReference(at)).build(); + } + if ("timerDown".equals(style)) { + return new TimeDifferenceComplicationText.Builder(TimeDifferenceStyle.STOPWATCH, + new CountDownTimeReference(at)).build(); + } + return null; + } catch (Throwable t) { + Log.w(TAG, "Could not build a ticking complication text for kind " + getKindId(), t); + return null; + } + } + + private ShortTextComplicationData shortText(ComplicationText ticking, String text, + String title, ComplicationText tickingTitle, PendingIntent tap) { + boolean titled = title != null && title.length() > 0; + // The content description, which a screen reader reads instead of the layout. + // + // When nothing moves it is both strings UNTRUNCATED -- the slot shows seven characters + // and the title beside it, and describing only the shortened text announced half of what + // is on the face. That is why the title arrives whole and is shortened below, where its + // visual form is made, rather than by the caller. + // + // When something DOES move, that thing describes the whole: the value first, then the + // title. A plain description is a string resolved at request time, and with no update + // period a screen reader would go on announcing the moment the provider was called long + // after the face had advanced -- reading out a time that is simply wrong. A ticking text + // is the same object the face advances, so it stays right. + // + // The other half is not folded in then, because a ticking value is an object rather than + // a string and there is nothing to concatenate onto. An announcement that is shorter and + // correct beats one that is complete and wrong. + ComplicationText described; + if (ticking != null) { + described = ticking; + } else if (tickingTitle != null) { + described = tickingTitle; + } else { + described = plain(titled ? text + ", " + title : text); + } + ShortTextComplicationData.Builder builder = + new ShortTextComplicationData.Builder( + ticking != null ? ticking : plain(shorten(text)), described); + if (titled) { + // Handed over whole when it ticks: shortening it would mean rendering it here, which + // is the freezing this avoids. The face sizes what it draws. + builder.setTitle(tickingTitle != null ? tickingTitle : plain(shorten(title))); + } + builder.setTapAction(tap); + return builder.build(); + } + + private Icon monochromeIcon(List nodes, JSONObject state) { + JSONObject node = CN1WatchSurface.firstOfType(nodes, "img"); + if (node == null) { + // A vector is the better source anyway: a gauge or a ring is exactly what this slot + // is for, and it rasterizes to a mask the face can tint. + node = CN1WatchSurface.firstOfType(nodes, "vec"); + } + Bitmap bitmap = CN1WatchSurface.bitmap(this, getKindId(), node, state); + if (bitmap == null || Build.VERSION.SDK_INT < 26) { + return null; + } + return Icon.createWithBitmap(bitmap); + } + + /** + * A request code for a tap intent, derived from its data string. + * + *

A PendingIntent request code is an {@code int} by API, so this cannot be widened the way + * the tile resource ids were. What it can avoid is String.hashCode's constructible + * collisions -- the ones short human-chosen strings actually hit, "Aa" and "BB" being the + * standard example. Extras are not part of {@code filterEquals}, so two complications whose + * data strings collided here would share one PendingIntent and the later would overwrite the + * earlier's extras; a digest makes that an accident nobody has managed to have rather than + * something a pair of kind ids can stumble into.

+ * + * @param data the intent's data string + * @return the request code + */ + private static int requestCode(String data) { + String material = data == null ? "" : data; + try { + byte[] bytes = java.security.MessageDigest.getInstance("SHA-256") + .digest(material.getBytes("UTF-8")); + return ((bytes[0] & 0xff) << 24) | ((bytes[1] & 0xff) << 16) + | ((bytes[2] & 0xff) << 8) | (bytes[3] & 0xff); + } catch (Exception noDigest) { + return material.hashCode(); + } + } + + private PendingIntent tapIntent(JSONObject layout) { + Intent intent = CN1WatchSurface.rootAction(this, getKindId(), layout); + if (intent == null) { + return null; + } + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 23) { + // FLAG_IMMUTABLE, named by value because the port compiles against an older SDK. + flags |= 0x04000000; + } + try { + return PendingIntent.getActivity(this, requestCode(intent.getDataString()), intent, + flags); + } catch (Throwable t) { + Log.w(TAG, "Could not build the complication tap action for " + getKindId(), t); + return null; + } + } + + /** + * Names what the face will not be showing. + * + *

A complication reduces a layout to a few values, and a developer whose careful design + * arrives as one number deserves to know that is by design rather than a bug.

+ */ + private void reportDroppedContent(List nodes, List texts) { + int images = 0; + for (JSONObject node : nodes) { + String type = node.optString("t", ""); + if ("img".equals(type) || "vec".equals(type)) { + images++; + } + } + if (hasDynamicNode(nodes)) { + Log.i(TAG, "Complication \"" + getKindId() + "\" renders a dynamic value as text, " + + "formatted when the face asks and refreshed when the timeline flips. A " + + "watch face slot takes a string, so it does not tick between updates."); + } + if (texts.size() > 2 || images > 1) { + Log.i(TAG, "Complication \"" + getKindId() + "\" reduces its layout to what a watch " + + "face slot can show: " + Math.min(texts.size(), 2) + " of " + texts.size() + + " text node(s) and " + Math.min(images, 1) + " of " + images + " image(s). " + + "Containers, padding, background, alignment and per-node colour are the " + + "face's own, not the app's."); + } + } + + /** + * Cuts a value down to what a short-text slot shows, without cutting a character in half. + * + *

The limit counts CODE POINTS rather than UTF-16 units, for two reasons that agree. It is + * what the limit means -- Wear's guidance is about characters a face can show, and a + * supplementary character is one of them. And a cut landing between the halves of one leaves + * a lone surrogate, which PlainComplicationText replaces or rejects: the slot then shows a + * corrupt character instead of the published value.

+ */ + private static String shorten(String text) { + if (text == null) { + return ""; + } + if (text.length() <= SHORT_TEXT_MAX) { + return text; + } + int points = text.codePointCount(0, text.length()); + if (points <= SHORT_TEXT_MAX) { + return text; + } + return text.substring(0, text.offsetByCodePoints(0, SHORT_TEXT_MAX)); + } + + /** + * Whether a text came from a dynamic node, which a watch face can tick for itself. + * + *

Not used to choose the value -- {@link CN1WatchSurface#texts} already formats it -- but + * to say so in the log, because a frozen countdown in a slot is the one degradation a + * developer is most likely to mistake for a bug.

+ */ + private static boolean hasDynamicNode(List nodes) { + for (JSONObject node : nodes) { + if ("dyn".equals(node.optString("t", ""))) { + return true; + } + } + return false; + } + + private static String join(List texts, int from) { + StringBuilder sb = new StringBuilder(); + for (int i = from; i < texts.size(); i++) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(texts.get(i)); + } + return sb.toString(); + } + + private static ComplicationText plain(String text) { + return new PlainComplicationText.Builder(text == null ? "" : text).build(); + } + + private static ComplicationData noData() { + return new NoDataComplicationData(); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java new file mode 100644 index 00000000000..6a973cdd330 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java @@ -0,0 +1,1069 @@ +/* + * 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.graphics.Bitmap; +import android.util.Log; + +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.wear.protolayout.ColorBuilders; +import androidx.wear.protolayout.DimensionBuilders; +import androidx.wear.protolayout.LayoutElementBuilders; +import androidx.wear.protolayout.ModifiersBuilders; +import androidx.wear.protolayout.ResourceBuilders; +import androidx.wear.protolayout.TimelineBuilders; +import androidx.wear.tiles.RequestBuilders; +import androidx.wear.tiles.TileBuilders; +import androidx.wear.tiles.TileService; + +import com.google.common.util.concurrent.ListenableFuture; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Serves one Codename One surface kind as a Wear OS Tile. + * + *

Generated per kind that declares {@code WATCH_RECTANGULAR}, which is the only family roomy + * enough for a layout rather than a readout. Ships as a build-time resource because it compiles + * against {@code androidx.wear.tiles} and {@code androidx.wear.protolayout}, which the Android + * port cannot depend on.

+ * + *

Unlike a complication, a Tile really does render the node tree: ProtoLayout has a near-1:1 + * counterpart for every node in the catalog, which was cut to the RemoteViews floor in the first + * place. Two things come out better here than on a phone widget -- circular progress + * renders natively where RemoteViews has to degrade to a linear bar, and per-node tap actions + * work where a small iOS widget honours only the root.

+ * + *

The one real limitation is time. A {@code SurfaceDynamicText} countdown ticks + * natively on both phone platforms; here it is frozen at render and the Tile asks again when the + * timeline says the value changes. ProtoLayout's dynamic expressions could animate it, but they + * are version-sensitive and platform-gated, and a frozen value that is always correct beats a + * ticking one that works on some watches. This is the largest fidelity gap in the feature.

+ */ +public abstract class CN1SurfaceTileService extends TileService { + + private static final String TAG = "CN1Surfaces"; + + /** ProtoLayout resource version; bumped by content, so unchanged art is not re-sent. */ + private static final String ROOT_ID = "cn1_root"; + + /** A Tile refresh is rate-limited by the system, so asking more often than this is waste. */ + private static final long MIN_FRESHNESS_MILLIS = 60L * 1000L; + private static final long MAX_FRESHNESS_MILLIS = 24L * 60L * 60L * 1000L; + /// How often a Tile showing dynamic text asks to be rebuilt; see freshnessFor. + private static final long DYNAMIC_FRESHNESS_MILLIS = 60L * 1000L; + /// The accent a progress node takes when it declares no colour of its own; the same + /// value CN1SurfaceRenderer tints a widget's progress bar with. + private static final int ACCENT = 0xff007aff; + + /// The whole resource response travels in one Binder transaction, whose ceiling is about a + /// megabyte and is SHARED with everything else in flight on the binder. This is deliberately + /// well under it: exceeding the ceiling fails the request outright rather than degrading, and + /// a tile of this size is already far past what a watch face shows legibly. + private static final int RESOURCE_BUDGET_BYTES = 600 * 1024; + + /// How many recent readings stay available for a resources callback; see `served`. + private static final int MAX_REMEMBERED_READINGS = 8; + + /** + * The entries recent Tiles were built from, keyed by the version each advertised. + * + *

A map and not one slot: tile requests can be handled before an earlier one's resource + * callback arrives, and a single slot would be overwritten -- the earlier layout falling back + * to whatever is current and its image ids going unresolved, which is the failure this + * remembering exists to prevent. Two was not enough either, because nothing bounds how many + * requests the host has outstanding; the cap is now generous enough that eviction is a + * theoretical concern rather than a two-deep interleaving, while still bounding a long-lived + * Tile process. A reading is a layout and a state, not a bitmap, so holding a few is cheap.

+ * + *

Synchronized rather than volatile because the two callbacks arrive on different threads + * and this is now a read-modify-write.

+ */ + private final java.util.LinkedHashMap served = + new java.util.LinkedHashMap(); + + /** Remembers the entry a Tile of this version was built from. */ + private synchronized void remember(String version, CN1WatchSurface.Reading reading) { + served.put(version, reading); + while (served.size() > MAX_REMEMBERED_READINGS) { + java.util.Iterator oldest = served.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + } + + /** The entry a Tile of this version was built from, or null. */ + private synchronized CN1WatchSurface.Reading recall(String version) { + return version == null ? null : served.get(version); + } + + /** The widget kind this Tile serves. Supplied by the generated subclass. */ + protected abstract String getKindId(); + + @Override + protected ListenableFuture onTileRequest( + RequestBuilders.TileRequest request) { + return CallbackToFutureAdapter.getFuture( + new CallbackToFutureAdapter.Resolver() { + @Override + public Object attachCompleter( + CallbackToFutureAdapter.Completer completer) { + completer.set(buildTile()); + return "cn1TileRequest"; + } + }); + } + + @Override + protected ListenableFuture onTileResourcesRequest( + RequestBuilders.ResourcesRequest request) { + return CallbackToFutureAdapter.getFuture( + new CallbackToFutureAdapter.Resolver() { + @Override + public Object attachCompleter( + CallbackToFutureAdapter.Completer + completer) { + completer.set(buildResources(request.getVersion())); + return "cn1TileResources"; + } + }); + } + + private TileBuilders.Tile buildTile() { + LayoutElementBuilders.LayoutElement root; + long freshness = MAX_FRESHNESS_MILLIS; + String version = "0"; + try { + CN1WatchSurface.Reading reading = + CN1WatchSurface.read(this, getKindId(), "watchRectangular"); + if (reading == null) { + root = text("No data yet"); + } else { + root = render(reading.getLayout(), reading.getState(), 0, false); + // The flip date OR the moment something starts moving, whichever comes first. + // Refusing periodic refresh before an interval begins was right -- the bar is + // clamped at zero until then -- but on its own it left nothing to wake the Tile + // AT the start, so a reading with no flip date would have sat at zero for ever. + // One refresh scheduled for that moment is enough: the rebuild then sees the + // interval running and asks for the periodic rate itself. + long wakeAt = earlierOf(reading.getNextFlipDate(), + nextMovingStart(reading.getLayout(), reading.getState())); + freshness = freshnessFor(wakeAt, + hasMovingContent(reading.getLayout(), reading.getState())); + version = resourcesVersion(reading); + // Kept for the resources request that follows, which asks about THIS version. + remember(version, reading); + // Exhausted, and the app asked to be woken when that happened. A widget makes the + // same throttled request; a Tile that skipped it froze on its final entry until + // something else published. RELOAD_NEVER means what it says and is left alone. + if (reading.getNextFlipDate() <= 0 && reading.isReloadAtEnd()) { + CN1WidgetProvider.requestAppRefresh(this, getKindId()); + } + } + } catch (Throwable t) { + // A Tile that throws is removed from the carousel, so a malformed descriptor must + // degrade to something rather than nothing. + Log.w(TAG, "Could not build the Tile for kind " + getKindId(), t); + root = text(""); + } + return new TileBuilders.Tile.Builder() + .setResourcesVersion(version) + .setFreshnessIntervalMillis(freshness) + .setTileTimeline(new TimelineBuilders.Timeline.Builder() + .addTimelineEntry(new TimelineBuilders.TimelineEntry.Builder() + .setLayout(new LayoutElementBuilders.Layout.Builder() + .setRoot(root) + .build()) + .build()) + .build()) + .build(); + } + + /** + * How long before the Tile should ask again. + * + *

Driven by the timeline rather than a fixed poll: an app that publishes entries covering + * the hours ahead is refreshed by the system on its own clock without ever being woken, which + * is the same deal the phone platforms give a widget. Zero when the timeline is exhausted -- + * there is nothing further to show until the app publishes again.

+ */ + /** + * Whether anything in this layout changes with the clock rather than with a timeline flip. + * + *

Dynamic text is the obvious case. Interval PROGRESS is the one that was missed: a prog + * node carrying start and end has its fraction snapshotted by progressValue at render time, + * exactly as a countdown's string is, so a Tile with a filling gauge and no dynamic text + * asked for no freshness at all and the bar stood still.

+ * + * @param layout the resolved layout root + * @return true when the Tile should be rebuilt periodically + */ + private static boolean hasMovingContent(JSONObject layout, JSONObject state) { + long now = System.currentTimeMillis(); + for (JSONObject node : CN1WatchSurface.flatten(layout)) { + String type = node.optString("t", ""); + if ("prog".equals(type) && node.has("start") && node.has("end")) { + // Only while the interval is actually RUNNING. A finished one is clamped at its + // completed value for ever, so treating its mere presence as movement asked for + // a rebuild every minute until the app published again -- spending the Tile + // refresh budget and the battery to redraw an identical bar. + // RUNNING, which means started as well as unfinished. A reading published hours + // ahead has an interval that has not begun, and its fraction is clamped at zero + // until it does -- asking for a rebuild every minute through all of that spends + // the refresh budget and the battery redrawing an empty bar. + if (now >= node.optLong("start") && now < node.optLong("end")) { + return true; + } + continue; + } + if (!"dyn".equals(type)) { + continue; + } + String style = node.optString("style", "timerDown"); + if ("time".equals(style) || "date".equals(style)) { + // These format the node's OWN timestamp, which does not move. They were counted + // as dynamic because they are dyn nodes, and a Tile carrying nothing else rebuilt + // itself every minute to redraw the same string. + continue; + } + long target = CN1WatchSurface.dynamicDate(node, state); + if ("timerDown".equals(style) && target <= now) { + // An expired countdown is clamped at zero and equally still. + continue; + } + if ("timerUp".equals(style) && target > now) { + // A count-up toward a future target reads 0:00 until it arrives, so it is + // dormant in exactly the way a not-yet-started interval is. nextMovingStart + // brings the Tile back at the target rather than polling through the wait. + continue; + } + return true; + } + return false; + } + + /// The earlier of two moments, treating 0 as "no such moment". + private static long earlierOf(long a, long b) { + if (a <= 0) { + return b; + } + if (b <= 0) { + return a; + } + return Math.min(a, b); + } + + /** + * When the first not-yet-started interval in this layout begins, or 0 when none does. + * + *

An interval whose start is still ahead renders as an empty bar, and a count-up toward a + * future target reads 0:00; neither moves, so neither earns a periodic refresh -- but + * something has to bring the Tile back when they do begin, and a reading with no flip date + * has nothing else that would.

+ * + * @param layout the resolved layout root + * @return the earliest future start, or 0 + */ + private static long nextMovingStart(JSONObject layout, JSONObject state) { + long now = System.currentTimeMillis(); + long earliest = 0; + for (JSONObject node : CN1WatchSurface.flatten(layout)) { + String type = node.optString("t", ""); + if ("prog".equals(type) && node.has("start") && node.has("end")) { + long start = node.optLong("start"); + if (start > now && now < node.optLong("end")) { + earliest = earlierOf(earliest, start); + } + continue; + } + // A count-up toward a future target is dormant for the same reason and wakes the + // same way: it reads 0:00 until the target, and then begins to move. + if ("dyn".equals(type) && "timerUp".equals(node.optString("style", "timerDown"))) { + long target = CN1WatchSurface.dynamicDate(node, state); + if (target > now) { + earliest = earlierOf(earliest, target); + } + } + } + return earliest; + } + + private static long freshnessFor(long nextFlipDate, boolean hasDynamicText) { + long delta = nextFlipDate > 0 ? nextFlipDate - System.currentTimeMillis() : Long.MAX_VALUE; + if (nextFlipDate <= 0 && !hasDynamicText) { + // Nothing further to show until the app publishes again. + return 0; + } + if (hasDynamicText) { + // A clock, a countdown or a relative date. This is the fidelity gap the guide + // records: ProtoLayout has no ticking text this can lower onto, so the value is + // frozen at render time -- and with no flip date to ask about, nothing would ever + // ask again and "in 5 minutes" would still say that tomorrow. A bounded refresh is + // the honest compromise: minute-accurate rather than second-accurate, which is what + // a Tile's refresh rate limit allows anyway. + delta = Math.min(delta, DYNAMIC_FRESHNESS_MILLIS); + } + if (delta < MIN_FRESHNESS_MILLIS) { + return MIN_FRESHNESS_MILLIS; + } + return Math.min(delta, MAX_FRESHNESS_MILLIS); + } + + /// Whether the active layout shows anything that changes on its own. + + /** + * The version the Tile advertises for its resource set. + * + *

ONE computation, called from both the Tile and the resources it names: Wear caches + * resources by this string and only asks for them again when it changes, so the two sides + * disagreeing is either a stale bitmap or a rebuild on every frame.

+ * + *

The entry state is part of it, not just the resource ids. A vector's id already covers + * its own definition -- {@code imageId} hashes the node -- but not the state its ops read. + * A timeline flip to an entry that only moves a hand or fills a gauge leaves every id + * identical while the rasterizer would now draw something else, and the Tile kept showing + * the previous entry's artwork. Folding the state in costs a resource rebuild on a flip that + * did not need one, which is the harmless direction to be wrong in.

+ * + * @param reading the timeline entry being rendered + * @return the resources version + */ + /** + * Finds the entry that advertised a version, by re-deriving the versions from what is stored. + * + *

The version is a pure function of an entry, so this cannot guess wrong: an entry either + * hashes to the requested version or it is not the one being asked about.

+ * + * @param requested the version the host is asking for, possibly null + * @return the entry that advertised it, or null when nothing stored does + */ + private CN1WatchSurface.Reading rebuild(String requested) { + if (requested == null || requested.length() == 0) { + return null; + } + try { + for (CN1WatchSurface.Reading entry + : CN1WatchSurface.readAllEntries(this, getKindId(), "watchRectangular")) { + if (requested.equals(resourcesVersion(entry))) { + return entry; + } + } + } catch (Throwable t) { + Log.w(TAG, "Could not rebuild tile resources for version " + requested, t); + } + return null; + } + + private static String resourcesVersion(CN1WatchSurface.Reading reading) { + return digest(imageNames(reading.getLayout()).toString() + + "|" + String.valueOf(reading.getState())); + } + + /** + * A version string that differs whenever the material does. + * + *

String.hashCode is 32 bits and trivially collidable -- "Aa" and "BB" hash equally, and + * the material here is user-controlled text -- so two different snapshots could advertise one + * version. Wear would then treat changed artwork as unchanged and never request the new map, + * and {@link #rebuild} would match the wrong entry and rasterize against the wrong state. + * Neither corrects itself, because both read the collision as "nothing happened".

+ * + *

SHA-256 truncated to 128 bits: far past any accident, and short enough to stay a + * comfortable resource-version string. If the digest is somehow unavailable the material + * itself is the version -- longer, but injective, which is the property that matters.

+ * + * @param material the content the version stands for + * @return the version + */ + private static String digest(String material) { + try { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + byte[] bytes = sha.digest(material.getBytes("UTF-8")); + StringBuilder out = new StringBuilder(32); + for (int i = 0; i < 16; i++) { + out.append(Character.forDigit((bytes[i] >> 4) & 0xf, 16)); + out.append(Character.forDigit(bytes[i] & 0xf, 16)); + } + return out.toString(); + } catch (Exception noDigest) { + Log.w(TAG, "Could not digest the tile resource version; using the material itself", + noDigest); + return material; + } + } + + /** + * The resources for the version the host asked about. + * + *

The host asks for the version the LAYOUT it is showing advertised, and that layout may + * be one entry old: a timeline flip or a publish can land between the tile request and this + * one. Re-reading whatever is current then answered with a different version and a different + * resource map, leaving the displayed layout's image ids unresolved -- a blank where a glyph + * should be, on a Tile that had just been refreshed.

+ * + *

So the reading the last tile was built from is kept, and used when the host asks for its + * version. That memory is in this process, though, and neither of the two things that make + * the host ask for an old version respects a process: the service can be torn down between + * onTileRequest and onTileResourcesRequest, and the map is bounded so a busy timeline evicts. + * Both leave a request this instance has never seen.

+ * + *

The published descriptor outlives all of that, so a miss is answered by rebuilding from + * it: every entry is re-read -- including the ones already superseded, which is why this + * needs readAllEntries rather than readTimeline -- and the one whose version matches is the + * entry the host is showing. Falling back to the current entry instead would answer a + * DIFFERENT version, and then the displayed layout's image ids are either absent from the map + * or mapped to artwork rasterized against the wrong state; the first renders a blank and the + * second renders the wrong thing, and neither corrects itself until the next flip.

+ * + *

Only a publish that replaced the descriptor outright leaves nothing to find. The current + * entry is then genuinely all there is, and the publish has already requested a tile update, + * so the mismatch is about to be replaced rather than left on screen.

+ * + * @param requested the version the host is asking for, possibly null + * @return the resources, whose version is the one being served + */ + private ResourceBuilders.Resources buildResources(String requested) { + ResourceBuilders.Resources.Builder builder = new ResourceBuilders.Resources.Builder(); + String version = "0"; + try { + CN1WatchSurface.Reading remembered = recall(requested); + if (remembered == null) { + remembered = rebuild(requested); + } + CN1WatchSurface.Reading reading = remembered != null ? remembered + : CN1WatchSurface.read(this, getKindId(), "watchRectangular"); + if (reading != null) { + version = resourcesVersion(reading); + int spent = 0; + int dropped = 0; + for (Map.Entry e + : imageNodes(reading.getLayout()).entrySet()) { + Bitmap bitmap = CN1WatchSurface.bitmap(this, getKindId(), e.getValue(), + reading.getState()); + if (bitmap == null) { + continue; + } + // PNG bytes with IMAGE_FORMAT_UNDEFINED, which is the documented pairing and + // not an omission: the library says of the format that it "may be left + // unspecified or set to IMAGE_FORMAT_UNDEFINED in which case the platform + // will attempt to extract this from the raw image data", and of widthPx and + // heightPx that they are "only required for formats (e.g. + // IMAGE_FORMAT_RGB_565) where the image data does not include size". A PNG + // carries its own header, so it decodes; the named formats describe a raw + // pixel buffer, which this is not. They are supplied anyway because they cost + // nothing and a renderer that wants them has them. + ByteArrayOutputStream out = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + // The whole response has to fit one Binder transaction, and the renderer's + // budget cannot see that: it is reset for every bitmap() call, so each image + // is measured alone and a handful of individually acceptable ones still add + // up past the limit. Over it the host does not render a partial tile, it + // fails the request -- so dropping the images that do not fit is strictly + // better than sending all of them: a tile with a gap beats no tile. + if (spent + out.size() > RESOURCE_BUDGET_BYTES) { + dropped++; + continue; + } + spent += out.size(); + builder.addIdToImageMapping(e.getKey(), + new ResourceBuilders.ImageResource.Builder() + .setInlineResource( + new ResourceBuilders.InlineImageResource.Builder() + .setData(out.toByteArray()) + .setWidthPx(bitmap.getWidth()) + .setHeightPx(bitmap.getHeight()) + .setFormat(ResourceBuilders.IMAGE_FORMAT_UNDEFINED) + .build()) + .build()); + } + if (dropped > 0) { + Log.w(TAG, "Tile \"" + getKindId() + "\" sent " + spent + " byte(s) of " + + "imagery and dropped " + dropped + " image(s) that would not fit " + + "one Binder transaction. Publish smaller artwork, or fewer images, " + + "for the watchRectangular layout."); + } + } + } catch (Throwable t) { + Log.w(TAG, "Could not build Tile resources for kind " + getKindId(), t); + } + return builder.setVersion(version).build(); + } + + // --- node tree to ProtoLayout ------------------------------------------------ + + private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject state, + int depth, boolean inRow) { + if (node == null || depth > 8) { + return text(""); + } + String type = node.optString("t", ""); + if ("col".equals(type)) { + LayoutElementBuilders.Column.Builder col = new LayoutElementBuilders.Column.Builder(); + int spacing = node.optInt("spacing", 0); + boolean first = true; + for (JSONObject child : children(node)) { + if (!first && spacing > 0) { + col.addContent(gap(spacing, false)); + } + first = false; + col.addContent(weighted( + aligned(render(child, state, depth + 1, false), child, true, false), + child, false)); + } + return sized(col.setModifiers(modifiers(node)).build(), node); + } + if ("row".equals(type)) { + LayoutElementBuilders.Row.Builder row = new LayoutElementBuilders.Row.Builder(); + int spacing = node.optInt("spacing", 0); + boolean first = true; + for (JSONObject child : children(node)) { + if (!first && spacing > 0) { + row.addContent(gap(spacing, true)); + } + first = false; + row.addContent(weighted( + aligned(render(child, state, depth + 1, true), child, false, true), + child, true)); + } + return sized(row.setModifiers(modifiers(node)).build(), node); + } + if ("box".equals(type)) { + LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder(); + for (JSONObject child : children(node)) { + box.addContent(aligned(render(child, state, depth + 1, inRow), child, true, true)); + } + return sized(box.setModifiers(modifiers(node)).build(), node); + } + if ("spacer".equals(type)) { + // A spacer carries "min" and never "w"/"h" -- SurfaceSpacer.serializeContent writes + // the one key, and only when it is non-zero -- so reading w/h turned every declared + // spacer into the same 4dp stub and every flexible one along with it. + // + // The measurement belongs to the PARENT's axis, which is why the axis is threaded + // down: the same node is a width in a row and a height in a column. A spacer with no + // minimum is the flexible kind that absorbs what is left over, which is expand() + // rather than a few dips -- rendering it as 4dp collapsed exactly the push-apart + // layouts it exists for. The cross axis stays at 1dp: a spacer never has a size of + // its own there. + int min = node.optInt("min", 0); + DimensionBuilders.SpacerDimension along = min > 0 + ? DimensionBuilders.dp(min) : DimensionBuilders.expand(); + DimensionBuilders.SpacerDimension across = DimensionBuilders.dp(1); + // Wrapped so the node's shared modifiers apply. Padding, a background with its + // corner radius and an action all come from modifiers(node), and a Spacer has no + // setModifiers of its own -- so returning the bare element dropped them on Tiles + // alone, where the widget and SwiftUI renderers apply the same pass to a spacer. + // Through sized() like every other branch. A spacer inherits setSize from SurfaceNode + // and the descriptor carries the result as w/h, so an explicitly sized spacer was the + // one node whose declared size Tiles ignored -- min is the spacer's OWN length, not a + // replacement for the shared contract. + return sized(new LayoutElementBuilders.Box.Builder() + .addContent(new LayoutElementBuilders.Spacer.Builder() + .setWidth(inRow ? along : across) + .setHeight(inRow ? across : along) + .build()) + .setModifiers(modifiers(node)) + .build(), node); + } + if ("img".equals(type) || "vec".equals(type)) { + String name = imageId(node); + LayoutElementBuilders.Image.Builder image = new LayoutElementBuilders.Image.Builder() + .setResourceId(name) + .setModifiers(modifiers(node)); + // BOTH axes, always. setSize documents 0 as "natural" and the wire omits an axis left + // at it, but ProtoLayout has no natural size for an inline image to fall back on: the + // library says of the width that "if not defined, the image will not be rendered". + // Leaving an undeclared axis unset therefore does not preserve anything -- it makes + // the image disappear -- so an undeclared axis takes a default instead. + // + // That default is a real fidelity gap and not a preference: a Tile shows an unsized + // image at 24dp where the rasterizer would have used the bitmap's own size, and + // setSize(100, 0) becomes 100x24. There is no ProtoLayout expression for "as tall as + // the bitmap is", so the alternative is not a better size but no image at all. + image.setWidth(DimensionBuilders.dp(Math.max(1, node.optInt("w", 24)))); + image.setHeight(DimensionBuilders.dp(Math.max(1, node.optInt("h", 24)))); + // The declared scale mode. "fill" crops to the bounds and "center" keeps the natural + // size, which is what the rasterizer does with the same values -- left unset, every + // image took ProtoLayout's default and a fill image was fitted instead of cropped. + // FILL_BOUNDS is not the match for "fill": it stretches, while the other renderers + // preserve the aspect ratio and crop. + String scale = node.optString("scale", "fit"); + if ("fill".equals(scale)) { + image.setContentScaleMode(LayoutElementBuilders.CONTENT_SCALE_MODE_CROP); + } else { + // "center" has no ProtoLayout equivalent -- there is no "do not resize" mode -- + // so it takes FIT, which at least does not crop what a centred image was meant to + // show whole. Recorded rather than silently mapped: this is the one scale mode a + // Tile cannot reproduce. + image.setContentScaleMode(LayoutElementBuilders.CONTENT_SCALE_MODE_FIT); + } + // setTint was dropped entirely: the bitmap is produced by the port's decoder, which + // does not tint -- CN1SurfaceRenderer applies the tint at the ImageView instead -- + // so reusing only that decoder left every tinted glyph its original colour. + // ProtoLayout has the same separation and its own colour filter, so the tint travels + // with the element rather than being baked into the resource. + JSONObject tint = node.optJSONObject("tint"); + if (tint != null) { + image.setColorFilter(new LayoutElementBuilders.ColorFilter.Builder() + .setTint(ColorBuilders.argb(CN1SurfaceRenderer.resolveColor(tint, true, + 0xFFFFFFFF, 0xFFFFFFFF))) + .build()); + } + return image.build(); + } + if ("prog".equals(type)) { + float value = CN1WatchSurface.progressValue(node, state); + float fraction = value < 0 ? 0f : value; + // The style the app asked for, not an arc for everything. A ProtoLayout arc renders a + // ring natively -- the one place a Tile beats the phone widget, which has to degrade a + // circular bar to a linear one -- but reaching for it unconditionally turned every + // default SurfaceProgress into a ring, which is a different shape from the one the + // same descriptor draws in the simulator, in WidgetKit and in an Android widget. + // Wrapped in a Box so the node's own modifiers apply. Returning the Arc or the bar + // directly skipped modifiers(node), which is the only place a ProtoLayout Clickable is + // built -- so a progress node with setAction on it rendered and then ignored the tap, + // alone among the actionable node types, and lost its padding and background with it. + // Arc and Row have no setModifiers of their own to use instead. + // The node's own colour, resolved the same way text and backgrounds are. Neither + // branch read it, so an explicitly or semantically coloured progress changed colour + // on a Tile alone; the accent default matches what the RemoteViews path tints with. + JSONObject progColor = node.optJSONObject("color"); + int tint = progColor == null ? ACCENT + : CN1SurfaceRenderer.resolveColor(progColor, true, ACCENT, ACCENT); + LayoutElementBuilders.LayoutElement bar; + if (!"circular".equals(node.optString("style", "linear"))) { + bar = linearProgress(fraction, tint); + } else { + bar = new LayoutElementBuilders.Arc.Builder() + .addContent(new LayoutElementBuilders.ArcLine.Builder() + .setLength(DimensionBuilders.degrees(360f * fraction)) + .setThickness(DimensionBuilders.dp(6)) + .setColor(ColorBuilders.argb(tint)) + .build()) + .build(); + } + return sized(new LayoutElementBuilders.Box.Builder() + .addContent(bar) + .setModifiers(modifiers(node)) + .build(), node); + } + // text, dyn and anything unknown: whatever string the node resolves to. A dyn value is + // frozen here; see the class comment. + return sized(styledText(node, state), node); + } + + /// A node given the fixed size it declared, if it declared one. + /// + /// setSize serializes as "w"/"h" on ANY node, and only the image branch read them -- so text, + /// dynamic text, progress and containers came out naturally sized while the simulator and the + /// other renderers honoured the same descriptor. A built LayoutElement has no size to set + /// afterwards, so the sizing goes on a Box around it, exactly as the weight and alignment + /// wrappers do. + private LayoutElementBuilders.LayoutElement sized( + LayoutElementBuilders.LayoutElement element, JSONObject node) { + int w = node == null ? 0 : node.optInt("w", 0); + int h = node == null ? 0 : node.optInt("h", 0); + if (w <= 0 && h <= 0) { + return element; + } + LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder() + .addContent(element) + .setModifiers(clickOnly(node)); + if (w > 0) { + box.setWidth(DimensionBuilders.dp(w)); + } + if (h > 0) { + box.setHeight(DimensionBuilders.dp(h)); + } + return box.build(); + } + + /// A child placed where its own node asked to be placed. + /// + /// Used by every container, not only SurfaceBox. A column child declaring LEADING or TRAILING + /// and a row child declaring TOP or BOTTOM are asking about the CROSS axis, which a + /// ProtoLayout Column or Row does not take from the child either -- so those documented + /// positions rendered centred until this ran for them as well. + /// + /// + /// SurfaceNode.setAlignment serializes as "align" on the CHILD, and a ProtoLayout Box carries + /// the alignment of its contents rather than a child carrying its own -- so adding children + /// straight onto one shared Box gave every documented position the same default placement. + /// Each child therefore gets a Box of its own, which is also what lets siblings in the same + /// SurfaceBox sit in different corners. + /// + /// Centre is the default and is what a bare element already gets. + /// - `expandWidth`, `expandHeight`: which axes the wrapper may fill. A Box overlay fills + /// both; a column child fills only its width and a row child only its height, because the + /// other one is the container's main axis and expanding it would push every sibling out. + private LayoutElementBuilders.LayoutElement aligned( + LayoutElementBuilders.LayoutElement element, JSONObject child, + boolean expandWidth, boolean expandHeight) { + String align = child == null ? "" : child.optString("align", ""); + if (align.length() == 0 || "center".equals(align)) { + return element; + } + int horizontal = LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER; + if ("leading".equals(align) || "topLeading".equals(align) + || "bottomLeading".equals(align)) { + horizontal = LayoutElementBuilders.HORIZONTAL_ALIGN_START; + } else if ("trailing".equals(align) || "topTrailing".equals(align) + || "bottomTrailing".equals(align)) { + horizontal = LayoutElementBuilders.HORIZONTAL_ALIGN_END; + } + int vertical = LayoutElementBuilders.VERTICAL_ALIGN_CENTER; + if ("top".equals(align) || "topLeading".equals(align) || "topTrailing".equals(align)) { + vertical = LayoutElementBuilders.VERTICAL_ALIGN_TOP; + } else if ("bottom".equals(align) || "bottomLeading".equals(align) + || "bottomTrailing".equals(align)) { + vertical = LayoutElementBuilders.VERTICAL_ALIGN_BOTTOM; + } + LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder() + .addContent(element) + .setModifiers(clickOnly(child)) + .setHorizontalAlignment(horizontal) + .setVerticalAlignment(vertical); + if (expandWidth) { + box.setWidth(DimensionBuilders.expand()); + } + if (expandHeight) { + box.setHeight(DimensionBuilders.expand()); + } + return box.build(); + } + + /// The node's tap, on a wrapper box. + /// + /// modifiers(node) attaches the clickable to the element itself, and the sizing, weight and + /// alignment wrappers then put a LARGER box around it -- so a node combining setAction with + /// setSize had a tap target the size of its content rather than the bounds it asked for, and + /// taps in the remainder did nothing. Each wrapper carries the tap as well; a nested pair + /// with the same action id is harmless, and between them the whole declared area responds. + /// + /// Padding and background stay on the inner element, where they describe the content. + private ModifiersBuilders.Modifiers clickOnly(JSONObject node) { + ModifiersBuilders.Modifiers.Builder mods = new ModifiersBuilders.Modifiers.Builder(); + JSONObject action = node == null ? null : node.optJSONObject("action"); + if (action != null && action.optString("id", "").length() > 0) { + // Null when the action token could not be persisted; the node is then left + // unclickable rather than clickable-and-inert. See launchAction. + androidx.wear.protolayout.ActionBuilders.LaunchAction launch = launchAction(action); + if (launch != null) { + mods.setClickable(new ModifiersBuilders.Clickable.Builder() + .setId(action.optString("id")) + .setOnClick(launch) + .build()); + } + } + return mods.build(); + } + + /// A child sized to its share of the parent's leftover space, when it asked for one. + /// + /// SurfaceNode.setWeight serializes as "weight", and adding the rendered child straight onto + /// the container ignored it -- so two children weighted 1 and 2 came out naturally sized here + /// while every other renderer split the row between them. ProtoLayout expresses the share as + /// a dimension rather than a property of the child, and a built LayoutElement has no size to + /// set after the fact, so the child goes inside a Box that carries it. + /// + /// A weight of 0 is the default and means natural sizing, which is what the bare element + /// already does. + private LayoutElementBuilders.LayoutElement weighted( + LayoutElementBuilders.LayoutElement element, JSONObject child, boolean horizontal) { + int weight = child == null ? 0 : child.optInt("weight", 0); + if (weight <= 0) { + return element; + } + LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder() + .addContent(element) + .setModifiers(clickOnly(child)); + if (horizontal) { + box.setWidth(DimensionBuilders.weight(weight)); + } else { + box.setHeight(DimensionBuilders.weight(weight)); + } + return box.build(); + } + + /// The gap a row or column puts between adjacent children. + /// + /// SurfaceRow and SurfaceColumn serialize setSpacing(n) as "spacing", and adding the children + /// straight onto the builder packed them together -- the same descriptor spaced correctly + /// everywhere else. ProtoLayout containers have no spacing property, so the gap is an + /// explicit element, sized along the container's own axis. + private static LayoutElementBuilders.LayoutElement gap(int dips, boolean horizontal) { + DimensionBuilders.SpacerDimension along = DimensionBuilders.dp(dips); + DimensionBuilders.SpacerDimension across = DimensionBuilders.dp(1); + return new LayoutElementBuilders.Spacer.Builder() + .setWidth(horizontal ? along : across) + .setHeight(horizontal ? across : along) + .build(); + } + + /// A linear progress bar, built from two boxes because ProtoLayout has no bar element: a + /// track that fills the width and a filled portion weighted to the fraction. Degrading a + /// linear bar into a ring would be the reverse of the phone widget's own compromise and + /// would not look like the surface the app described. + /// + /// The track is the fill's own colour at a quarter alpha -- its RGB kept and its alpha + /// replaced, so a coloured bar reads as one bar rather than as two unrelated ones. + private static LayoutElementBuilders.LayoutElement linearProgress(float fraction, + int tint) { + float filled = Math.max(0f, Math.min(1f, fraction)); + LayoutElementBuilders.Row.Builder bar = new LayoutElementBuilders.Row.Builder(); + if (filled > 0f) { + bar.addContent(new LayoutElementBuilders.Box.Builder() + .setWidth(DimensionBuilders.weight(filled)) + .setHeight(DimensionBuilders.dp(6)) + .setModifiers(new ModifiersBuilders.Modifiers.Builder() + .setBackground(new ModifiersBuilders.Background.Builder() + .setColor(ColorBuilders.argb(tint)) + .build()) + .build()) + .build()); + } + if (filled < 1f) { + bar.addContent(new LayoutElementBuilders.Box.Builder() + .setWidth(DimensionBuilders.weight(1f - filled)) + .setHeight(DimensionBuilders.dp(6)) + .setModifiers(new ModifiersBuilders.Modifiers.Builder() + .setBackground(new ModifiersBuilders.Background.Builder() + .setColor(ColorBuilders.argb((tint & 0x00FFFFFF) | 0x40000000)) + .build()) + .build()) + .build()); + } + return bar.build(); + } + + private LayoutElementBuilders.LayoutElement styledText(JSONObject node, JSONObject state) { + // A dynamic node serializes a style plus a date or dateKey and carries no "text" at all, + // so it has to be formatted rather than interpolated -- otherwise every countdown, clock + // and relative date rendered blank. Frozen at render; see the class comment. + String value = "dyn".equals(node.optString("t", "")) + ? CN1WatchSurface.dynamicText(node, state) + : CN1SurfaceRenderer.interpolate(node.optString("text", ""), state); + LayoutElementBuilders.FontStyle.Builder font = new LayoutElementBuilders.FontStyle.Builder(); + int size = node.optInt("size", 0); + if (size > 0) { + font.setSize(DimensionBuilders.sp(size)); + } + String weight = node.optString("fw", ""); + if ("semibold".equals(weight) || "bold".equals(weight) || "black".equals(weight)) { + // The same collapse to regular/bold the RemoteViews path documents. + font.setWeight(LayoutElementBuilders.FONT_WEIGHT_BOLD); + } + JSONObject color = node.optJSONObject("color"); + if (color != null) { + // Through the renderer's own resolution, not a "d" lookup. A semantic colour -- + // ACCENT, SECONDARY_LABEL and the rest -- serializes as {"role": ...} with no light + // or dark value at all, so testing for "d" discarded every one of them and left the + // ProtoLayout default. Always the dark appearance: a watch face composites over + // black and has no light one, which is the same answer CN1SurfaceModel gives on the + // Apple side. + font.setColor(ColorBuilders.argb(CN1SurfaceRenderer.resolveColor(color, true, + 0xFFFFFFFF, 0xFFFFFFFF))); + } + LayoutElementBuilders.Text.Builder text = new LayoutElementBuilders.Text.Builder() + .setText(value == null ? "" : value) + .setFontStyle(font.build()); + int maxLines = node.optInt("maxLines", 0); + if (maxLines > 0) { + text.setMaxLines(maxLines); + } + return text.setModifiers(modifiers(node)).build(); + } + + private ModifiersBuilders.Modifiers modifiers(JSONObject node) { + ModifiersBuilders.Modifiers.Builder mods = new ModifiersBuilders.Modifiers.Builder(); + // SurfaceNode serializes padding as the array [top, right, bottom, left], which is what + // the RemoteViews renderer reads too. Asking for an object returned null for every valid + // descriptor, so all declared padding was silently dropped from a Tile. + JSONArray pad = node.optJSONArray("pad"); + if (pad != null && pad.length() == 4) { + mods.setPadding(new ModifiersBuilders.Padding.Builder() + .setTop(DimensionBuilders.dp(pad.optInt(0))) + .setEnd(DimensionBuilders.dp(pad.optInt(1))) + .setBottom(DimensionBuilders.dp(pad.optInt(2))) + .setStart(DimensionBuilders.dp(pad.optInt(3))) + .build()); + } + JSONObject bg = node.optJSONObject("bg"); + if (bg != null) { + // Resolved, not read out of "d". A semantic background -- BACKGROUND, ACCENT and the + // rest -- serializes as {"role": ...} with no light or dark value, so testing for "d" + // dropped the background AND the corner radius that only exists inside it. Same + // resolution the text path uses, and the same dark appearance: a watch face + // composites over black. + ModifiersBuilders.Background.Builder background = + new ModifiersBuilders.Background.Builder() + .setColor(ColorBuilders.argb(CN1SurfaceRenderer.resolveColor(bg, true, + 0x00000000, 0x00000000))); + int corner = node.optInt("corner", 0); + if (corner > 0) { + background.setCorner(new ModifiersBuilders.Corner.Builder() + .setRadius(DimensionBuilders.dp(corner)) + .build()); + } + mods.setBackground(background.build()); + } + // Per-node tap actions work on a Tile, which a small iOS widget cannot do. + JSONObject action = node.optJSONObject("action"); + if (action != null && action.optString("id", "").length() > 0) { + // Null when the action token could not be persisted; the node is then left + // unclickable rather than clickable-and-inert. See launchAction. + androidx.wear.protolayout.ActionBuilders.LaunchAction launch = launchAction(action); + if (launch != null) { + mods.setClickable(new ModifiersBuilders.Clickable.Builder() + .setId(action.optString("id")) + .setOnClick(launch) + .build()); + } + } + return mods.build(); + } + + /** + * The launch action for a tapped node, carrying what the trampoline needs to dispatch. + * + *

A Clickable's id is ProtoLayout interaction metadata and never reaches the started + * activity, so an action built from it alone opened the app and dropped the action id, + * source and parameters on the floor. {@code CN1SurfaceActionActivity} dispatches only when + * {@code EXTRA_ACTION_ID} is present, so the extras are attached explicitly here -- the same + * three a widget tap sends.

+ */ + private androidx.wear.protolayout.ActionBuilders.LaunchAction launchAction(JSONObject action) { + // The trampoline is exported so the tile host can start it, which means any app on the + // watch can too. The token says the tap came from a surface THIS app drew; see + // CN1SurfaceActionActivity.token. The layout carrying it goes to the tile host and + // nowhere else. + // + // Null when it could not be persisted, and then there is no action to build: the + // trampoline would reject the tap anyway, and a Clickable that starts an activity which + // immediately finishes is worse than a node that was never clickable -- it looks broken + // rather than inert. + String token = CN1SurfaceActionActivity.token(this); + if (token == null) { + return null; + } + androidx.wear.protolayout.ActionBuilders.AndroidActivity.Builder activity = + new androidx.wear.protolayout.ActionBuilders.AndroidActivity.Builder() + .setPackageName(getPackageName()) + .setClassName(CN1SurfaceActionActivity.class.getName()); + activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_TOKEN, + stringExtra(token)); + activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_SOURCE, + stringExtra(getKindId())); + activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_ACTION_ID, + stringExtra(action.optString("id", ""))); + JSONObject params = action.optJSONObject("p"); + if (params != null) { + activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_ACTION_PARAMS, + stringExtra(params.toString())); + } + return new androidx.wear.protolayout.ActionBuilders.LaunchAction.Builder() + .setAndroidActivity(activity.build()) + .build(); + } + + private static androidx.wear.protolayout.ActionBuilders.AndroidStringExtra stringExtra( + String value) { + return new androidx.wear.protolayout.ActionBuilders.AndroidStringExtra.Builder() + .setValue(value == null ? "" : value) + .build(); + } + + private static LayoutElementBuilders.LayoutElement text(String value) { + return new LayoutElementBuilders.Text.Builder().setText(value).build(); + } + + private static List children(JSONObject node) { + List out = new ArrayList(); + // "ch", which is what SurfaceContainer.serializeContent writes. Reading "c" found + // nothing, so every row, column and box rendered empty. + JSONArray array = node.optJSONArray("ch"); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + JSONObject child = array.optJSONObject(i); + if (child != null) { + out.add(child); + } + } + } + return out; + } + + /** + * The resource id an image node maps to, derived from its CONTENT. + * + *

An {@code img} node already names a content hash, which is what lets the resources + * version be a hash of the name list: unchanged art is never re-sent. A {@code vec} node + * carries no name at all, so its serialized form is hashed instead.

+ * + *

Content rather than object identity, because the layout request and the resources + * request are two separate calls that each re-read and re-parse the timeline. An id taken + * from a JSONObject's identity therefore differed between them: the layout referenced a + * resource the returned map did not contain, and every vector rendered as a missing image. + * Two identical vectors sharing one resource is correct -- they draw the same thing.

+ */ + private static String imageId(JSONObject node) { + String name = node.optString("name", ""); + if (name.length() > 0) { + // The RENDERING, not just the registered name. CN1WatchSurface.bitmap pre-scales and + // may crop to the node's own size and scale mode, so two nodes sharing one registered + // image but sized differently need two resources -- with one id, imageNodes kept only + // the last node and both drew that one's bitmap. The size and mode are what change + // the bytes, so they are what the id has to include. + return name + "_" + node.optInt("w", 0) + "x" + node.optInt("h", 0) + + "_" + node.optString("scale", "fit"); + } + // Digested, not hashCode: the id IS the identity of a rendered vector, and imageNodes + // keys its map by it -- so two distinct nodes colliding on a 32-bit hash lose one + // mapping and both elements draw the second one's artwork. The node's JSON is + // user-controlled text, where a collision is something to construct rather than wait for. + return ROOT_ID + "_vec" + digest(node.toString()); + } + + private static Map imageNodes(JSONObject root) { + Map out = new LinkedHashMap(); + for (JSONObject node : CN1WatchSurface.flatten(root)) { + String type = node.optString("t", ""); + if ("img".equals(type) || "vec".equals(type)) { + out.put(imageId(node), node); + } + } + return out; + } + + private static List imageNames(JSONObject root) { + return new ArrayList(imageNodes(root).keySet()); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java index 3448b675118..048c7ad6674 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java @@ -85,6 +85,20 @@ public class CN1WearableBridge implements WearableBridge { static final String CAPABILITY_NAME = "cn1_wearable"; /** The key the payload bytes live under inside a DataItem. */ private static final String PAYLOAD_KEY = "cn1.payload"; + + /** + * The DataMap key a replicated value's payload lives under. + * + *

Exposed for the listener service's surface-mirror branch, which reads a mirrored + * complication descriptor straight from the map: a mirror is a replacement rather than a + * replicated value with a logical clock, so it does not go through the ordering machinery + * that owns this constant everywhere else.

+ * + * @return the payload key + */ + static String payloadKey() { + return PAYLOAD_KEY; + } /** The publication order of a value or transfer, so the newer of two items wins. */ private static final String SEQUENCE_KEY = "cn1.seq"; /** @@ -3505,6 +3519,70 @@ private static boolean claimRetryChain(Uri uri) { private static final java.util.concurrent.ScheduledThreadPoolExecutor transferTimer = newTransferWorker(); + /** + * Runs a piece of framework bookkeeping later, on the transfer worker. + * + *

Exposed for the surface mirror's write retry, which is the same shape as the transfer + * retries this worker already carries: a Data Layer item that does not change after a failed + * apply, so nothing offers it again and something here has to ask. Ordering with the rest of + * the transfer bookkeeping is preserved because it is the same single thread.

+ * + * @param task the work + * @param delayMillis how long to wait + */ + /// The reserved path a watch asks the phone to republish a mirrored surface on. + /// + /// Framework traffic, so it is routed before anything app-visible and never reaches a + /// WearableMessageListener -- the same treatment /cn1surface descriptors get. + static String surfaceReloadPath() { + return "/cn1surfacereload/"; + } + + /** + * Asks the phone to publish a mirrored kind again. + * + *

A mirrored surface is produced on the phone, so the watch cannot refresh it by asking + * itself -- it has no content and no background-fetch listener of its own. This sends the ask + * back up the link the descriptor came down.

+ * + *

Best effort by contract: no watch, no phone half, or no reachable node is the ordinary + * case for most installs, and a complication keeping what it already has is the right + * outcome there.

+ * + * @param context any context + * @param kindId the kind wanting fresh content + */ + public static void requestSurfaceReload(Context context, String kindId) { + if (context == null || kindId == null || kindId.length() == 0) { + return; + } + try { + // The same live-or-build pattern sweepAfterAcknowledgement uses: this runs in a + // service process the system may have started cold, where no bridge exists yet. + CN1WearableBridge live = current; + if (live == null) { + live = new CN1WearableBridge(context); + } + String wire = surfaceReloadPath() + kindId; + for (Node n : Tasks.await( + Wearable.getNodeClient(context.getApplicationContext()).getConnectedNodes(), + TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + live.messageClient.sendMessage(n.getId(), wire, new byte[0]); + } + } catch (Throwable t) { + android.util.Log.w("CN1Surfaces", "could not ask the phone to republish " + kindId, t); + } + } + + static void scheduleFrameworkRetry(Runnable task, long delayMillis) { + try { + transferTimer.schedule(task, delayMillis, TimeUnit.MILLISECONDS); + } catch (Throwable rejected) { + // The worker is shutting down with the process. Nothing to retry into. + android.util.Log.w("CN1Wearable", "could not schedule a framework retry", rejected); + } + } + private static java.util.concurrent.ScheduledThreadPoolExecutor newTransferWorker() { java.util.concurrent.ScheduledThreadPoolExecutor worker = new java.util.concurrent.ScheduledThreadPoolExecutor(1, diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java index ec554a6a460..9353b048208 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java @@ -26,6 +26,7 @@ import com.google.android.gms.wearable.DataEvent; import com.google.android.gms.wearable.DataEventBuffer; +import com.google.android.gms.wearable.DataMapItem; import com.google.android.gms.wearable.MessageEvent; import com.google.android.gms.wearable.WearableListenerService; @@ -152,7 +153,11 @@ private void ensureAppRunning() { @Override public void onMessageReceived(final MessageEvent event) { // Same reasoning as onDataChanged: isFromAKnownNode can block on a cold start. - final MessageEvent frozen = event.freeze(); + // Copied field by field rather than frozen: DataEvent is Freezable and MessageEvent is + // not -- it is a plain four-method interface -- so there is no freeze() to call here. The + // copy is what makes the hand-off safe, because Play services may recycle the event once + // onMessageReceived returns and the worker below reads it after that. + final MessageEvent frozen = new FrozenMessageEvent(event); MESSAGE_WORKER.execute(new Runnable() { public void run() { handleMessageReceived(frozen); @@ -170,6 +175,18 @@ private void handleMessageReceived(MessageEvent event) { // node can still send something this process cannot act on -- a reply whose requester died, // a malformed request path, a path from a different build -- and launching first brought // the UI forward for a message that is discarded a few lines later. + if (path.startsWith(CN1WearableBridge.surfaceReloadPath())) { + // A watch asking the phone to publish a mirrored kind again. Framework traffic, so it + // is answered here and never delivered to the app's own listeners -- the same + // position and reasoning the /cn1surface descriptors get on the way down. + // + // The watch cannot refresh a mirrored surface itself: the content is produced here, + // and it has no background-fetch listener recorded because that preference is written + // by the publish path a watch never runs. + surfaceMirror("reloadRequested", + path.substring(CN1WearableBridge.surfaceReloadPath().length()), null); + return; + } if (path.startsWith(CN1WearableBridge.replyUnavailablePath())) { // The peer says it cannot answer -- it is installed and reachable, but has no listener // and no permitted way to get one (see CN1WearableBridge.declineRequest). Failing the @@ -303,6 +320,271 @@ public void run() { }); } + /** + * An immutable copy of a delivered {@link MessageEvent}. + * + *

{@link com.google.android.gms.wearable.DataEvent} extends {@code Freezable} and hands out + * a detached copy through {@code freeze()}; {@code MessageEvent} does not, so a hand-off to a + * worker thread has to copy the four accessors by hand. Play services documents the delivered + * event as valid only for the duration of the callback, and every read below happens after it + * has returned.

+ * + *

Implementing the interface, rather than passing the fields separately, keeps + * {@code handleMessageReceived} typed against {@code MessageEvent}. If Play services ever adds + * a fifth method this stops compiling, which is the intended way to find out.

+ */ + private static final class FrozenMessageEvent implements MessageEvent { + private final int requestId; + private final String path; + private final String sourceNodeId; + private final byte[] data; + + FrozenMessageEvent(MessageEvent event) { + requestId = event.getRequestId(); + path = event.getPath(); + sourceNodeId = event.getSourceNodeId(); + byte[] payload = event.getData(); + data = payload == null ? null : (byte[]) payload.clone(); + } + + @Override + public int getRequestId() { + return requestId; + } + + @Override + public String getPath() { + return path; + } + + @Override + public String getSourceNodeId() { + return sourceNodeId; + } + + @Override + public byte[] getData() { + return data; + } + } + + /** The port's surface mirror, or null on a port that predates it. See {@link #surfaceMirror}. */ + private static Class mirrorClass; + private static boolean mirrorLookedUp; + + /** + * The mirror class, looked up once, or null when this port has no surfaces implementation. + * + *

Reflective on purpose. This service is injected into EVERY build that references + * {@code com.codename1.wearable}, including a versioned build pinned to a Codename One + * release older than external surfaces -- and a hard reference to a class that port does not + * contain fails javac in a file the developer never wrote, for a feature they never enabled. + * The same reason {@code CN1WatchSurfaceNotifier} reaches for the androidx complication + * classes this way.

+ * + *

Safe against R8 because the two conditions coincide: the builder emits + * {@code -keep class com.codename1.impl.android.surfaces.**} exactly when the app uses + * surfaces, which is exactly when this class is present to be found. A rename would otherwise + * turn this into the failure the reflection ban exists for -- working in the simulator and + * silently dead in a release build.

+ * + * @return the mirror class, or null + */ + private static synchronized Class mirrorClass() { + if (!mirrorLookedUp) { + mirrorLookedUp = true; + try { + mirrorClass = Class.forName( + "com.codename1.impl.android.surfaces.CN1SurfaceMirror"); + } catch (Throwable t) { + // A port without surfaces. Mirror traffic cannot arrive for it either, because + // nothing on the phone half would have sent any. + mirrorClass = null; + } + } + return mirrorClass; + } + + /** Whether a path belongs to the surface mirror rather than to the application. */ + private static boolean surfaceMirrorHandles(String path) { + Class mirror = mirrorClass(); + if (mirror == null || path == null) { + return false; + } + try { + return Boolean.TRUE.equals(mirror.getMethod("isMirrorPath", String.class) + .invoke(null, path)); + } catch (Throwable t) { + return false; + } + } + + /** + * Hands one piece of mirror traffic to the port. + * + * @param method {@code receive}, {@code receiveFile} or {@code remove} + * @param path the reserved application path + * @param payload the payload, or null for {@code remove} + * @return true when the mirror took it; false when there is no mirror or it threw. A caller + * that acknowledges delivery has to know the difference, because an acknowledgement + * is durable and stops the sender retrying + */ + /// How many times a failed mirrored descriptor write is re-attempted, and the delay before + /// the first. The delays double, so the last attempt is a little over twenty minutes out -- + /// long enough to outlast the transient conditions this is for (storage momentarily full, a + /// directory briefly unwritable) without holding the payload for ever. + private static final int MIRROR_WRITE_RETRIES = 6; + private static final long MIRROR_WRITE_RETRY_MILLIS = 20000L; + + /// How many mirror events each reserved path has seen, so a delayed retry can tell whether it + /// has been overtaken. Small and bounded by the number of declared kinds. + private static final java.util.HashMap MIRROR_GENERATIONS = + new java.util.HashMap(); + + private static synchronized long bumpMirrorGeneration(String path) { + Long current = MIRROR_GENERATIONS.get(path); + long next = (current == null ? 0L : current.longValue()) + 1L; + MIRROR_GENERATIONS.put(path, Long.valueOf(next)); + return next; + } + + private static synchronized long mirrorGeneration(String path) { + Long current = MIRROR_GENERATIONS.get(path); + return current == null ? 0L : current.longValue(); + } + + /** + * Re-attempts a mirrored descriptor the watch could not store. + * + *

Bounded, and in memory. What it cannot cover is the process dying mid-outage: the + * payload goes with it and the unchanged Data Layer item produces no fresh callback, so that + * descriptor waits for the phone's next publish. Persisting it to survive that would mean + * writing to the storage that just refused a write, which is the condition being retried.

+ * + * @param path the reserved application path + * @param payload the descriptor payload, held for the retry + * @param attempt 1 for the first re-attempt + */ + /** + * Re-attempts a mirrored withdrawal the watch could not carry out. + * + *

The mirror image of {@link #retryMirrorDescriptor}, and needed for a sharper reason: a + * descriptor that fails to apply is at least offered again by the next publish, while a + * deletion is offered once and never again.

+ * + * @param path the reserved application path + * @param attempt 1 for the first re-attempt + * @param generation the mirror generation this withdrawal belongs to + */ + private void retryMirrorRemoval(final String path, final int attempt, final long generation) { + if (mirrorGeneration(path) != generation) { + // Overtaken by a republish, which supersedes the withdrawal outright. + return; + } + if (attempt > MIRROR_WRITE_RETRIES) { + android.util.Log.w("CN1Surfaces", "gave up withdrawing the mirrored surface on " + + path + " after " + MIRROR_WRITE_RETRIES + " attempts; the watch keeps " + + "showing it until the phone publishes that kind again"); + return; + } + final CN1WearableListenerService self = this; + CN1WearableBridge.scheduleFrameworkRetry(new Runnable() { + public void run() { + if (mirrorGeneration(path) != generation) { + return; + } + if (!self.surfaceMirror("remove", path, null)) { + self.retryMirrorRemoval(path, attempt + 1, generation); + } + } + }, MIRROR_WRITE_RETRY_MILLIS << (attempt - 1)); + } + + private void retryMirrorDescriptor(final String path, final byte[] payload, + final int attempt, final long generation) { + if (mirrorGeneration(path) != generation) { + // Overtaken. A newer descriptor for this path -- or its tombstone -- has been handled + // since this retry was scheduled, so applying the payload now would either overwrite + // content that is newer than it or resurrect a surface the phone has withdrawn. The + // newer event has its own retry if it needs one. + return; + } + if (payload == null || attempt > MIRROR_WRITE_RETRIES) { + android.util.Log.w("CN1Surfaces", "gave up applying the mirrored surface on " + path + + " after " + MIRROR_WRITE_RETRIES + " attempts; the watch keeps what it had " + + "until the phone publishes again"); + return; + } + final CN1WearableListenerService self = this; + CN1WearableBridge.scheduleFrameworkRetry(new Runnable() { + public void run() { + if (mirrorGeneration(path) != generation) { + // Checked again here, not only on entry: the overtaking event usually lands + // while this task is sitting on the timer, which is the whole window. + return; + } + if (!self.surfaceMirror("receive", path, payload)) { + self.retryMirrorDescriptor(path, payload, attempt + 1, generation); + } + } + }, MIRROR_WRITE_RETRY_MILLIS << (attempt - 1)); + } + + private boolean surfaceMirror(String method, String path, byte[] payload) { + Class mirror = mirrorClass(); + if (mirror == null) { + return false; + } + try { + // A null payload means the two-argument form, whichever method it is: remove and + // reloadRequested both take (Context, String) and neither carries bytes. + if (payload == null) { + Object removed = mirror + .getMethod(method, android.content.Context.class, String.class) + .invoke(null, this, path); + // Its own answer. remove used to be void and this returned true regardless, so a + // withdrawal the watch could not carry out looked like one that had -- and a + // deletion is offered exactly once, so nothing would have tried again. A port + // still declaring the void form answers null here, which is treated as success + // exactly as it was before: it is the same old behaviour for the same old port. + return !(removed instanceof Boolean) || ((Boolean) removed).booleanValue(); + } + Object answer = mirror + .getMethod(method, android.content.Context.class, String.class, byte[].class) + .invoke(null, this, path, payload); + // The mirror's own answer where it has one. receiveFile catches its write failures + // internally and reports them by returning false, so a call that merely did not throw + // proves nothing -- and an acknowledgement made on that basis is durable, which loses + // the artwork rather than having it redelivered. A void method (receive) answers null + // and is taken at its word. + return !(answer instanceof Boolean) || ((Boolean) answer).booleanValue(); + } catch (Throwable t) { + // Caught rather than propagated: a listener that throws takes the Data Layer + // callback down with it, and mirror traffic is a refresh rather than something the + // app is waiting on. Logged under the mirror's own tag so it reads beside the + // failures the mirror reports itself. + android.util.Log.w("CN1Surfaces", + "Could not hand " + path + " to the surface mirror", t); + return false; + } + } + + /** + * The payload of a mirrored surface item. + * + *

Read straight from the DataMap rather than through the bridge's ordering machinery: a + * mirror is a replacement, not a replicated value with a logical clock, and the newest write + * always wins.

+ */ + private static byte[] readMirrorPayload(DataEvent event) { + try { + return DataMapItem.fromDataItem(event.getDataItem()).getDataMap() + .getByteArray(CN1WearableBridge.payloadKey()); + } catch (Throwable t) { + return null; + } + } + private void handleDataChanged(java.lang.Iterable events, long removalGeneration, java.util.Set openRemovals) { // Before anything is read: in a cold service process this is the only context there is, and @@ -374,6 +656,50 @@ && dataItemExists(uri)) { ? null : CN1WearableBridge.decode( path.substring(CN1WearableBridge.pathPrefix().length())); + // Framework bookkeeping, routed BEFORE anything app-visible and before + // ensureAppRunning -- the same position and the same reasoning the acknowledgement + // traffic above uses. A mirrored complication is applied by writing a file and asking + // the watch face to re-read; there is nothing for the application to do, and starting + // it would bring a UI forward that the user did not ask for. Delivering it to the + // app's own listeners would also show it a message it never sent itself. + if (appPath != null + && surfaceMirrorHandles(appPath)) { + // Deletions too, and NOT through the ordinary value-removal path below. A mirror + // is a replacement rather than a replicated value, so it never entered that + // path's cache or its logical clock, and letting a tombstone go there left the + // descriptor CN1SurfaceMirror.receive wrote sitting on disk -- the complication + // kept showing content the phone had already withdrawn. + // Every mirror event for this path moves its generation on, which is what lets a + // scheduled retry tell that it has been overtaken. Bumped BEFORE the work, so a + // retry scheduled by this very event carries the current number. + long generation = bumpMirrorGeneration(appPath); + if (deleted) { + // The tombstone is consumed either way, and deliberately. A Data Layer + // deletion is not redelivered -- unlike a changed item there is nothing left + // to ask for -- so there is no later attempt to preserve it for. + // + // Which is exactly why a FAILED removal has to be retried here rather than + // dropped: nothing else will ever offer this deletion again, so a directory + // that is momentarily unwritable would leave the complication showing content + // the phone withdrew, permanently. Same generation guard as a descriptor + // retry, so a republish landing meanwhile cancels the withdrawal instead of + // racing it. + if (!surfaceMirror("remove", appPath, null)) { + retryMirrorRemoval(appPath, 1, generation); + } + } else { + byte[] descriptor = readMirrorPayload(event); + if (!surfaceMirror("receive", appPath, descriptor)) { + // The write failed -- storage momentarily full is the case this is for. + // Nothing else will offer this descriptor again: a Data Layer item that + // has not changed produces no further callback, so the watch would keep + // showing content the phone has already replaced, indefinitely. The + // payload is in hand, so the retry needs no round trip. + retryMirrorDescriptor(appPath, descriptor, 1, generation); + } + } + continue; + } // Read before anything is cleared, so the reset below can tell this device's own // removal from a republish that landed while it was being processed. String beforeTombstone = !transferItem && deleted @@ -442,6 +768,30 @@ && dataItemExists(uri)) { // Confirmed from inside the delivery: dispatched is not delivered, and a // claim persisted for a file the app never received suppresses the redelivery // that would have replaced it. + if (surfaceMirrorHandles(transfer.logicalPath)) { + // Mirrored complication artwork. Stored beside the descriptor that names + // it, without waking the app: see the data-item branch above. + // Confirmed only if it was actually stored. The helper catches whatever + // the mirror throws -- a directory that is momentarily unwritable, say -- + // and a claim made anyway is durable: the sender stops retrying and the + // artwork is gone for good. + if (surfaceMirror("receiveFile", transfer.logicalPath, transfer.payload)) { + CN1WearableBridge.confirmTransferDelivered(this, uri, transferSeq, + true); + } else { + // RELINQUISHED, not merely left unconfirmed. Passing false to + // confirmTransferDelivered returns without touching the in-memory + // claim claimTransfer already made, and that claim then suppresses + // every retry -- while the DataItem is unchanged, so nothing + // generates a fresh callback either. The artwork would be missing + // until the process restarted. relinquishTransfer drops the claim + // AND goes back to read the item, which is the same thing the + // tracked-delivery path below does when the listener never got the + // payload. + CN1WearableBridge.relinquishTransfer(this, uri); + } + continue; + } if (!started) { ensureAppRunning(); started = true; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java new file mode 100644 index 00000000000..12cae02c8ec --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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 decisions the Wear complication codegen makes, as pure functions so they can be pinned +/// without generating a project. +class AndroidWatchSurfaceCodegenTest { + + private static BuildRequest request() { + BuildRequest req = new BuildRequest(); + req.setMainClass("MyApp"); + req.setPackageName("com.mycompany.myapp"); + req.setDisplayName("My App"); + req.setVersion("1.0"); + return req; + } + + // --- which module is the watch product ------------------------------------ + + /// A project that has not asked for a watch must be left completely alone, which is what + /// every branch downstream keys on. + @Test + void noWatchMainMeansNoWatchModule() { + assertNull(AndroidGradleBuilder.watchModuleName(request())); + } + + /// Standalone: the single APK IS the watch app, so the services go in the phone module's + /// place -- there is no phone app to keep them out of. + @Test + void standaloneMakesTheAppModuleTheWatchProduct() { + BuildRequest req = request(); + req.putArgument("watchMain", "com.mycompany.myapp.Watch"); + req.putArgument("watchStandalone", "true"); + + assertEquals("app", AndroidGradleBuilder.watchModuleName(req)); + } + + /// Companion: a second module beside the phone one. + @Test + void companionGeneratesASeparateWearModule() { + BuildRequest req = request(); + req.putArgument("watchMain", "com.mycompany.myapp.Watch"); + + assertEquals("wear", AndroidGradleBuilder.watchModuleName(req)); + } + + /// A project that wants the wearable link but no watch app of its own can say so, and its + /// phone build is then exactly what it was. + @Test + void theWearModuleCanBeDeclined() { + BuildRequest req = request(); + req.putArgument("watchMain", "com.mycompany.myapp.Watch"); + req.putArgument("android.watchModule", "false"); + + assertNull(AndroidGradleBuilder.watchModuleName(req)); + } + + // --- where the wear libraries land ------------------------------------------ + + /// The androidx.wear complication and Tile libraries declare minSdk 26, and only the WATCH + /// module is raised to it. In a companion build the phone module keeps its own floor, so a + /// shared dependency list makes its manifest merge fail against libraries it never uses -- + /// which is exactly what happened: a phone app on API 24 stopped building the moment a watch + /// family was declared. + @Test + void theWearLibrariesNeverReachAPhoneModule() { + BuildRequest companion = request(); + companion.putArgument("watchMain", "com.mycompany.myapp.Watch"); + + // The companion phone module is not the watch product, so nothing wear-specific may be + // added to the dependency hint it shares. + assertEquals("wear", AndroidGradleBuilder.watchModuleName(companion)); + assertEquals("", companion.getArg("gradleDependencies", ""), + "a companion phone module must carry no androidx.wear dependency"); + } + + /// A standalone build is the other way round: that single module IS the watch, so the + /// libraries and the 26 floor both belong to it. + @Test + void aStandaloneModuleIsTheWatchProductAndTakesBoth() { + BuildRequest standalone = request(); + standalone.putArgument("watchMain", "com.mycompany.myapp.Watch"); + standalone.putArgument("watchStandalone", "true"); + + assertEquals("app", AndroidGradleBuilder.watchModuleName(standalone)); + } + + // --- where the generated services land -------------------------------------- + + /// The wear module shares the phone's source directory, so a service written to the phone's + /// root is compiled by BOTH -- and these import androidx.wear, whose dependencies belong to + /// the wear module alone. A companion build therefore failed compiling the phone module + /// against imports it has no libraries for. + @Test + void aCompanionBuildGeneratesTheServicesInTheWearModule() { + File appSrc = new File("/tmp/proj/app/src/main/java"); + + File watchSrc = AndroidGradleBuilder.watchSourceRoot("wear", appSrc); + + assertEquals(new File("/tmp/proj/wear/src/main/java"), watchSrc); + } + + /// A standalone build has one module, which IS the watch, so they belong where they are. + @Test + void aStandaloneBuildGeneratesThemInPlace() { + File appSrc = new File("/tmp/proj/app/src/main/java"); + + assertEquals(appSrc, AndroidGradleBuilder.watchSourceRoot("app", appSrc)); + } + + // --- which services get copied ---------------------------------------------- + + /// Gradle compiles every source in the tree whether or not a generated subclass names it, + /// and the tiles/protolayout dependencies are added only for a rectangular family -- so a + /// complication-only build that copied the Tile service failed on unresolved imports. + @Test + void aComplicationOnlyBuildDoesNotCopyTheTileService() { + List kinds = new ArrayList(); + kinds.add(new String[] {"a", "A", "watchCircular,watchInline,watchCorner"}); + + List sources = AndroidGradleBuilder.watchSurfaceSources(kinds); + + assertEquals(1, sources.size(), sources.toString()); + assertEquals("CN1ComplicationDataSource.java", sources.get(0)); + } + + @Test + void aRectangularFamilyBringsTheTileServiceWithIt() { + List kinds = new ArrayList(); + kinds.add(new String[] {"a", "A", "watchCircular"}); + kinds.add(new String[] {"b", "B", "watchRectangular"}); + + List sources = AndroidGradleBuilder.watchSurfaceSources(kinds); + + assertEquals(2, sources.size(), sources.toString()); + assertTrue(sources.contains("CN1SurfaceTileService.java"), sources.toString()); + } + + // --- family to ComplicationData mapping ------------------------------------ + + /// A watch face asks a data source for ONE type and gets nothing if the source does not + /// offer it, so this list is what decides whether a complication can be placed at all. + @Test + void circularOffersAGaugeAGlyphAndAReadout() { + assertEquals("RANGED_VALUE,MONOCHROMATIC_IMAGE,SHORT_TEXT", + AndroidGradleBuilder.complicationTypes("watchCircular")); + } + + /// Wear OS has no corner slot at all; a corner complication is round, so it offers exactly + /// what the circular family does -- which is what WidgetSize already documents. + @Test + void cornerIsTreatedAsCircular() { + assertEquals(AndroidGradleBuilder.complicationTypes("watchCircular"), + AndroidGradleBuilder.complicationTypes("watchCorner")); + } + + @Test + void inlineIsShortTextOnly() { + assertEquals("SHORT_TEXT", AndroidGradleBuilder.complicationTypes("watchInline")); + } + + @Test + void rectangularIsTheRoomyOne() { + assertEquals("LONG_TEXT,SHORT_TEXT", + AndroidGradleBuilder.complicationTypes("watchRectangular")); + } + + /// Declaring several families offers the union, deduped, so one data source can fill every + /// slot the developer designed for. + @Test + void severalFamiliesOfferTheUnionWithoutRepeats() { + String types = AndroidGradleBuilder.complicationTypes( + "watchCircular,watchRectangular,watchInline"); + + assertTrue(types.contains("RANGED_VALUE"), types); + assertTrue(types.contains("LONG_TEXT"), types); + assertEquals(types.indexOf("SHORT_TEXT"), types.lastIndexOf("SHORT_TEXT"), types); + } + + /// A phone family contributes nothing: a home-screen size is not a watch-face slot. + @Test + void phoneFamiliesContributeNoComplicationTypes() { + assertEquals("", AndroidGradleBuilder.complicationTypes("small,medium,large,lockscreen")); + } + + // --- version codes ----------------------------------------------------------- + + /// A watch APK must outrank the phone's. On a watch, Play picks among the APKs the device + /// supports by version code; on a phone the required watch feature filters the wear one out + /// entirely, so the phone APK still wins there. + @Test + void theWearArtifactOutranksThePhoneOne() throws BuildException { + assertEquals(100 + AndroidGradleBuilder.DEFAULT_WATCH_VERSION_CODE_OFFSET, + AndroidGradleBuilder.wearVersionCode(request(), 100)); + } + + @Test + void theWearVersionCodeCanBeSetOutright() throws BuildException { + BuildRequest req = request(); + req.putArgument("android.watchVersionCode", "5000"); + + assertEquals(5000, AndroidGradleBuilder.wearVersionCode(req, 100)); + } + + @Test + void theOffsetCanBeWidenedForAProjectThatNumbersItsBuildsTightly() throws BuildException { + BuildRequest req = request(); + req.putArgument("android.watchVersionCodeOffset", "50"); + + assertEquals(150, AndroidGradleBuilder.wearVersionCode(req, 100)); + } + + /// A malformed hint must not produce a version code that silently reorders the two artifacts. + @Test + void aMalformedVersionHintFallsBackToTheDefault() throws BuildException { + BuildRequest req = request(); + req.putArgument("android.watchVersionCodeOffset", "not a number"); + + assertEquals(100 + AndroidGradleBuilder.DEFAULT_WATCH_VERSION_CODE_OFFSET, + AndroidGradleBuilder.wearVersionCode(req, 100)); + } + + /// A hint that puts the watch at or below the phone cannot be honoured: on a watch that also + /// supports the phone APK, Play would install the phone build and the user would see it + /// running on their wrist. That is not a build error anyone would trace back to a hint, so + /// the build refuses it instead. + @Test + void aWearVersionCodeThatDoesNotOutrankThePhoneIsRefused() { + BuildRequest tooLow = request(); + tooLow.putArgument("android.watchVersionCode", "99"); + assertThrows(BuildException.class, () -> AndroidGradleBuilder.wearVersionCode(tooLow, 100)); + + BuildRequest equal = request(); + equal.putArgument("android.watchVersionCode", "100"); + assertThrows(BuildException.class, () -> AndroidGradleBuilder.wearVersionCode(equal, 100)); + + BuildRequest zeroOffset = request(); + zeroOffset.putArgument("android.watchVersionCodeOffset", "0"); + assertThrows(BuildException.class, + () -> AndroidGradleBuilder.wearVersionCode(zeroOffset, 100)); + + BuildRequest negativeOffset = request(); + negativeOffset.putArgument("android.watchVersionCodeOffset", "-5"); + assertThrows(BuildException.class, + () -> AndroidGradleBuilder.wearVersionCode(negativeOffset, 100)); + } + + /// Play refuses a version code it has already seen for an applicationId, and the two + /// artifacts share one -- so an offset of 1 hands the Wear artifact the code the NEXT phone + /// release needs, and a project on sequential codes cannot upload its second release at all. + /// The default partitions the space instead. + @Test + void theDefaultOffsetDoesNotConsumeTheNextReleasesCode() throws BuildException { + int phone = 100; + int wear = AndroidGradleBuilder.wearVersionCode(request(), phone); + + assertTrue(wear > phone + 1000, + "an offset a sequential project would reach is not a partition: " + wear); + assertTrue(wear <= AndroidGradleBuilder.MAX_PLAY_VERSION_CODE, "over Play's ceiling"); + } + + /// A project whose own codes are already enormous -- a date-derived code -- would be pushed + /// over Play's ceiling, and is told to choose its own offset rather than having one silently + /// truncated. + @Test + void aVersionCodeOverPlaysCeilingIsRefused() { + assertThrows(BuildException.class, + () -> AndroidGradleBuilder.wearVersionCode(request(), 2090000000)); + } + + /// The generated class name has to tell two kinds apart, because one class cannot serve two + /// kinds: the second overwrote the first and both manifest entries pointed at it, so one kind + /// served the other kind's data. Asserted over the whole declared SET, which is the level the + /// property belongs to -- the fold on its own is deliberately not injective, so that a kind + /// keeps the name shipped builds already use. + @Test + void twoKindsNeverShareAGeneratedClassName() { + List ids = Arrays.asList( + "status", "status_", "_status", "a_b", "ab_", "a__b", "a_b_", "_a_b"); + Map named = AndroidGradleBuilder.surfaceKindClassSuffixes(ids); + assertEquals(ids.size(), named.size(), "every declared kind must be named"); + assertEquals(ids.size(), new java.util.HashSet(named.values()).size(), + "two ids produced the same class suffix: " + named); + } + + /// An id without underscores keeps the name it has always had, so no existing project is + /// renamed by this. + @Test + void anIdWithoutUnderscoresIsUnchanged() { + assertEquals("Status", AndroidGradleBuilder.surfaceKindClassSuffix("status")); + assertEquals("Weather2", AndroidGradleBuilder.surfaceKindClassSuffix("weather2")); + } + + /// A malformed explicit code is refused rather than silently replaced. Substituting + /// intVersion + 1 hid the typo AND recreated the collision this method exists to prevent, with + /// the developer looking at a hint that says something else entirely. + @Test + void aMalformedExplicitVersionCodeIsRefused() { + BuildRequest req = request(); + req.putArgument("android.watchVersionCode", "not a number"); + assertThrows(BuildException.class, () -> AndroidGradleBuilder.wearVersionCode(req, 100)); + + BuildRequest blank = request(); + blank.putArgument("android.watchVersionCode", " "); + assertThrows(BuildException.class, () -> AndroidGradleBuilder.wearVersionCode(blank, 100)); + } + + /// The name a shipped build already uses must not move. Android remembers a pinned widget + /// by its provider ComponentName, so renaming the receiver of an existing kind makes the + /// widget the user pinned name a receiver that is gone, and the home screen drops it. + @Test + void aKindKeepsTheClassNameItAlreadyShipsUnder() { + assertEquals("DeliveryStatus", AndroidGradleBuilder.surfaceKindClassSuffix("delivery_status")); + assertEquals("Status", AndroidGradleBuilder.surfaceKindClassSuffix("status")); + assertEquals("BatteryLevel", AndroidGradleBuilder.surfaceKindClassSuffix("battery_level")); + + // And a whole declared set of ordinary ids resolves to exactly those names. + Map named = AndroidGradleBuilder.surfaceKindClassSuffixes( + Arrays.asList("delivery_status", "status", "battery_level")); + assertEquals("DeliveryStatus", named.get("delivery_status")); + assertEquals("Status", named.get("status")); + assertEquals("BatteryLevel", named.get("battery_level")); + } + + /// Two ids differing only in where the underscores are fold to one name, and one generated + /// class cannot serve two kinds -- the second overwrote the first and both manifest entries + /// pointed at it. The FIRST declared keeps the shipped name; only the newcomer moves. + @Test + void acollidingKindTakesTheDisambiguatedNameAndTheFirstKeepsItsOwn() { + Map named = AndroidGradleBuilder.surfaceKindClassSuffixes( + Arrays.asList("status", "status_", "a_b", "a__b", "ab")); + assertEquals("Status", named.get("status")); + assertEquals("Status_6", named.get("status_")); + assertEquals("AB", named.get("a_b")); + assertEquals("AB_1_2", named.get("a__b"), "the later collider moves, not the first"); + // Not every underscore is a collision: "ab" folds to Ab, which nothing else claims. + assertEquals("Ab", named.get("ab")); + assertEquals(5, new java.util.HashSet(named.values()).size(), + "every declared kind must end up with its own class"); + + // The underscore-free id declared SECOND. Its positional form is the plain name -- there + // are no underscores to record -- so the disambiguation has nothing of its own to add and + // the resolver has to keep looking. + Map reversed = AndroidGradleBuilder.surfaceKindClassSuffixes( + Arrays.asList("status_", "status")); + assertEquals("Status", reversed.get("status_"), "the first declared keeps the plain name"); + assertNotEquals(reversed.get("status_"), reversed.get("status"), + "two kinds must never share a generated class: " + reversed); + } + + /// The runtime cannot recompute which kind won the plain name -- that is a property of the + /// whole declared set -- so it READS the map the build wrote. It must not guess by probing + /// for a class that exists either: CN1Widget_Status exists for "status", so "status_" + /// probing the plain name first would find the other kind's provider and publish into it. + /// Read out of the port's source rather than called, because the port class needs an Android + /// runtime this test does not have. + @Test + void theRuntimeReadsTheMapRatherThanGuessing() throws java.io.IOException { + java.io.File bridge = new java.io.File("../../Ports/Android/src/com/codename1/impl/" + + "android/surfaces/AndroidSurfaceBridge.java"); + assertTrue(bridge.isFile(), "the port must be readable: " + bridge.getAbsolutePath()); + String source = new String(java.nio.file.Files.readAllBytes(bridge.toPath()), "UTF-8"); + + int at = source.indexOf("static synchronized String classSuffix(Context ctx, String kindId) {"); + assertTrue(at >= 0, "the runtime must resolve the name from the generated map"); + String body = source.substring(at, source.indexOf("\n }\n", at)); + assertTrue(body.contains("\"cn1_surface_kind_classes\""), + "it must read the array the build writes:\n" + body); + assertFalse(body.contains("getReceiverInfo"), + "resolving by what exists finds another kind's provider:\n" + body); + + // And the port's fold has to be the SHIPPED one, with no positions folded in. + int fold = source.indexOf("static String toClassSuffix(String kindId) {"); + String foldBody = source.substring(fold, source.indexOf("\n }\n", fold)); + assertFalse(foldBody.contains("positions"), + "toClassSuffix must stay the name shipped builds use:\n" + foldBody); + } + + /// The map is the build's own table, written out -- not a second computation. The resource + /// name has to be the one the port looks up, and every declared kind has to be in it. + @Test + void theGeneratedMapNamesEveryKind() throws Exception { + java.io.File builder = new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java"); + assertTrue(builder.isFile(), builder.getAbsolutePath()); + String source = new String(java.nio.file.Files.readAllBytes(builder.toPath()), "UTF-8"); + + int at = source.indexOf("private void writeSurfaceKindClassMap(File resDir)"); + assertTrue(at >= 0, "the build must write the map"); + String body = source.substring(at, source.indexOf("\n }\n", at)); + assertTrue(body.contains("cn1_surface_kind_classes"), + "the array name must match what the port reads:\n" + body); + assertTrue(body.contains("surfaceKindClassNames.entrySet()"), + "it must be written from the table that named the classes, not recomputed:\n" + + body); + } + + // --- tiles ------------------------------------------------------------------ + + /// Only the rectangular family is roomy enough for a layout rather than a readout, so it is + /// the only one that earns a Tile. + @Test + void onlyTheRectangularFamilyEarnsATile() { + assertTrue(AndroidGradleBuilder.declaresTile("watchRectangular")); + assertTrue(AndroidGradleBuilder.declaresTile("small,watchRectangular")); + assertFalse(AndroidGradleBuilder.declaresTile("watchCircular,watchInline,watchCorner")); + assertFalse(AndroidGradleBuilder.declaresTile("small,medium")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/StubLifecycleCastTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/StubLifecycleCastTest.java new file mode 100644 index 00000000000..7dbee37a54f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/StubLifecycleCastTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The generated stub must not assume its lifecycle class is extensible. + * + *

The stub holds the app's lifecycle object in a field typed to the app's own main class, and + * asks whether that object also implements {@code PushCallback}, {@code PushActionsProvider} or + * {@code LocalNotificationCallback}. When the main class is FINAL, javac proves the conversion + * impossible and rejects the code outright -- so a main class written in Kotlin, where every + * class is final unless it says {@code open}, breaks a stub the developer never wrote. It was the + * Wear module that found this, because a companion build roots its stub at the watch lifecycle + * class, but the phone stub has always had it.

+ * + *

Testing the emitted text rather than a compile is deliberate: the stub is assembled inline + * across a few hundred lines of {@code AndroidGradleBuilder} with no seam to call, and what has + * to stay true is a property of every one of those sites. Going through {@code Object} keeps the + * runtime behaviour identical and makes the test legal for any type.

+ */ +public class StubLifecycleCastTest { + + private static final String BUILDER = + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java"; + + /** The interfaces the stub probes its lifecycle object for. */ + private static final String[] PROBED = { + "PushCallback", + "com.codename1.push.PushActionsProvider", + "com.codename1.notifications.LocalNotificationCallback", + }; + + @Test + void everyLifecycleProbeGoesThroughObject() throws IOException { + File builder = new File(BUILDER); + assertTrue(builder.isFile(), "the builder must be readable: " + builder.getAbsolutePath()); + String source = new String(Files.readAllBytes(builder.toPath()), StandardCharsets.UTF_8); + + List bare = new ArrayList(); + for (String probed : PROBED) { + // The generated text, as it appears inside the Java string literals that build it. + if (source.contains("i instanceof " + probed)) { + bare.add("i instanceof " + probed); + } + if (source.contains("(" + probed + ")i")) { + bare.add("(" + probed + ")i"); + } + } + + assertTrue(bare.isEmpty(), + "these probe the lifecycle field directly, so a final main class fails to " + + "compile; interpose (Object) as the neighbouring sites do: " + bare); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java index 75acf4795ed..4c03ae5ab2b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java @@ -1612,4 +1612,21 @@ private static File sdkPath(String sdk) { return null; } } + + /// The complication extension is embedded in the watch app, so a watch that cannot install + /// the app cannot show its complication. WidgetKit itself goes back to watchOS 9 and the + /// extension builds there, which is where its own floor sits -- but the DEFAULT the builder + /// is given has to be the app's, or the extension advertises support that does not exist. + /// Pinned as a relationship rather than a number so lowering the app's floor later needs no + /// change here. + @Test + public void theWatchAppNeverRequiresMoreThanItsComplicationAdvertises() { + String app = WatchNativeBuilder.MIN_DEPLOYMENT_TARGET; + String extension = com.codename1.util.IOSWidgetExtensionBuilder.WATCH_MIN_DEPLOYMENT_TARGET; + + assertTrue(Double.parseDouble(app) >= Double.parseDouble(extension), + "the watch app requires watchOS " + app + " while its complication extension " + + "claims to support " + extension + "; the extension is embedded in the " + + "app, so the app's floor is the one users actually meet"); + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java new file mode 100644 index 00000000000..0fbae7aef7b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import com.codename1.util.IOSWidgetExtensionBuilder; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The watchOS complication extension is embedded in the WATCH app, not the phone app. That one +/// choice is what makes the companion and standalone distributions need no separate handling: +/// the companion case already copies the finished watch app into the phone app, .appex and all, +/// and the standalone case ships the watch app as the product. +class WatchWidgetExtensionTargetTest { + + private static final String WATCH_MAIN = "com.mycompany.myapp.MyWatchMain"; + + private static BuildRequest request() { + BuildRequest req = new BuildRequest(); + req.setMainClass("MyApp"); + req.setPackageName("com.mycompany.myapp"); + req.setDisplayName("My App"); + req.setVersion("1.0"); + return req; + } + + private static WatchNativeBuilder parse(BuildRequest req) { + WatchNativeBuilder b = new WatchNativeBuilder(new IPhoneBuilder()); + b.parseHints(req); + return b; + } + + /// Writes a realistic extension folder so the script generator has files to reference. + private static File extensionDir(Path tmp) throws Exception { + File dist = new File(tmp.toFile(), "dist"); + File dir = new File(dist, IPhoneBuilder.SURFACES_WATCH_EXTENSION_NAME); + dir.mkdirs(); + IOSWidgetExtensionBuilder b = new IOSWidgetExtensionBuilder() + .setWatchTarget(true) + .setExtensionName(IPhoneBuilder.SURFACES_WATCH_EXTENSION_NAME) + .setHostBundleId("com.mycompany.myapp.watchkitapp") + .setAppGroupId("group.com.mycompany.myapp") + .addKind(new IOSWidgetExtensionBuilder.Kind("status") + .setIosFamilies(Arrays.asList("watchCircular"))); + for (java.util.Map.Entry e : b.buildFileMap().entrySet()) { + Files.write(new File(dir, e.getKey()).toPath(), e.getValue()); + } + return dir; + } + + private static String script(BuildRequest req, Path tmp, boolean withExtension) + throws Exception { + WatchNativeBuilder b = parse(req); + if (withExtension) { + b.setWidgetExtension(extensionDir(tmp), "group.com.mycompany.myapp", "10.0"); + } + return b.buildXcodeScript(req, tmp.toFile(), "1.0", new ArrayList()); + } + + /// :watch2_extension is the LEGACY paired WatchKit app extension -- the same trap as + /// :application vs :watch2_app for the app target itself. A WidgetKit extension is a plain + /// app extension on every platform Apple ships it on. + @Test + void theExtensionIsAnAppExtensionNotAWatchKitOne(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String ruby = script(req, tmp, true); + + assertTrue(ruby.contains("xcproj.new_target(:app_extension, 'CN1WatchWidgets', :watchos"), + ruby); + assertFalse(ruby.contains("watch2_extension"), ruby); + } + + /// PlugIns of the WATCH app. Embedding it in the phone app instead would put a watchOS + /// binary in an iOS bundle, and would need a separate answer for the standalone case. + @Test + void theExtensionIsEmbeddedInTheWatchApp(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String ruby = script(req, tmp, true); + + assertTrue(ruby.contains("watch_target.add_dependency(ext_target)"), ruby); + assertTrue(ruby.contains("watch_target.new_copy_files_build_phase(" + + "'Embed Foundation Extensions')"), ruby); + assertTrue(ruby.contains("ext_embed.dst_subfolder_spec = \"13\""), ruby); + } + + /// The whole point of embedding in the watch app: neither distribution needs a branch. + @Test + void theSameFragmentServesCompanionAndStandalone(@TempDir Path tmp) throws Exception { + BuildRequest companion = request(); + companion.putArgument("watchMain", WATCH_MAIN); + BuildRequest standalone = request(); + standalone.putArgument("watchMain", WATCH_MAIN); + standalone.putArgument("watchStandalone", "true"); + + String companionRuby = script(companion, tmp, true); + String standaloneRuby = script(standalone, tmp, true); + + for (String marker : new String[] { + "xcproj.new_target(:app_extension, 'CN1WatchWidgets', :watchos", + "watch_target.add_dependency(ext_target)", + "ext_embed.dst_subfolder_spec = \"13\"" }) { + assertTrue(companionRuby.contains(marker), "companion: " + marker); + assertTrue(standaloneRuby.contains(marker), "standalone: " + marker); + } + } + + /// A project that publishes no complication must produce the script it always did. + @Test + void noExtensionMeansNoFragment(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String ruby = script(req, tmp, false); + + assertFalse(ruby.contains("ext_target"), ruby); + assertFalse(ruby.contains("Embed Foundation Extensions"), ruby); + assertFalse(ruby.contains("CN1SurfaceBridge.swift"), ruby); + } + + /// IOSNative reaches the Swift bridge through NSClassFromString, so the watch target has to + /// compile it. Its own translation does not carry it -- only the phone's -src does -- so + /// without this the watch finds no bridge and every surfaces native answers unsupported. + @Test + void theWatchTargetCompilesTheAppSideSurfacesGlue(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String ruby = script(req, tmp, true); + + assertTrue(ruby.contains("CN1SurfaceBridge.swift"), ruby); + assertTrue(ruby.contains("CN1SurfaceConfig.swift"), ruby); + } + + /// The watch bundle is signed with its own entitlements and inherits nothing from the phone, + /// so the App Group it shares with its extension has to be granted here. + @Test + void publishingSurfacesEntitlesTheWatchAppGroup() { + BuildRequest req = request(); + String plist = WatchNativeBuilder.watchEntitlementsPlist(req, "false", false, + "group.com.mycompany.myapp"); + + assertTrue(plist.contains("com.apple.security.application-groups"), plist); + assertTrue(plist.contains("group.com.mycompany.myapp"), plist); + } + + /// Granting a capability nothing uses is not harmless: entitlement validation refuses a + /// signature carrying one the provisioning profile does not have. + @Test + void aSurfacesOnlyWatchIsNotGrantedHealthKit() { + BuildRequest req = request(); + String plist = WatchNativeBuilder.watchEntitlementsPlist(req, "false", false, + "group.com.mycompany.myapp"); + + assertFalse(plist.contains("com.apple.developer.healthkit"), plist); + } + + /// And the reverse: a HealthKit watch that publishes nothing keeps exactly the entitlements + /// it had before complications existed. + @Test + void aHealthOnlyWatchIsUnchanged() { + BuildRequest req = request(); + + String before = WatchNativeBuilder.watchEntitlementsPlist(req, "false"); + String after = WatchNativeBuilder.watchEntitlementsPlist(req, "false", true, null); + + assertTrue(before.equals(after), "before:\n" + before + "\nafter:\n" + after); + assertTrue(before.contains("com.apple.developer.healthkit"), before); + assertFalse(before.contains("application-groups"), before); + } + + /// The natives compare the running OS against this key. The iOS default of 16.1 compared + /// against a watchOS version is never met, so a watch left on it reports no widget support + /// however well everything else is wired. + @Test + void theWatchPlistAdvertisesItsOwnSurfacesFloor(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + WatchNativeBuilder b = parse(req); + b.setWidgetExtension(extensionDir(tmp), "group.com.mycompany.myapp", "10.0"); + File srcDir = new File(tmp.toFile(), "src"); + srcDir.mkdirs(); + + b.writeWatchInfoPlist(req, srcDir); + String plist = new String(Files.readAllBytes( + new File(srcDir, "MyApp-Watch-Info.plist").toPath()), StandardCharsets.UTF_8); + + assertTrue(plist.contains("CN1SurfacesAppGroup"), plist); + assertTrue(plist.contains("group.com.mycompany.myapp"), plist); + assertTrue(plist.contains("CN1SurfacesMinOS"), plist); + assertTrue(plist.contains("10.0"), plist); + } + + /// A complication tap launches the watch app with the widgetURL rather than delivering it to + /// a delegate -- there is no UIApplicationDelegate on watchOS -- so the SwiftUI scene is the + /// only place it can be caught. Without this the tap opened the app and the action was lost. + @Test + void aComplicationTapReachesTheActionHandler(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + WatchNativeBuilder b = parse(req); + b.setWidgetExtension(extensionDir(tmp), "group.com.mycompany.myapp", "10.0"); + File srcDir = new File(tmp.toFile(), "src"); + srcDir.mkdirs(); + + b.writeWatchEntry(req, srcDir); + String swift = new String(Files.readAllBytes( + new File(srcDir, "CN1WatchApp.swift").toPath()), StandardCharsets.UTF_8); + String bridging = new String(Files.readAllBytes( + new File(srcDir, "MyApp-Watch-Bridging-Header.h").toPath()), StandardCharsets.UTF_8); + + assertTrue(swift.contains(".onOpenURL"), swift); + assertTrue(swift.contains("cn1_watch_surface_url(url.absoluteString)"), swift); + // A plain C function is invisible to Swift unless the bridging header declares it. + assertTrue(bridging.contains("void cn1_watch_surface_url(const char *url);"), bridging); + } + + /// A watch app with no complication keeps the scene it had, and a bridging header naming + /// nothing it cannot call. + @Test + void noComplicationMeansNoTapPlumbing(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + WatchNativeBuilder b = parse(req); + File srcDir = new File(tmp.toFile(), "src"); + srcDir.mkdirs(); + + b.writeWatchEntry(req, srcDir); + String swift = new String(Files.readAllBytes( + new File(srcDir, "CN1WatchApp.swift").toPath()), StandardCharsets.UTF_8); + String bridging = new String(Files.readAllBytes( + new File(srcDir, "MyApp-Watch-Bridging-Header.h").toPath()), StandardCharsets.UTF_8); + + assertFalse(swift.contains("onOpenURL"), swift); + assertTrue(swift.contains("WindowGroup { CN1WatchRootView() }"), swift); + assertFalse(bridging.contains("cn1_watch_surface_url"), bridging); + } + + /// A complication supplies a :// widgetURL, and the watch is a separate bundle that + /// inherits none of the phone's URL types -- so without this declaration watchOS has nothing + /// to route the tap to and onOpenURL never fires, however well the rest is wired. + /// + /// The scheme has to be the app's OWN. A URL scheme is a global claim and a complication tap + /// is routed by nothing else, so two Codename One apps on one watch both claiming the bare + /// cn1surface meant a tap could open the other one. Asserting the bare name is ABSENT is the + /// half that would rot silently: re-adding it costs nothing at build time and gives the + /// collision back. + @Test + void theWatchBundleDeclaresTheSurfaceUrlScheme(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + WatchNativeBuilder b = parse(req); + b.setWidgetExtension(extensionDir(tmp), "group.com.mycompany.myapp", "10.0"); + File srcDir = new File(tmp.toFile(), "src"); + srcDir.mkdirs(); + + b.writeWatchInfoPlist(req, srcDir); + String plist = new String(Files.readAllBytes( + new File(srcDir, "MyApp-Watch-Info.plist").toPath()), StandardCharsets.UTF_8); + + assertTrue(plist.contains("CFBundleURLTypes"), plist); + // The WATCH bundle id, not the phone's. The extension is built with + // setHostBundleId(.watchkitapp), so that is what its widgetURL carries -- and a + // plist registering the phone's scheme would look correct while routing nothing. + assertTrue(plist.contains( + "cn1surface." + req.getPackageName() + ".watchkitapp"), plist); + assertFalse(plist.contains("cn1surface"), plist); + assertFalse(plist.contains("cn1surface." + req.getPackageName() + ""), + plist); + } + + /// The scheme the watch registers and the scheme the widget generates are computed by one + /// method, so they cannot drift -- and it has to stay a LEGAL scheme, which is a narrower + /// grammar than a bundle id: letters, digits, '+', '-' and '.' only. + @Test + void theSurfaceSchemeIsQualifiedAndLegal() { + assertEquals("cn1surface.com.mycompany.myapp", + IOSWidgetExtensionBuilder.surfaceScheme("com.mycompany.myapp")); + assertEquals("cn1surface.com.my-app.x9", + IOSWidgetExtensionBuilder.surfaceScheme("com.my-app.x9")); + assertEquals("cn1surface.com.my-app", + IOSWidgetExtensionBuilder.surfaceScheme("com.my_app"), + "an underscore is legal in a bundle id and not in a scheme"); + assertEquals("cn1surface", IOSWidgetExtensionBuilder.surfaceScheme(null)); + } + + /// A watch app that publishes nothing must not carry either key. + @Test + void aWatchThatPublishesNothingCarriesNoSurfacesKeys(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + WatchNativeBuilder b = parse(req); + File srcDir = new File(tmp.toFile(), "src"); + srcDir.mkdirs(); + + b.writeWatchInfoPlist(req, srcDir); + String plist = new String(Files.readAllBytes( + new File(srcDir, "MyApp-Watch-Info.plist").toPath()), StandardCharsets.UTF_8); + + assertFalse(plist.contains("CN1SurfacesAppGroup"), plist); + assertFalse(plist.contains("CN1SurfacesMinOS"), plist); + assertFalse(plist.contains("cn1surface"), plist); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearGlueCompilesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearGlueCompilesTest.java new file mode 100644 index 00000000000..20b82315a37 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearGlueCompilesTest.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import java.io.File; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The injected Wear OS complication and Tile services have to compile. + * + *

Both are copied into a generated Android project at build time, so nothing else here reads + * them and no build in this repository ever compiles them: a broken edit ships and fails in a + * customer's Gradle build, naming a file they never wrote.

+ * + *

They are compiled against the REAL {@code CN1WatchSurface} from the Android port -- so a + * service that drifts from the reader's contract fails here rather than there -- and against a + * stub tree for the Android, AndroidX Wear and Guava types, which are the only parts a build of + * this module cannot supply. That tree deliberately declares only what the services use, which + * makes it an executable record of how much of the AndroidX Wear API surface this depends on.

+ */ +public class WearGlueCompilesTest { + + private static final String WEAR_RESOURCES = + "src/main/resources/com/codename1/builders/surfaces/wear"; + + /** The port's own reader, so the check is against the real contract. */ + private static final String PORT_SURFACES = + "../../Ports/Android/src/com/codename1/impl/android/surfaces"; + + private static final String STUBS = "src/test/resources/wear-surface-stubs"; + + @Test + void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); + assertNotNull(javac, "these tests need a JDK, not a JRE"); + + File wear = new File(WEAR_RESOURCES); + assertTrue(wear.isDirectory(), "the injected Wear services must be readable: " + + wear.getAbsolutePath()); + + List sources = new ArrayList(); + collectJava(wear, sources); + assertTrue(sources.size() >= 2, + "expected the complication data source and the Tile service in " + WEAR_RESOURCES); + + // The reader they share, from the port. Only the two files the services actually touch, + // because the rest of that package reaches into the wider Android port and would need a + // far larger stub tree to say nothing more than these two already do. + sources.add(new File(PORT_SURFACES, "CN1WatchSurface.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 port classes CN1WatchSurface calls into are stubbed rather than compiled: they + // pull in the whole RemoteViews renderer, and what matters here is that the services + // agree with the reader, not that the renderer builds -- which the port's own build + // already proves. + 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" + + " public static String readWidgetTimeline(Context c, String k) " + + "{ return null; }\n" + + "}\n").getBytes("UTF-8")); + // The mirror, for the stale-image collection the reader triggers. Reading is the durable + // hook for that sweep -- a process the system stops at will loses anything scheduled -- + // so the reader calls it, and this test has to know that. + Files.write(shims.resolve("CN1SurfaceMirror.java"), + ("package com.codename1.impl.android.surfaces;\n" + + "import android.content.Context;\n" + + "public class CN1SurfaceMirror {\n" + + " public static void collectStaleImages(Context c, String k) { }\n" + + "}\n").getBytes("UTF-8")); + Files.write(shims.resolve("CN1SurfaceRenderer.java"), + ("package com.codename1.impl.android.surfaces;\n" + + "import android.content.Context;\n" + + "import android.content.Intent;\n" + + "import android.graphics.Bitmap;\n" + + "import org.json.JSONObject;\n" + + "public class CN1SurfaceRenderer {\n" + + " static String interpolate(String t, JSONObject s) { return t; }\n" + + " static Bitmap renderWatchBitmap(Context c, String k, JSONObject n, " + + "JSONObject s) { return null; }\n" + + " static Intent watchActionIntent(Context c, String src, String id, " + + "JSONObject p) { return null; }\n" + + " static long resolveWatchDate(JSONObject n, JSONObject s) " + + "{ return 0L; }\n" + + " static String formatWatchDynamicText(JSONObject n, JSONObject s) " + + "{ return \"\"; }\n" + + " static double resolveFraction(JSONObject n, JSONObject s) " + + "{ return 0d; }\n" + // The as-of overload the watch reader uses to render a future timeline + // entry against the moment it takes over rather than against now. + + " static double resolveFraction(JSONObject n, JSONObject s, long a) " + + "{ return 0d; }\n" + + " static int resolveColor(JSONObject c, boolean d, int fl, int fd) " + + "{ return 0; }\n" + + "}\n").getBytes("UTF-8")); + 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 scheduled form, for a reload-at-end timeline whose end is in the + // future: asking now would spend the throttled fetch hours early. + + " static void scheduleAppRefresh(Context c, String k, long w) { }\n" + + "}\n").getBytes("UTF-8")); + Files.write(shims.resolve("CN1SurfaceActionActivity.java"), + ("package com.codename1.impl.android.surfaces;\n" + + "public class CN1SurfaceActionActivity {\n" + + " public static final String EXTRA_SOURCE = \"s\";\n" + + " public static final String EXTRA_ACTION_ID = \"a\";\n" + + " public static final String EXTRA_ACTION_PARAMS = \"p\";\n" + + " public static final String EXTRA_TOKEN = \"t\";\n" + + " public static String token(android.content.Context c) " + + "{ return \"\"; }\n" + + "}\n").getBytes("UTF-8")); + collectJava(shims.getParent().getParent().getParent().getParent().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 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 Wear OS services do 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, final List out) throws IOException { + Files.walkFileTree(dir.toPath(), new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (file.toString().endsWith(".java")) { + out.add(file.toFile()); + } + return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java new file mode 100644 index 00000000000..9f1659aa711 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/// The Wear module's build.gradle is DERIVED from the phone module's by textual substitution, +/// which is cheap and total -- and every one of these substitutions has been wrong at least once +/// in a way no compiler could see. A generated Gradle file only fails when Gradle evaluates it, +/// which on CI is twenty minutes after the mistake. +/// +/// These pin each substitution against a build.gradle shaped like the real one. +class WearModuleGradleTest { + + /// The shape that matters: a buildscript dependency block BEFORE the project one, an + /// indented `dependencies {` and a column-zero `dependencies {`, plus the paths that have to + /// be rewritten for a module one directory over. + private static final String PHONE_GRADLE = + "apply plugin: 'com.android.application'\n" + + "buildscript {\n" + + " repositories {\n" + + " mavenCentral()\n" + + " }\n" + + " dependencies {\n" + + " classpath 'com.android.tools.build:gradle:8.1.4'\n" + + " }\n" + + "}\n" + + "\n" + + "android {\n" + + " compileSdkVersion 34\n" + + " defaultConfig {\n" + + " applicationId \"com.mycompany.myapp\"\n" + + " minSdkVersion 24\n" + + " versionCode 100\n" + + " }\n" + + " buildTypes {\n" + + " release {\n" + + " minifyEnabled true\n" + + " proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg'\n" + + " }\n" + + " }\n" + + " signingConfigs {\n" + + " release {\n" + + " storeFile file(\"keyStore\")\n" + + " }\n" + + " }\n" + + "}\n" + + "\n" + + "repositories {\n" + + " flatDir{\n" + + " dirs 'libs'\n" + + " }\n" + + "}\n" + + "\n" + + "dependencies {\n" + + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + "}\n" + // The generated file really does repeat both block openings: a second, top-level + // dependency block for the instrumentation deps, and a second android block that the + // coverage harness appends. Both are here because an insertion that lands in them is + // the bug this fixture exists to catch. + + "\n" + + "dependencies {\n" + + " androidTestImplementation \"androidx.test:runner:1.5.2\"\n" + + "}\n" + + "\n" + + "android {\n" + + " buildTypes {\n" + + " debug {\n" + + " testCoverageEnabled true\n" + + " }\n" + + " }\n" + + "}\n"; + + private static final String WEAR_DEPS = + " implementation 'androidx.wear.watchface:watchface-complications-data-source:1.2.1'\n" + + " implementation 'androidx.wear.tiles:tiles:1.4.1'\n" + + " implementation 'androidx.concurrent:concurrent-futures:1.1.0'\n" + + " implementation 'com.google.guava:guava:31.1-android'\n"; + + /// The REAL derivation, not a copy of it. A test that reproduced the substitutions would + /// pass while the builder drifted away from it, which is the one failure mode these + /// assertions exist to prevent. + private static String deriveWearGradle() { + return AndroidGradleBuilder.deriveWearGradle(PHONE_GRADLE, 100, 101, WEAR_DEPS); + } + + /// The source set belongs in the module's OWN android block and nowhere else. A coverage + /// harness appends a second one to add a build type, and String.replace rewrites every + /// occurrence -- so the plain anchor put a second copy of the source set in a block that only + /// wanted a buildTypes entry. + @Test + void theSourceSetIsInsertedExactlyOnce() { + String wear = deriveWearGradle(); + + int first = wear.indexOf("java.srcDirs = ['../app/src/main/java'"); + assertTrue(first >= 0, "the shared source set must be there:\n" + wear); + assertEquals(-1, wear.indexOf("java.srcDirs = ['../app/src/main/java'", first + 1), + "the source set was inserted into more than one android block:\n" + wear); + } + + /// Same reasoning one level up: the generated file carries an androidTest dependency block + /// after the project one, and the androidx.wear libraries have no business in it. + @Test + void theWearDependencyIsInsertedExactlyOnce() { + String wear = deriveWearGradle(); + + int first = wear.indexOf("watchface-complications-data-source"); + assertTrue(first >= 0, "the wear dependency must be there:\n" + wear); + assertEquals(-1, wear.indexOf("watchface-complications-data-source", first + 1), + "the wear dependency was inserted into more than one block:\n" + wear); + assertTrue(wear.indexOf("androidTestImplementation") > first, + "the instrumentation block must still follow, untouched:\n" + wear); + } + + /// android.xgradle_default_config lets a project add its own declarations inside + /// defaultConfig, and they land AFTER the generated ones -- so rewriting the generated + /// versionCode left the project's value effective and the Wear artifact quietly kept the + /// phone's. A trailing block is evaluated last whatever the file above it says. + @Test + void theWearVersionCodeSurvivesADefaultConfigHint() { + String withHint = PHONE_GRADLE.replace( + " versionCode 100\n", + " versionCode 100\n versionCode 777\n"); + + String wear = AndroidGradleBuilder.deriveWearGradle(withHint, 100, 101, WEAR_DEPS); + + int hint = wear.lastIndexOf("versionCode 777"); + int ours = wear.lastIndexOf("versionCode 101"); + assertTrue(hint >= 0, "the fixture must still carry the hint:\n" + wear); + assertTrue(ours > hint, + "the Wear version code must be declared after the hint's:\n" + wear); + } + + /// Same reasoning for the floor: a hint declaring minSdkVersion 21 would otherwise build the + /// Wear module below the API level the complication and Tile libraries require. + @Test + void theWearFloorSurvivesADefaultConfigHint() { + String withHint = PHONE_GRADLE.replace( + " minSdkVersion 24\n", + " minSdkVersion 24\n minSdkVersion 21\n"); + + String wear = AndroidGradleBuilder.deriveWearGradle(withHint, 100, 101, WEAR_DEPS); + + int hint = wear.lastIndexOf("minSdkVersion 21"); + int ours = wear.lastIndexOf("minSdkVersion 26"); + assertTrue(hint >= 0, wear); + assertTrue(ours > hint, "the Wear floor must be declared after the hint's:\n" + wear); + } + + /// ...but only for the libraries that need it. A companion watch app using just the + /// lifecycle or the Data Layer has always run on the Wear OS 2 baseline. + @Test + void aWatchWithNoSurfacesKeepsTheWearOsTwoBaseline() { + String wear = AndroidGradleBuilder.deriveWearGradle(PHONE_GRADLE, 100, 101, ""); + + assertFalse(wear.contains("minSdkVersion 26"), + "nothing here needs API 26:\n" + wear); + assertTrue(wear.contains("versionCode 101"), + "the version code still has to outrank the phone:\n" + wear); + } + + /// The generated in-app billing interface for a pre-v8 port is written next to the Java it + /// serves, which is why the phone gradle names src/main/java as an AIDL root. Pointing the + /// wear module at a src/main/aidl that no build creates left it compiling the billing sources + /// with no IInAppBillingService to compile against. + @Test + void aidlIsReadFromTheSameTreeAsTheJava() { + String wear = deriveWearGradle(); + + assertTrue(wear.contains("aidl.srcDirs = ['../app/src/main/java']"), wear); + assertFalse(wear.contains("'../app/src/main/aidl'"), + "no build writes that directory:\n" + wear); + } + + /// Gradle resolves a bare proguardFiles path against the project it appears in, and the + /// generated proguard.cfg is written into the app module alone -- so a verbatim copy had the + /// Wear R8 task looking for a file beside itself. Exactly the trap the keystore line records, + /// one line further on, and it fails the release build rather than the watch half of it. + @Test + void proguardIsReadFromTheAppModule() { + String wear = deriveWearGradle(); + + assertTrue(wear.contains("'../app/proguard.cfg'"), wear); + assertFalse(wear.contains(", 'proguard.cfg'"), + "the bare path resolves against the wear module:\n" + wear); + } + + /// TileService.onTileRequest returns a ListenableFuture, whose class ships in + /// com.google.guava:listenablefuture:1.0. Guava publishes the SAME coordinate at + /// 9999.0-empty-to-avoid-conflict-with-guava holding NO classes, for builds that carry full + /// Guava; anything pulling the marker wins the version comparison and the real jar drops out + /// -- CameraX's graph does exactly that. Supplying Guava is what makes the marker correct. + /// Forcing 1.0 back instead put ListenableFuture in two jars for a project whose graph + /// already had Guava, and failed checkDuplicateClasses. + @Test + void aTileBringsGuavaSoListenableFutureHasSomewhereToComeFrom() { + String deps = AndroidGradleBuilder.watchSurfaceDependencyBlock( + "implementation", true, "1.2.1", "1.4.1", "1.2.1", "31.1-android"); + + assertTrue(deps.contains("androidx.concurrent:concurrent-futures"), deps); + assertTrue(deps.contains("com.google.guava:guava:31.1-android"), + "the empty marker artifact leaves ListenableFuture with no provider:\n" + deps); + assertFalse(deps.contains("resolutionStrategy"), + "forcing the 1.0 jar duplicates the class wherever full Guava is present:\n" + + deps); + } + + /// A kind with no rectangular family earns no Tile, and then none of it is needed. + @Test + void aComplicationOnlyKindPullsNoTileDependencies() { + String deps = AndroidGradleBuilder.watchSurfaceDependencyBlock( + "implementation", false, "1.2.1", "1.4.1", "1.2.1", "31.1-android"); + + assertTrue(deps.contains("watchface-complications-data-source:1.2.1"), deps); + assertFalse(deps.contains("tiles"), deps); + assertFalse(deps.contains("guava"), deps); + assertFalse(deps.contains("concurrent-futures"), deps); + } + + /// A legacy support-library build writes "compile" throughout, and these lines have to match + /// the block they are inserted into. + @Test + void theDependencyKeywordFollowsTheBuild() { + String deps = AndroidGradleBuilder.watchSurfaceDependencyBlock( + "compile", true, "1.2.1", "1.4.1", "1.2.1", "31.1-android"); + + assertFalse(deps.contains("implementation "), deps); + assertTrue(deps.contains(" compile 'com.google.guava:guava:"), deps); + } + + /// The androidx.wear dependency must land in the PROJECT block. The buildscript block is + /// indented and comes first, and String.replace hits every occurrence -- so the plain + /// "dependencies {" anchor put an implementation() call inside buildscript's dependency + /// handler, where the method does not exist and the whole :wear project failed to evaluate. + @Test + void theWearDependencyGoesInTheProjectBlockAndNotBuildscript() { + String wear = deriveWearGradle(); + + int buildscriptDeps = wear.indexOf(" dependencies {"); + int projectDeps = wear.indexOf("\ndependencies {"); + int wearDep = wear.indexOf("watchface-complications-data-source"); + + assertTrue(buildscriptDeps >= 0 && projectDeps > buildscriptDeps, wear); + assertTrue(wearDep > projectDeps, + "the wear dependency must follow the project block, not buildscript's:\n" + wear); + } + + /// Gradle resolves file("keyStore") relative to the project it appears in, and the key is + /// written only to the app module -- so a verbatim copy made a release build fail to + /// CONFIGURE, taking the phone artifact down with it. + @Test + void theSigningKeyIsReachedFromTheAppModule() { + assertTrue(deriveWearGradle().contains("storeFile file(\"../app/keyStore\")")); + } + + /// Libraries are shared rather than copied; a stale 'libs' path would resolve to an empty + /// directory in the wear module and drop every submitted jar. + @Test + void librariesAreSharedFromTheAppModule() { + String wear = deriveWearGradle(); + + assertTrue(wear.contains("fileTree(dir: '../app/libs'"), wear); + assertTrue(wear.contains("dirs '../app/libs'"), wear); + assertTrue(!wear.contains("dir: 'libs'"), wear); + } + + /// android.xmanifest is how a project accepts a dependency whose own manifest demands a + /// higher minSdk than the app declares -- tools:overrideLibrary, on uses-sdk. The wear module + /// keeps the phone's dependency graph, so it merges that same library manifest and needs the + /// same override; without it a project that builds today fails in the wear merge instead. + @Test + void theWearManifestCarriesAUsesSdkOverride() { + BuildRequest overridden = + new BuildRequest(); + overridden.putArgument("android.xmanifest", + " tools:overrideLibrary=\"com.example.sdk\""); + + String usesSdk = AndroidGradleBuilder.wearUsesSdk(overridden); + assertTrue(usesSdk.contains("{@code CN1WearableBridge} and {@code CN1WearableListenerService} are copied into a generated + * Android project when a project uses {@code com.codename1.wearable}, and are typed against + * play-services-wearable, which this repository does not depend on. Nothing here read them, so for + * a long time nothing compiled them either: the pair carried a call to {@code MessageEvent.freeze()} + * -- a method only {@code DataEvent} has, because only {@code DataEvent} is {@code Freezable} -- + * and it reached a customer's Gradle build rather than a build of ours. Watch surfaces turn + * {@code usesWearable} on, which is what finally compiled these files and found it.

+ * + *

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 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> 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"