Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
58f2b88
Share the surface family rule, and make its Swift compile for watchOS
shai-almog Aug 21, 2026
9e54822
Give the widget extension a watchOS flavour
shai-almog Aug 21, 2026
a647275
Let the watch publish its own surfaces
shai-almog Aug 21, 2026
7a3505a
Build the watch a complication extension of its own
shai-almog Aug 21, 2026
df9b845
Make CI compile the complication extension on every PR
shai-almog Aug 21, 2026
b310374
Deliver a complication tap to the action handler
shai-almog Aug 21, 2026
4e57de0
Preview a complication in the simulator
shai-almog Aug 21, 2026
67b90fa
Mirror a phone-published surface to the watch complication
shai-almog Aug 21, 2026
84e137e
Recognize a Wear complication kind, and say what becomes of it
shai-almog Aug 21, 2026
445086b
Render a Codename One surface on a Wear OS watch face
shai-almog Aug 21, 2026
822e641
Document what the watch actually shows
shai-almog Aug 21, 2026
ad8aed6
Read the wire format the serializer actually writes
shai-almog Aug 22, 2026
badd9cd
Give the watch slice its natives, its listener and its padding
shai-almog Aug 22, 2026
e4952d5
Keep the wear libraries, and their SDK floor, on the watch module
shai-almog Aug 22, 2026
d2c4b52
Keep the Wear module compiling, and pin the file that says how
shai-almog Aug 22, 2026
81f8584
Compile the Data Layer glue, and fix the two errors that found
shai-almog Aug 22, 2026
b4a130a
Make the Wear module compile: three separate causes
shai-almog Aug 22, 2026
d0e7d59
Give ListenableFuture a provider instead of forcing one
shai-almog Aug 22, 2026
25cebc8
Round five review: six findings across the Wear half
shai-almog Aug 22, 2026
d08e85b
Round five review: three findings on the Apple side
shai-almog Aug 22, 2026
f128284
Name the version helpers as the daemon already names them
shai-almog Aug 22, 2026
023172e
Close the door the exported Tile trampoline opened
shai-almog Aug 22, 2026
a1551b3
Round six review: findings raised against the daemon, fixed at the so…
shai-almog Aug 22, 2026
a51ab65
Round seven review: answer the whole timeline, and four more
shai-almog Aug 22, 2026
a270b7b
Recognize the debug Wear artifact's role suffix
shai-almog Aug 22, 2026
ccccac3
Take the conditional image write back out
shai-almog Aug 22, 2026
f655db0
Round nine review: exact family names, and two the code answers already
shai-almog Aug 22, 2026
7333c48
Round ten review: an untrusted tap does nothing at all
shai-almog Aug 22, 2026
291429a
Round eleven review: five, one of them my own regression
shai-almog Aug 22, 2026
1fcd13f
Round twelve review: an app called fitness-wear, and two more
shai-almog Aug 22, 2026
bcce5be
Round thirteen review: iOS reloads, Tile weights, and a prefix match
shai-almog Aug 22, 2026
0696d62
Round fourteen review: the role rule, and two Tile fidelity gaps
shai-almog Aug 22, 2026
bf5f3b0
Round fifteen review: Tile fidelity, and a listener that broke old ports
shai-almog Aug 22, 2026
9d3403a
Activate the watch session where a background wake can reach it
shai-almog Aug 22, 2026
7ca2c09
Round sixteen review: a reload is also a first delivery
shai-almog Aug 22, 2026
7acb019
Round seventeen review: art the descriptor names but the publish did …
shai-almog Aug 22, 2026
1b8d74b
Round eighteen review: the same registered-name gap on iOS
shai-almog Aug 22, 2026
3c58051
Round nineteen review: version codes, class names, labels, resource v…
shai-almog Aug 22, 2026
eed7a5d
Round twenty review: three small ones with visible consequences
shai-almog Aug 22, 2026
39bc7c2
Round twenty-one review: four, including one of my own from last round
shai-almog Aug 22, 2026
482ed5e
Round twenty-two review: a tap that arrives before the runtime
shai-almog Aug 22, 2026
445ebf5
Round twenty-three review: an unset image dimension is not a natural one
shai-almog Aug 22, 2026
6e0564a
Round twenty-four review: the queue's own race, and a write nobody ch…
shai-almog Aug 22, 2026
5cd150e
Round twenty-five review: coverage in the wrong module, and three more
shai-almog Aug 22, 2026
370a599
Round twenty-six review: the watchOS floor was wrong about its own code
shai-almog Aug 22, 2026
cc50dbd
Round twenty-seven review: ask at the end, not while building the tim…
shai-almog Aug 22, 2026
5a11ac7
Round twenty-eight review: advertising a watchOS the host cannot run
shai-almog Aug 23, 2026
fa6195a
Round twenty-nine review: name the class the same way at both ends
shai-almog Aug 23, 2026
9d7b11f
Round thirty review: answer a resources request from the descriptor
shai-almog Aug 23, 2026
06e75e4
Round thirty-one review: stop cutting characters in half, and stop cr…
shai-almog Aug 23, 2026
289bd81
Declare nativeSurfaceAction where the decode now lives
shai-almog Aug 23, 2026
b872e76
Round thirty-two review: bound the Tile response, and keep the entries
shai-almog Aug 23, 2026
9b1006a
Give each app its own surface scheme, and a version that cannot collide
shai-almog Aug 23, 2026
825a05e
Stop renaming widget receivers, and stop showing stale values
shai-almog Aug 23, 2026
3486f18
Read the kind's class name from the build, not from what exists
shai-almog Aug 23, 2026
d18b712
Give back a transfer claim the mirror could not honour, and digest th…
shai-almog Aug 23, 2026
96c37b1
Retry a mirrored descriptor the watch could not write, and let it see…
shai-almog Aug 23, 2026
e52904f
Collect mirrored artwork the newest descriptor left behind
shai-almog Aug 23, 2026
6969b21
Send complication updates one at a time, and share to the watch
shai-almog Aug 23, 2026
f83fefd
Keep a complication replacement queued behind the transfer it replaces
shai-almog Aug 23, 2026
b2f1557
Match the transfer, keep staged art, cancel overtaken retries, keep t…
shai-almog Aug 23, 2026
7324dc7
Do not lose a completion that beats its own recording, or reuse a cla…
shai-almog Aug 23, 2026
4b7567e
Keep the phone's accessory families out of the watch extension
shai-almog Aug 23, 2026
0f480b4
Let a complication tick, speak its whole value, and refuse a path for…
shai-almog Aug 23, 2026
daf299f
Validate the remote kind, drop empty blobs, and escape the conditiona…
shai-almog Aug 23, 2026
b7b47fb
Let the portable families key win when it is present, not when it parses
shai-almog Aug 23, 2026
8546f3a
Keep collecting artwork past a bad blob, and wire intents on the watch
shai-almog Aug 23, 2026
5923c47
Order the mirror by publication, not by arrival; quote and key correctly
shai-almog Aug 23, 2026
08ff1a0
Remember the highest mirror sequence we issued, not just the clock
shai-almog Aug 23, 2026
126fd0b
Retry a mirrored withdrawal the watch could not carry out
shai-almog Aug 23, 2026
9f5f272
Move the mirror's high-water mark only when the timeline is installed
shai-almog Aug 23, 2026
88c8ac0
Ask for the reload at the only moment we get, and retry a failed install
shai-almog Aug 23, 2026
afd5c6c
Retain and serialize the mirror retry
shai-almog Aug 23, 2026
aa7af9f
Let a relative complication cross its own target
shai-almog Aug 23, 2026
5bb898e
Tick the long-text body too, and keep a crossing inside its own entry
shai-almog Aug 23, 2026
aede072
Render a future entry as of when it takes over, and name the phone APK
shai-almog Aug 23, 2026
adc3c09
Point the watch's push glue at the watch stub, and cross every relati…
shai-almog Aug 23, 2026
40dc02b
Refuse an explicit null families key instead of defaulting it to the …
shai-almog Aug 23, 2026
a04d6cb
Pair each text with its own node, bound crossings below, sweep after …
shai-almog Aug 23, 2026
3cffc41
Wake at the timeline's end, split the base entry, sweep on read
shai-almog Aug 23, 2026
5b3eea8
Wake at the right moment, through a component that exists
shai-almog Aug 23, 2026
4198629
Let an interval gauge advance, on the Tile and in the complication
shai-almog Aug 23, 2026
53704d3
Keep the watch StubUtil, and give it the method the push glue calls
shai-almog Aug 23, 2026
7501069
Check a remote image against its own name, and stop refreshing what s…
shai-almog Aug 23, 2026
3eae16e
Let the gauge reach full, and tick every text that is displayed
shai-almog Aug 23, 2026
4fe3a99
Sweep stale artwork on every read path, not just one
shai-almog Aug 23, 2026
5693f43
Sample from now onward, and wait for an interval to start
shai-almog Aug 23, 2026
34eb565
Wake the Tile when its interval starts, not throughout the wait
shai-almog Aug 23, 2026
8db952d
Let a ticking title describe the complication too
shai-almog Aug 23, 2026
d82b441
Let a mirrored watch ask the phone for fresh content
shai-almog Aug 23, 2026
9e2c872
Stop the reload request bouncing, and make the hash check actually fire
shai-almog Aug 23, 2026
6408d79
Describe a monochrome complication with its ticking value
shai-almog Aug 23, 2026
f1987c3
Park the waiting complication queue, and let a dormant timer sleep
shai-almog Aug 23, 2026
44a20b3
Do not judge the watch before the session can answer
shai-almog Aug 23, 2026
4e04f11
Serialize publishes of one kind against each other
shai-almog Aug 23, 2026
7428bad
Judge the watch transfer when it is sent, not when it is published
shai-almog Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,13 @@ public static long nextEntryFlip(Map<String, Object> 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
///
Expand All @@ -274,6 +279,17 @@ public static Map<String, Object> layoutForSize(Map<String, Object> timelineDoc,
}
Map<String, Object> layouts = (Map<String, Object>) 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");
}
Expand All @@ -282,15 +298,23 @@ public static Map<String, Object> layoutForSize(Map<String, Object> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
157 changes: 155 additions & 2 deletions CodenameOne/src/com/codename1/surfaces/Surfaces.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -197,19 +198,171 @@ public static void publish(String kindId, WidgetTimeline timeline) {
}
Map<String, byte[]> images = new LinkedHashMap<String, byte[]>();
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<String, Object> PUBLISH_LOCKS = new HashMap<String, Object>();

/// 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.<String, byte[]>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<String, byte[]> images) {
SurfaceBridge b = bridgeInternal();
if (b == null || !b.areWidgetsSupported() || kindId == null || timelineJson == null) {
return;
}
b.publishWidgetTimeline(kindId, timelineJson, Collections.<String, byte[]>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<String, byte[]> safeImageNames(Map<String, byte[]> images) {
if (images == null || images.isEmpty()) {
return Collections.<String, byte[]>emptyMap();
}
Map<String, byte[]> safe = new LinkedHashMap<String, byte[]>();
for (Map.Entry<String, byte[]> 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());
Comment thread
shai-almog marked this conversation as resolved.
Comment thread
shai-almog marked this conversation as resolved.
}
return safe;
}

/// Asks the platform to re-render widgets from their already-published timelines.
Expand Down
6 changes: 5 additions & 1 deletion CodenameOne/src/com/codename1/surfaces/WidgetKind.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading