Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
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
29 changes: 28 additions & 1 deletion CodenameOne/src/com/codename1/surfaces/Surfaces.java
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,39 @@ public static void publish(String kindId, WidgetTimeline timeline) {
/// 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());
b.publishWidgetTimeline(kindId, timelineJson,
images == null ? Collections.<String, byte[]>emptyMap() : images);
}

/// Asks the platform to re-render widgets from their already-published timelines.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,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);
Comment thread
shai-almog marked this conversation as resolved.
CN1SurfaceMirror.onPublished(ctx, kindId, timelineJson, images);
} catch (Throwable t) {
Log.w(TAG, "Failed to publish the timeline of widget kind " + kindId, t);
}
Expand All @@ -124,10 +128,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);
Comment thread
shai-almog marked this conversation as resolved.
// ...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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,13 +45,77 @@ 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.
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);
prefs.edit().putString(TOKEN_KEY, fresh).commit();
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) {
intent.putExtra(EXTRA_TOKEN, token(ctx));
}

/// 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) {
Expand All @@ -61,6 +130,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()
Expand Down
Loading
Loading